邹景立 d2b499d14a refactor: 使用 BC Math 替换浮点运算以提高精度
将多处基于浮点的数值计算替换为 BC Math 字符串运算以避免浮点精度问题,涉及支付、退款、转账、比较与统计逻辑的重构。主要改动包括:

- 将比较与判断替换为 bccomp,累加与合并使用 bcadd,乘以 100 等使用 bcmul;
- 将部分初始数值与统计结果从数值类型改为字符串形式(如 '0.00'),并调整相关返回类型(如 Payment::paidAmount 改为返回 string);
- 修正订单/退款金额计算与超额校验逻辑以使用高精度算术;
- 更新微信支付相关 SDK 调用中金额乘 100 的计算以避免精度误差;
- 在若干插件中用高精度运算替换 floatval/int 转换(包括 SystemQueue、Wemall、Wuma 等);
- 更新文档(readme)添加 BC Math/高精度计算等说明并统一版权年份至 2014-2026;
- 新增 .copilot-commit-message-instructions.md(提交信息规范)。

此改动旨在增强金融/金额相关业务的计算正确性与一致性,避免因浮点运算导致的金额误差。
2026-02-01 13:27:10 +08:00

552 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | Payment Plugin for ThinkAdmin
// +----------------------------------------------------------------------
// | 版权所有 2014~2026 ThinkAdmin [ thinkadmin.top ]
// +----------------------------------------------------------------------
// | 官方网站: https://thinkadmin.top
// +----------------------------------------------------------------------
// | 免责声明 ( https://thinkadmin.top/disclaimer )
// | 会员免费 ( https://thinkadmin.top/vip-introduce )
// +----------------------------------------------------------------------
// | gitee 代码仓库https://gitee.com/zoujingli/think-plugs-payment
// | github 代码仓库https://github.com/zoujingli/think-plugs-payment
// +----------------------------------------------------------------------
declare(strict_types=1);
/**
* +----------------------------------------------------------------------
* | Payment Plugin for ThinkAdmin
* +----------------------------------------------------------------------
* | 版权所有 2014~2026 ThinkAdmin [ thinkadmin.top ]
* +----------------------------------------------------------------------
* | 官方网站: https://thinkadmin.top
* +----------------------------------------------------------------------
* | 开源协议 ( https://mit-license.org )
* | 免责声明 ( https://thinkadmin.top/disclaimer )
* | 会员特权 ( https://thinkadmin.top/vip-introduce )
* +----------------------------------------------------------------------
* | gitee 代码仓库https://gitee.com/zoujingli/ThinkAdmin
* | github 代码仓库https://github.com/zoujingli/ThinkAdmin
* +----------------------------------------------------------------------
*/
namespace plugin\payment\service;
use plugin\account\service\Account;
use plugin\account\service\contract\AccountInterface;
use plugin\payment\model\PluginPaymentConfig;
use plugin\payment\model\PluginPaymentRecord;
use plugin\payment\model\PluginPaymentRefund;
use plugin\payment\service\contract\PaymentInterface;
use plugin\payment\service\contract\PaymentResponse;
use plugin\payment\service\payment\AliPayment;
use plugin\payment\service\payment\BalancePayment;
use plugin\payment\service\payment\CouponPayment;
use plugin\payment\service\payment\EmptyPayment;
use plugin\payment\service\payment\IntegralPayment;
use plugin\payment\service\payment\VoucherPayment;
use plugin\payment\service\payment\WechatPayment;
use think\admin\Exception;
use think\admin\extend\CodeExtend;
use think\db\Query;
use think\db\Raw;
/**
* 支付配置调度器.
* @class Payment
*/
abstract class Payment
{
// 内置支付类型
public const EMPTY = 'empty';
public const COUPON = 'coupon';
public const BALANCE = 'balance';
public const VOUCHER = 'voucher';
public const INTEGRAL = 'integral';
// 汇聚支付参数
public const JOINPAY_GZH = 'joinpay_gzh';
public const JOINPAY_XCX = 'joinpay_xcx';
// 微信商户支付
public const WECHAT_APP = 'wechat_app';
public const WECHAT_GZH = 'wechat_gzh';
public const WECHAT_XCX = 'wechat_xcx';
public const WECHAT_WAP = 'wechat_wap';
public const WECHAT_QRC = 'wechat_qrc';
// 支付宝支付参数
public const ALIAPY_APP = 'alipay_app';
public const ALIPAY_WAP = 'alipay_wap';
public const ALIPAY_WEB = 'alipay_web';
// 已禁用的支付方式
private static $denys;
private static $cakey = 'plugin.payment.denys';
// 支付方式配置
private static $types = [
// 空支付,金额为零时自动完成支付
self::EMPTY => [
'name' => '订单无需支付',
'class' => EmptyPayment::class,
'status' => 1,
'account' => [],
],
// 优惠券抵扣,只维护编号+金额
self::COUPON => [
'name' => '优惠券抵扣',
'class' => CouponPayment::class,
'status' => 1,
'account' => [
Account::WAP,
Account::WEB,
Account::WXAPP,
Account::WECHAT,
Account::IOSAPP,
Account::ANDROID,
],
],
// 余额支付,使用账户余额支付
self::BALANCE => [
'name' => '账户余额支付',
'class' => BalancePayment::class,
'status' => 1,
'account' => [
Account::WAP,
Account::WEB,
Account::WXAPP,
Account::WECHAT,
Account::IOSAPP,
Account::ANDROID,
],
],
// 积分抵扣,使用账户积分抵扣
self::INTEGRAL => [
'name' => '账户积分抵扣',
'class' => IntegralPayment::class,
'status' => 1,
'account' => [
Account::WAP,
Account::WEB,
Account::WXAPP,
Account::WECHAT,
Account::IOSAPP,
Account::ANDROID,
],
],
// 凭证支付,上传凭证后台审核支付
self::VOUCHER => [
'name' => '单据凭证支付',
'class' => VoucherPayment::class,
'status' => 1,
'account' => [
Account::WAP,
Account::WEB,
Account::WXAPP,
Account::WECHAT,
Account::IOSAPP,
Account::ANDROID,
],
],
// 微信支付配置(不需要的直接注释)
self::WECHAT_WAP => [
'name' => '微信WAP支付',
'class' => WechatPayment::class,
'status' => 1,
'account' => [Account::WAP],
],
self::WECHAT_APP => [
'name' => '微信APP支付',
'class' => WechatPayment::class,
'status' => 1,
'account' => [Account::IOSAPP, Account::ANDROID],
],
self::WECHAT_XCX => [
'name' => '微信小程序支付',
'class' => WechatPayment::class,
'status' => 1,
'account' => [Account::WXAPP],
],
self::WECHAT_GZH => [
'name' => '微信公众号支付',
'class' => WechatPayment::class,
'status' => 1,
'account' => [Account::WECHAT],
],
self::WECHAT_QRC => [
'name' => '微信二维码支付',
'class' => WechatPayment::class,
'status' => 1,
'account' => [Account::WEB],
],
// 支付宝支持配置(不需要的直接注释)
self::ALIPAY_WAP => [
'name' => '支付宝WAP支付',
'class' => AliPayment::class,
'status' => 1,
'account' => [Account::WAP],
],
self::ALIPAY_WEB => [
'name' => '支付宝WEB支付',
'class' => AliPayment::class,
'status' => 1,
'account' => [Account::WEB],
],
self::ALIAPY_APP => [
'name' => '支付宝APP支付',
'class' => AliPayment::class,
'status' => 1,
'account' => [Account::ANDROID, Account::IOSAPP],
],
// 汇聚支持配置(不需要的直接注释)
/* self::JOINPAY_XCX => [
'name' => '汇聚小程序支付',
'class' => JoinPayment::class,
'status' => 1,
'account' => [Account::WXAPP],
],
self::JOINPAY_GZH => [
'name' => '汇聚公众号支付',
'class' => JoinPayment::class,
'status' => 1,
'account' => [Account::WECHAT],
], */
];
/**
* 实例化支付配置.
* @param string $code 编号或类型
* @throws Exception
*/
public static function mk(string $code): PaymentInterface
{
if (in_array($code, [self::EMPTY, self::COUPON, self::BALANCE, self::INTEGRAL])) {
if (empty(self::$types[$code]['status'])) {
throw new Exception(self::typeName($code) . '已被禁用!');
}
return self::$types[$code]['class']::mk($code, $code, []);
}
[$type, $attr, $params] = self::params($code);
if (self::typeStatus($type)) {
return $attr['class']::mk($code, $type, $params);
}
throw new Exception(self::typeName($type) . '已被禁用!');
}
/**
* 获取支付参数.
* @param string $code 支付配置编号
* @param array $config 支付配置参数
* @return array [type, attr, params]
* @throws Exception
*/
public static function params(string $code, array $config = []): array
{
try {
if (empty($config)) {
$map = ['code' => $code, 'status' => 1, 'deleted' => 0];
$config = PluginPaymentConfig::mk()->where($map)->findOrEmpty()->toArray();
}
if (empty($config)) {
throw new Exception("支付配置[#{$code}]参数异常!");
}
$params = is_string($config['content']) ? @json_decode($config['content'], true) : $config['content'];
if (empty($params)) {
throw new Exception("支付配置[#{$code}]参数无效!");
}
if (empty(self::$types[$config['type']]['status'])) {
throw new Exception("支付配置[@{$config['type']}]未启用!");
}
return [$config['type'], self::$types[$config['type']], $params];
} catch (\Exception $exception) {
throw new Exception($exception->getMessage(), $exception->getCode());
}
}
/**
* 添加支付方式.
* @param string $type 支付编码
* @param string $name 支付名称
* @param string $class 处理机制
* @param array $account 绑定终端
* @return array[]
*/
public static function add(string $type, string $name, string $class, array $account = []): array
{
if (class_exists($class) && in_array(PaymentInterface::class, class_implements($class))) {
self::$types[$type] = ['name' => $name, 'class' => $class, 'status' => 1, 'account' => $account];
}
return self::types();
}
/**
* 设置方式状态
* @param string $type 支付编码
* @param int $status 支付状态
*/
public static function set(string $type, int $status): bool
{
if (isset(self::$types[$type])) {
self::$types[$type]['status'] = $status;
return true;
}
return false;
}
/**
* 保存支付方式.
* @return int|true
* @throws Exception
*/
public static function save()
{
self::$denys = [];
foreach (self::types() as $k => $v) {
if (empty($v['status'])) {
self::$denys[] = $k;
}
}
return sysdata(self::$cakey, self::$denys);
}
/**
* 获取支付方式.
*/
public static function types(?int $status = null): array
{
try {
[$all, $binds] = [[], array_keys(Account::types(1))];
foreach (self::init() as $type => $item) {
if (is_null($status) || $status == $item['status']) {
if (array_intersect($item['account'], $binds)) {
$all[$type] = $item;
}
}
}
return $all;
} catch (\Exception $exception) {
return [];
}
}
/**
* 通过接口类型筛选支付方式.
* @param string $account 指定终端
* @param bool $getfull 读取参数
*/
public static function typesByAccess(string $account, bool $getfull = false): array
{
$types = [];
foreach (self::types(1) as $type => $attr) {
if (in_array($account, $attr['account'])) {
$types[$type] = $attr['name'];
}
}
if ($getfull) {
$items = [];
$query = PluginPaymentConfig::mk()->field('type,code,name,cover,content');
$query->where(['status' => 1, 'deleted' => 0])->whereIn('type', array_keys($types));
foreach ($query->order('sort desc,id desc')->cursor() as $item) {
$item['qrcode'] = $item['content']['voucher_qrcode'] ?? '';
unset($item['content']);
$items[] = $item->toArray();
}
return $items;
}
return $types;
}
/**
* 读取支付配置.
*/
public static function items(): array
{
$map = ['status' => 1, 'deleted' => 0];
return PluginPaymentConfig::mk()->where($map)->order('sort desc,id desc')->column('type,code,name', 'code');
}
/**
* 获取支付类型名称.
*/
public static function typeName(string $type): string
{
return self::$types[$type]['name'] ?? $type;
}
/**
* 判断支付类型状态
*/
public static function typeStatus(string $type): bool
{
return !empty(self::$types[$type]['status']);
}
/**
* 判断是否完成支付.
* @param string $orderNo 原订单号
* @param string $amount 需支付金额
*/
public static function isPayed(string $orderNo, string $amount): bool
{
$paidAmount = strval(self::paidAmount($orderNo));
return bccomp($paidAmount, $amount, 2) >= 0;
}
/**
* 发起订单整体退款.
* @throws Exception
*/
public static function refund(string $orderNo)
{
$items = PluginPaymentRecord::mq()->where(function (Query $query) {
$query->whereOr([['payment_status', '=', 1], ['audit_status', '>', '0']]);
})->where(['order_no' => $orderNo])->column('code,channel_code,payment_amount');
foreach ($items as $item) {
static::mk($item['channel_code'])->refund($item['code'], $item['payment_amount']);
}
}
/**
* 获取已支付金额.
* @param string $orderNo 订单单号
* @param bool $realtime 有效金额
*/
public static function paidAmount(string $orderNo, bool $realtime = false): string
{
$map = ['order_no' => $orderNo, 'payment_status' => 1];
$raw = new Raw($realtime ? 'payment_amount - refund_amount' : 'payment_amount');
return bcadd('0.00', strval(PluginPaymentRecord::mk()->where($map)->sum($raw)), 2);
}
/**
* 订单剩余支付金额.
* @param mixed $orderAmount
*/
public static function leaveAmount(string $orderNo, $orderAmount): string
{
$orderAmountFloat = strval($orderAmount);
$paidAmount = strval(self::paidAmount($orderNo, true));
return bcsub($orderAmountFloat, $paidAmount, 2);
}
/**
* 统计三种模式支付金额.
* @return array ['amount'=>0,'payment'=>0,'balance'=>0,'integral'=>0]
*/
public static function totalPaymentAmount(string $orderNo): array
{
$total = ['amount' => '0.00', 'payment' => '0.00', 'balance' => '0.00', 'integral' => '0.00'];
try {
PluginPaymentRecord::mk()->where(['order_no' => $orderNo, 'payment_status' => 1])->field([
'channel_type',
'sum(payment_amount-refund_amount)' => 'amount',
'sum(used_payment-refund_payment)' => 'payment',
'sum(used_balance-refund_balance)' => 'balance',
'sum(used_integral-refund_integral)' => 'integral',
])->group('channel_type')->select()->map(static function (PluginPaymentRecord $item) use (&$total) {
$total['amount'] = bcadd($total['amount'], strval($item->getAttr('amount')), 2);
$type = $item->getAttr('channel_type');
if (!in_array($type, [self::INTEGRAL, self::BALANCE])) {
$type = 'payment';
}
$total[$type] = bcadd($total[$type], strval($item[$type] ?? '0.00'), 2);
});
} catch (\Exception $exception) {
trace_file($exception);
}
return $total;
}
/**
* 根据支付号统计退款金额.
* @return array ['amount'=>0,'payment'=>0,'balance'=>0,'integral'=>0]
*/
public static function totalRefundAmount(string $pCode): array
{
$total = ['amount' => '0.00', 'payment' => '0.00', 'balance' => '0.00', 'integral' => '0.00'];
try {
PluginPaymentRefund::mk()->where(['record_code' => $pCode, 'refund_status' => [0, 1]])->field([
'refund_account', 'sum(refund_amount) amount', 'sum(used_payment)' => 'payment', 'sum(used_balance)' => 'balance', 'sum(used_integral)' => 'integral',
])->group('refund_account')->select()->map(static function (PluginPaymentRefund $item) use (&$total) {
$total['amount'] = bcadd($total['amount'], strval($item->getAttr('amount')), 2);
$type = $item->getAttr('refund_account');
if (!in_array($type, [self::INTEGRAL, self::BALANCE])) {
$type = 'payment';
}
$total[$type] = bcadd($total[$type], strval($item[$type] ?? '0.00'), 2);
});
} catch (\Exception $exception) {
trace_file($exception);
}
return $total;
}
/**
* 生成支付单号.
*/
public static function withPaymentCode(): string
{
do {
$data = ['code' => CodeExtend::uniqidNumber(16, 'P')];
} while (PluginPaymentRecord::mk()->master()->where($data)->findOrEmpty()->isExists());
return $data['code'];
}
/**
* 生成退款单号.
*/
public static function withRefundCode(): string
{
do {
$data = ['code' => CodeExtend::uniqidNumber(16, 'R')];
} while (PluginPaymentRefund::mk()->master()->where($data)->findOrEmpty()->isExists());
return $data['code'];
}
/**
* 创建订单空支付.
* @param string $orderNo 订单单号
* @param string $title 订单标题
* @param string $remark 订单描述
* @throws Exception
*/
public static function emptyPayment(AccountInterface $account, string $orderNo, string $title = '商城订单支付', string $remark = '订单金额为0无需要支付'): PaymentResponse
{
return self::mk(self::EMPTY)->create($account, $orderNo, $title, '0.00', '0.00', $remark);
}
/**
* 初始化数据状态
* @return array[]
*/
private static function init(): array
{
if (is_null(self::$denys)) {
try {
self::$denys = sysdata(self::$cakey);
foreach (self::$types as $type => &$item) {
$item['status'] = intval(!in_array($type, self::$denys));
}
} catch (\Exception $exception) {
}
}
return self::$types;
}
}