使用 JavaScript 实现支付系统是否存在风险?
结论
客户端 JavaScript 和服务端 PHP 可以用来开发支付系统。语言本身不会让系统“不合法”,也不意味着系统一定不安全。常见风险主要来自以下问题:
- 使用二进制浮点数计算金额;
- 信任客户端提交的价格、折扣或支付结果;
- 没有明确规定舍入方式,或缺少幂等控制和并发保护;
- 未满足当地法规、支付渠道和数据安全要求。
支付的核心逻辑必须放在服务端。金额应使用整数最小货币单位(如“分”)、定点十进制类型,或经过验证的十进制运算库,不能直接依赖 JavaScript Number 或 PHP float。
为什么浮点数不适合金额计算
JavaScript 的 Number 和 PHP 的 float 通常采用 IEEE 754 双精度浮点格式。许多十进制小数无法用二进制精确表示:
0.1 + 0.2 === 0.3 // false
0.1 + 0.2 // 0.30000000000000004
这不是 IEEE 754 的缺陷,而是二进制与十进制的表示方式不同。在普通业务计算中,这类误差可能很小,但到了支付、退款、分账和账务核对环节,就可能引发实际问题。
下面的写法并不可靠:
<?php
$total = 19.90 * 3;
$cents = (int) ($total * 100);
金额经过多次乘除、税率计算或折扣叠加后,误差可能进入最后的取整操作。只在末尾调用一次 round() 也不能自动解决问题,因为系统还必须明确:
- 在哪个计算步骤舍入;
- 采用四舍五入、银行家舍入还是其他规则;
- 如何处理负数和退款;
- 税费按单项计算还是按订单总额计算;
- 分摊结果无法整除时,余数由谁承担。
这些都是需要显式定义的业务规则。
推荐的金额表示方式
使用整数最小货币单位
对于通常保留两位小数的货币,可以把金额保存为“分”:
const unitPriceCents = 1990;
const quantity = 3;
const totalCents = unitPriceCents * quantity;
console.log(totalCents); // 5970
PHP 服务端也使用整数:
<?php
declare(strict_types=1);
$unitPriceCents = 1990;
$quantity = 3;
$totalCents = $unitPriceCents * $quantity;
数据库可以用 BIGINT 保存最小单位:
CREATE TABLE payments (
id BIGINT PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL,
provider_transaction_id VARCHAR(128),
UNIQUE (order_id),
UNIQUE (provider_transaction_id)
);
并非所有货币都有两位小数。金额模型最好同时保存:
- 整数金额;
- ISO 4217 货币代码;
- 业务采用的最小单位或小数位规则。
具体的小数位数和舍入方式,应以支付渠道、会计规则和适用法规为准。
必须处理十进制时使用定点运算
如果业务涉及税率、汇率、利息或高精度分账,只用整数计算可能不够方便。此时可以选择:
- 数据库的
DECIMAL或NUMERIC; - PHP 的 BCMath 扩展;
- 经过审查的任意精度十进制库。
下面是使用 BCMath 的例子:
<?php
declare(strict_types=1);
$unitPrice = '19.90';
$quantity = '3';
$total = bcmul($unitPrice, $quantity, 2);
echo $total; // 59.70
金额应以字符串形式传入。下面的代码会先产生浮点值,再将其转换为字符串,也就失去了使用十进制库的意义:
$total = bcmul(19.90, 3, 2); // 不推荐
BCMath 的 scale 用来控制结果保留的小数位数,但不能代替业务上的舍入规则。如果需要四舍五入,应根据项目采用的规则单独实现并测试。
不要信任客户端金额
JavaScript 运行在用户设备上,代码、请求参数和页面状态都可能被修改。客户端可以显示价格,但最终应付金额不能由客户端决定。
下面是一种不安全的接口设计:
{
"productId": "P1001",
"amount": 1
}
如果服务端直接使用客户端提交的 amount 创建支付订单,攻击者就有机会篡改金额。
更合理的做法是只提交业务意图:
{
"productId": "P1001",
"quantity": 2,
"couponCode": "SUMMER"
}
服务端收到请求后,应重新完成整个计算过程:
<?php
declare(strict_types=1);
function calculateOrderTotal(
int $unitPriceMinor,
int $quantity,
int $discountMinor
): int {
if ($unitPriceMinor < 0) {
throw new InvalidArgumentException('Invalid unit price');
}
if ($quantity < 1 || $quantity > 100) {
throw new InvalidArgumentException('Invalid quantity');
}
if ($discountMinor < 0) {
throw new InvalidArgumentException('Invalid discount');
}
$subtotal = $unitPriceMinor * $quantity;
if ($discountMinor > $subtotal) {
throw new InvalidArgumentException('Discount exceeds subtotal');
}
return $subtotal - $discountMinor;
}
价格应从服务端数据库读取,优惠券也应由服务端验证。库存、税费和运费同样需要在服务端确定。客户端计算的总价只用于即时展示。
如果业务允许用户输入金额,例如充值或捐款,可以把金额作为十进制字符串提交,再由服务端严格解析,避免先转换为浮点数:
<?php
declare(strict_types=1);
function parseAmountToMinor(string $amount): int
{
if (!preg_match('/^(0|[1-9]\d{0,9})(?:\.(\d{1,2}))?$/', $amount, $matches)) {
throw new InvalidArgumentException('Invalid amount format');
}
$major = $matches[1];
$fraction = str_pad($matches[2] ?? '', 2, '0');
$minor = ((int) $major * 100) + (int) $fraction;
if ($minor <= 0) {
throw new InvalidArgumentException('Amount must be positive');
}
return $minor;
}
这个例子假定货币使用两位小数,并限制了整数部分的长度。实际系统需要根据支持的货币、单笔限额和 PHP 整数范围进行调整。
JavaScript 中的整数也有范围限制
JavaScript Number 只能精确表示不超过 Number.MAX_SAFE_INTEGER 的整数:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
大多数单笔支付换算成“分”后不会接近这个范围,但累计账务、批量统计或高精度资产可能超过它。需要处理更大的整数时,可以使用 BigInt:
const amountMinor = 9007199254740993n;
const feeMinor = 100n;
const totalMinor = amountMinor + feeMinor;
不过,BigInt 不能直接通过 JSON.stringify() 序列化:
JSON.stringify({ amount: 100n }); // TypeError
通过接口传输大整数时,建议使用十进制字符串:
const payload = {
amountMinor: amountMinor.toString(),
currency: "CNY"
};
服务端必须检查字符串格式和取值范围,不能在没有验证的情况下直接强制转换。
利用 PHP 的类型约束,但不要过度依赖它
PHP 可以启用严格类型:
<?php
declare(strict_types=1);
function createPayment(string $orderId, int $amountMinor, string $currency): void
{
if ($orderId === '') {
throw new InvalidArgumentException('Order ID is required');
}
if ($amountMinor <= 0) {
throw new InvalidArgumentException('Amount must be positive');
}
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
throw new InvalidArgumentException('Invalid currency');
}
// 创建支付记录
}
declare(strict_types=1) 可以减少隐式类型转换带来的意外,但不能代替输入验证。来自 HTTP、JSON、数据库和第三方回调的数据仍需检查:
- 字段是否存在;
- 格式是否正确;
- 数值是否在允许范围内;
- 系统是否支持该货币;
- 订单是否属于当前用户;
- 当前订单状态是否允许付款或退款。
还可以使用静态分析工具、不可变值对象和自动化测试来减少实现错误,但系统最终是否安全,仍取决于边界处的校验是否完整。
支付流程还需要处理哪些风险
幂等性
用户重复点击、网络重试和支付渠道重复通知,都可能让同一操作执行多次。创建支付、确认付款和退款接口应使用幂等键。
<?php
declare(strict_types=1);
function buildIdempotencyKey(string $orderId, string $operation): string
{
return hash('sha256', $orderId . ':' . $operation);
}
数据库还应设置唯一约束。不能只在应用层“先查询再插入”,否则并发请求可能同时通过检查。
支付状态必须以服务端核验为准
浏览器跳转到“支付成功”页面,不代表服务端可以直接把订单标记为已付款。通常应按以下流程处理:
- 服务端创建本地订单;
- 服务端调用支付渠道创建支付;
- 客户端完成付款;
- 服务端验证支付渠道通知的签名;
- 必要时主动查询支付渠道,确认支付状态;
- 核对订单号、金额、币种和商户身份;
- 在事务中更新支付状态和业务状态。
验签方式和回调字段必须严格按照所用支付渠道的官方文档实现。
并发与事务
支付确认、取消和退款可能同时发生。关键状态变更应配合数据库事务、唯一约束,以及适当的行锁或条件更新。例如:
UPDATE payments
SET status = 'paid'
WHERE order_id = :order_id
AND status = 'pending';
应用程序还要检查受影响的行数,确认状态确实按预期发生了变化。
日志与敏感数据
支付系统需要保留可审计的记录,但日志中不能写入完整银行卡号、安全码、私钥或访问令牌等敏感信息。密码和密钥应保存在受控的密钥管理设施中,不能放进前端代码或代码仓库。
系统能否保存某类支付数据,以及需要满足哪些认证或合规要求,取决于所在司法辖区、业务模式和接入方式。通常可以优先采用成熟支付服务商提供的托管收银台或令牌化方案,缩小系统直接接触敏感支付数据的范围。
建议的实施原则
一个稳妥的 JavaScript/PHP 支付系统至少应做到:
- 客户端只负责展示和发起请求;
- 服务端重新计算并确认最终金额;
- 金额使用整数最小单位或定点十进制;
- 不使用
float或Number完成关键账务计算; - 明确定义税费、折扣、退款和分账的舍入规则;
- 检查所有外部输入的格式、范围和业务状态;
- 使用严格类型、静态分析和自动化测试减少实现错误;
- 为创建支付、回调处理和退款操作提供幂等控制;
- 使用数据库事务和唯一约束处理并发;
- 验证支付渠道回调的签名,并核对金额和币种;
- 不在前端保存密钥,也不把浏览器跳转结果当作付款凭证;
- 根据适用地区和支付方式完成必要的安全与合规评估。
JavaScript 加 PHP 是可行的支付系统技术组合。开发时应把客户端当作不可信环境,在服务端使用可靠的金额模型,并妥善处理支付状态、并发、审计和合规问题。不能把支付的正确性寄托在语言默认的数值行为和宽松的类型转换上。
备注:内容仅供参考。
