2026-10-27 16:19:46 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace app\common\service;
|
|
|
|
|
|
|
|
|
|
|
|
use app\common\model\User;
|
|
|
|
|
|
use EasyWeChat\Kernel\Encryptor;
|
|
|
|
|
|
use think\Cache;
|
|
|
|
|
|
use think\Log;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 小程序虚拟支付(代币充值)
|
|
|
|
|
|
* 文档:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment.html
|
|
|
|
|
|
* 客户端:wx.requestVirtualPayment,mode=short_series_coin
|
|
|
|
|
|
* 服务器:/xpay/query_order 确认现金单,/xpay/currency_pay 扣减代币
|
|
|
|
|
|
*/
|
|
|
|
|
|
class VirtualPayService
|
|
|
|
|
|
{
|
|
|
|
|
|
const SESSION_PREFIX = 'wx_session_key:';
|
|
|
|
|
|
const TRADE_PREFIX = 'vp_trade:';
|
|
|
|
|
|
const LATEST_PREFIX = 'vp_order_latest:';
|
2026-10-27 18:10:51 +08:00
|
|
|
|
const PLATFORM_PREFIX = 'vp_pay_platform:';
|
2026-10-27 16:19:46 +08:00
|
|
|
|
const CACHE_TTL = 1296000; // 15天
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 登录成功后缓存 session_key,用户态签名要用
|
|
|
|
|
|
* @param int $userId
|
|
|
|
|
|
* @param string $sessionKey
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function saveSessionKey($userId, $sessionKey)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!$userId || $sessionKey === '') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
Cache::set(self::SESSION_PREFIX . $userId, $sessionKey, self::CACHE_TTL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 18:10:51 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 记下客户拉起支付时的终端。iOS 单独记,鸿蒙、Windows 等按安卓费率
|
|
|
|
|
|
* @param string $orderNo
|
|
|
|
|
|
* @param string $platform
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function rememberPayPlatform($orderNo, $platform)
|
|
|
|
|
|
{
|
|
|
|
|
|
$normalized = self::normalizePayPlatform($platform);
|
|
|
|
|
|
if ($orderNo !== '' && $normalized !== '') {
|
|
|
|
|
|
Cache::set(self::PLATFORM_PREFIX . $orderNo, $normalized, self::CACHE_TTL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param string $orderNo
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function payPlatformOf($orderNo)
|
|
|
|
|
|
{
|
|
|
|
|
|
$cached = $orderNo ? Cache::get(self::PLATFORM_PREFIX . $orderNo) : '';
|
|
|
|
|
|
|
|
|
|
|
|
return self::normalizePayPlatform($cached);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param string $platform
|
|
|
|
|
|
* @return string android|ios|空
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function normalizePayPlatform($platform)
|
|
|
|
|
|
{
|
|
|
|
|
|
$platform = strtolower(trim((string)$platform));
|
|
|
|
|
|
if ($platform === 'ios' || $platform === 'iphone') {
|
|
|
|
|
|
return 'ios';
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($platform === '') {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return 'android';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:19:46 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 生成 wx.requestVirtualPayment 参数。signData 必须原样传给客户端,不能再 JSON.stringify
|
|
|
|
|
|
* @param string $orderNo
|
|
|
|
|
|
* @param float|string $amountYuan
|
|
|
|
|
|
* @param string $openid
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function createClientPay($orderNo, $amountYuan, $openid)
|
|
|
|
|
|
{
|
2026-10-27 16:51:41 +08:00
|
|
|
|
$offerId = (string)(self::payConfig()['offer_id'] ?? '');
|
2026-10-27 16:19:46 +08:00
|
|
|
|
if ($offerId === '') {
|
2026-10-27 16:47:23 +08:00
|
|
|
|
self::logMissingOfferId();
|
2026-10-27 16:19:46 +08:00
|
|
|
|
throw new \Exception('未配置虚拟支付 OfferID');
|
|
|
|
|
|
}
|
|
|
|
|
|
$buyQuantity = self::coinQuantity($amountYuan);
|
|
|
|
|
|
$outTradeNo = 'V' . date('ymdHis') . substr(md5(uniqid('', true)), 0, 10);
|
|
|
|
|
|
$signData = json_encode([
|
|
|
|
|
|
'offerId' => $offerId,
|
|
|
|
|
|
'buyQuantity' => $buyQuantity,
|
|
|
|
|
|
'env' => self::env(),
|
|
|
|
|
|
'currencyType' => 'CNY',
|
|
|
|
|
|
'outTradeNo' => $outTradeNo,
|
|
|
|
|
|
'attach' => (string)$orderNo,
|
|
|
|
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
|
|
if ($signData === false) {
|
|
|
|
|
|
throw new \Exception('虚拟支付参数编码失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
$sessionKey = self::sessionKeyByOpenid($openid);
|
|
|
|
|
|
Cache::set(self::TRADE_PREFIX . $outTradeNo, (string)$orderNo, self::CACHE_TTL);
|
|
|
|
|
|
Cache::set(self::LATEST_PREFIX . $orderNo, $outTradeNo, self::CACHE_TTL);
|
|
|
|
|
|
Log::info('虚拟支付下单 ' . json_encode([
|
|
|
|
|
|
'order_no' => $orderNo,
|
|
|
|
|
|
'out_trade_no' => $outTradeNo,
|
|
|
|
|
|
'buy_quantity' => $buyQuantity,
|
|
|
|
|
|
'env' => self::env(),
|
|
|
|
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'mode' => 'short_series_coin',
|
|
|
|
|
|
'signData' => $signData,
|
|
|
|
|
|
'paySig' => hash_hmac('sha256', 'requestVirtualPayment&' . $signData, self::appKey()),
|
|
|
|
|
|
'signature' => hash_hmac('sha256', $signData, $sessionKey),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 现金单已支付时扣减代币并入账。没有可确认的支付单时返回 false,调用方再拉起新的支付
|
|
|
|
|
|
* @param string $orderNo
|
|
|
|
|
|
* @param string $openid
|
|
|
|
|
|
* @return bool
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function settleIfPaid($orderNo, $openid)
|
|
|
|
|
|
{
|
|
|
|
|
|
$order = OrderService::getOne(['order_no' => $orderNo]);
|
|
|
|
|
|
if (!$order) {
|
|
|
|
|
|
throw new \Exception('订单不存在');
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((int)$order->order_status !== OrderService::ORDER_STATUS_WAIT_PAY) {
|
|
|
|
|
|
return in_array((int)$order->order_status, [
|
|
|
|
|
|
OrderService::ORDER_STATUS_WAIT_SHIP,
|
|
|
|
|
|
OrderService::ORDER_STATUS_SHIPPED,
|
|
|
|
|
|
OrderService::ORDER_STATUS_FINISHED,
|
|
|
|
|
|
], true);
|
|
|
|
|
|
}
|
|
|
|
|
|
$outTradeNo = Cache::get(self::LATEST_PREFIX . $orderNo);
|
|
|
|
|
|
if (!$outTradeNo) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
$res = self::queryOrder($openid, $outTradeNo);
|
|
|
|
|
|
if ((int)($res['errcode'] ?? -1) !== 0) {
|
|
|
|
|
|
Log::info('虚拟支付查单未支付 ' . json_encode([
|
|
|
|
|
|
'order_no' => $orderNo,
|
|
|
|
|
|
'out_trade_no' => $outTradeNo,
|
|
|
|
|
|
'res' => $res,
|
|
|
|
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
$wxOrder = $res['order'] ?? [];
|
|
|
|
|
|
$status = (int)($wxOrder['status'] ?? 0);
|
|
|
|
|
|
// 2已支付待发货 3发货中 4已发货。代币由平台入账,这三种都表示现金已支付
|
|
|
|
|
|
if (!in_array($status, [2, 3, 4], true)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
self::deductCoins($order, $openid, $wxOrder);
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理虚拟支付消息推送
|
|
|
|
|
|
* @param array $msg
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function handleMessage(array $msg)
|
|
|
|
|
|
{
|
|
|
|
|
|
$event = (string)($msg['Event'] ?? '');
|
|
|
|
|
|
if ($event === 'xpay_coin_pay_notify') {
|
|
|
|
|
|
self::finishFromCoinNotify($msg);
|
|
|
|
|
|
return ['ErrCode' => 0, 'ErrMsg' => 'success'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($event === 'xpay_refund_notify') {
|
|
|
|
|
|
self::handleRefundNotify($msg);
|
|
|
|
|
|
return ['ErrCode' => 0, 'ErrMsg' => 'success'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($event === 'xpay_subscribe_ios_refund_query_notify') {
|
|
|
|
|
|
return self::iosRefundQuery($msg);
|
|
|
|
|
|
}
|
|
|
|
|
|
Log::info('虚拟支付推送 ' . $event . ' ' . json_encode($msg, JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
|
|
|
|
|
|
return ['ErrCode' => 0, 'ErrMsg' => 'success'];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解密安全模式下的消息,返回明文字符串
|
|
|
|
|
|
* @param string $encrypt
|
|
|
|
|
|
* @param string $msgSignature
|
|
|
|
|
|
* @param string $nonce
|
|
|
|
|
|
* @param string $timestamp
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function decryptMessage($encrypt, $msgSignature, $nonce, $timestamp)
|
|
|
|
|
|
{
|
2026-10-27 16:51:41 +08:00
|
|
|
|
$payConfig = self::payConfig();
|
|
|
|
|
|
$wechat = config('common.wechat');
|
|
|
|
|
|
$token = (string)($payConfig['msg_token'] ?? '');
|
|
|
|
|
|
$aesKey = (string)($payConfig['aes_key'] ?? '');
|
|
|
|
|
|
$appId = is_array($wechat) ? (string)($wechat['app_id'] ?? '') : '';
|
2026-10-27 16:19:46 +08:00
|
|
|
|
if ($token === '' || $aesKey === '' || $appId === '') {
|
|
|
|
|
|
throw new \Exception('未配置虚拟支付消息加解密参数');
|
|
|
|
|
|
}
|
|
|
|
|
|
$encryptor = new Encryptor($appId, $token, $aesKey);
|
|
|
|
|
|
|
|
|
|
|
|
return $encryptor->decrypt($encrypt, $msgSignature, $nonce, $timestamp);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param string $openid
|
|
|
|
|
|
* @param string $outTradeNo
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function queryOrder($openid, $outTradeNo)
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::postXpay('/xpay/query_order', [
|
|
|
|
|
|
'openid' => $openid,
|
|
|
|
|
|
'env' => self::env(),
|
|
|
|
|
|
'order_id' => (string)$outTradeNo,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 校验现金金额后扣减代币,再把业务订单标成已支付
|
|
|
|
|
|
* @param \think\Model $order
|
|
|
|
|
|
* @param string $openid
|
|
|
|
|
|
* @param array $wxOrder
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function deductCoins($order, $openid, array $wxOrder)
|
|
|
|
|
|
{
|
|
|
|
|
|
$paidFee = (int)($wxOrder['paid_fee'] ?? 0);
|
|
|
|
|
|
if ($paidFee <= 0) {
|
|
|
|
|
|
$paidFee = (int)($wxOrder['order_fee'] ?? 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
$expect = self::amountFen($order->payment_amount);
|
|
|
|
|
|
if ($paidFee !== $expect) {
|
|
|
|
|
|
Log::error('虚拟支付金额不一致 ' . json_encode([
|
|
|
|
|
|
'order_no' => $order->order_no,
|
|
|
|
|
|
'paid_fee' => $paidFee,
|
|
|
|
|
|
'expect' => $expect,
|
|
|
|
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
throw new \Exception('支付金额与订单不一致');
|
|
|
|
|
|
}
|
|
|
|
|
|
$buyQuantity = self::coinQuantity($order->payment_amount);
|
|
|
|
|
|
$res = self::postXpay('/xpay/currency_pay', [
|
|
|
|
|
|
'openid' => $openid,
|
|
|
|
|
|
'env' => self::env(),
|
|
|
|
|
|
'user_ip' => self::userIp(),
|
|
|
|
|
|
'amount' => $buyQuantity,
|
|
|
|
|
|
'order_id' => (string)$order->order_no,
|
|
|
|
|
|
'payitem' => json_encode([[
|
|
|
|
|
|
'productid' => 'order',
|
|
|
|
|
|
'unit_price' => $buyQuantity,
|
|
|
|
|
|
'quantity' => 1,
|
|
|
|
|
|
]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
|
|
|
|
'remark' => '订单' . $order->order_no,
|
|
|
|
|
|
], self::sessionKeyByOpenid($openid));
|
|
|
|
|
|
$errcode = (int)($res['errcode'] ?? -1);
|
|
|
|
|
|
// 268490004:重复扣减,上一笔已经成功
|
|
|
|
|
|
if ($errcode !== 0 && $errcode !== 268490004) {
|
|
|
|
|
|
if ($errcode === 268490006) {
|
|
|
|
|
|
throw new \Exception('支付确认中,请稍后在订单里再次点击支付,不会重复扣款');
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new \Exception('代币扣减失败:' . ($res['errmsg'] ?? '未知错误'));
|
|
|
|
|
|
}
|
|
|
|
|
|
$transactionId = (string)($wxOrder['wxpay_order_id'] ?? ($wxOrder['channel_order_id'] ?? ''));
|
|
|
|
|
|
$paidTime = (int)($wxOrder['paid_time'] ?? 0);
|
|
|
|
|
|
OrderService::finishPaid(
|
|
|
|
|
|
$order->order_no,
|
|
|
|
|
|
$expect,
|
|
|
|
|
|
$transactionId,
|
|
|
|
|
|
'XPAY',
|
|
|
|
|
|
$paidTime > 0 ? $paidTime : time(),
|
2026-10-27 18:10:51 +08:00
|
|
|
|
'settle',
|
|
|
|
|
|
self::payPlatformOf($order->order_no)
|
2026-10-27 16:19:46 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param array $msg
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function finishFromCoinNotify(array $msg)
|
|
|
|
|
|
{
|
|
|
|
|
|
$outTradeNo = (string)($msg['OutTradeNo'] ?? '');
|
|
|
|
|
|
$orderNo = Cache::get(self::TRADE_PREFIX . $outTradeNo) ?: $outTradeNo;
|
|
|
|
|
|
$coinInfo = isset($msg['CoinInfo']) && is_array($msg['CoinInfo']) ? $msg['CoinInfo'] : [];
|
|
|
|
|
|
$order = OrderService::getOne(['order_no' => $orderNo]);
|
|
|
|
|
|
if (!$order) {
|
|
|
|
|
|
Log::error('虚拟支付代币通知未找到订单 ' . $outTradeNo);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
$fen = isset($coinInfo['ActualPrice']) ? (int)$coinInfo['ActualPrice'] : self::amountFen($order->payment_amount);
|
|
|
|
|
|
$payInfo = isset($msg['WeChatPayInfo']) && is_array($msg['WeChatPayInfo']) ? $msg['WeChatPayInfo'] : [];
|
|
|
|
|
|
$paidTime = (int)($payInfo['PaidTime'] ?? 0);
|
|
|
|
|
|
OrderService::finishPaid(
|
|
|
|
|
|
$orderNo,
|
|
|
|
|
|
$fen,
|
|
|
|
|
|
(string)($payInfo['TransactionId'] ?? ''),
|
|
|
|
|
|
'XPAY',
|
|
|
|
|
|
$paidTime > 0 ? $paidTime : time(),
|
2026-10-27 18:10:51 +08:00
|
|
|
|
'coin_notify',
|
|
|
|
|
|
self::payPlatformOf($orderNo)
|
2026-10-27 16:19:46 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 未发货的订单退款成功后改为已退款。已发货或需求单只记日志,避免把已结算合同改回去
|
|
|
|
|
|
* @param array $msg
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function handleRefundNotify(array $msg)
|
|
|
|
|
|
{
|
|
|
|
|
|
Log::info('虚拟支付退款通知 ' . json_encode($msg, JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
if ((int)($msg['RetCode'] ?? 1) !== 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
$mchOrderId = (string)($msg['MchOrderId'] ?? '');
|
|
|
|
|
|
$orderNo = Cache::get(self::TRADE_PREFIX . $mchOrderId) ?: $mchOrderId;
|
|
|
|
|
|
$order = OrderService::getOne(['order_no' => $orderNo]);
|
|
|
|
|
|
if (!$order) {
|
|
|
|
|
|
Log::error('虚拟支付退款未找到订单 ' . $mchOrderId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((int)$order->order_status === OrderService::ORDER_STATUS_WAIT_SHIP
|
|
|
|
|
|
&& (int)$order->order_type === OrderService::ORDER_TYPE_SOFTWARE) {
|
|
|
|
|
|
OrderService::where('order_no', $order->order_no)->update([
|
|
|
|
|
|
'order_status' => OrderService::ORDER_STATUS_REFUNDED,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* iOS 退款问询:已交付建议拒绝,未交付建议退款。最终由苹果决定
|
|
|
|
|
|
* @param array $msg
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function iosRefundQuery(array $msg)
|
|
|
|
|
|
{
|
|
|
|
|
|
$payOrderId = (string)($msg['pay_order_id'] ?? ($msg['PayOrderId'] ?? ''));
|
|
|
|
|
|
$orderNo = $payOrderId ? (Cache::get(self::TRADE_PREFIX . $payOrderId) ?: $payOrderId) : '';
|
|
|
|
|
|
$order = $orderNo ? OrderService::getOne(['order_no' => $orderNo]) : null;
|
|
|
|
|
|
$delivered = $order && in_array((int)$order->order_status, [
|
|
|
|
|
|
OrderService::ORDER_STATUS_SHIPPED,
|
|
|
|
|
|
OrderService::ORDER_STATUS_FINISHED,
|
|
|
|
|
|
], true);
|
|
|
|
|
|
if ($delivered) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'result_code' => 1,
|
|
|
|
|
|
'result_info' => '已交付',
|
|
|
|
|
|
'evidence' => '虚拟商品已交付,建议拒绝退款',
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'result_code' => 0,
|
|
|
|
|
|
'result_info' => '未交付',
|
|
|
|
|
|
'evidence' => '订单未交付,建议退款',
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param string $uri
|
|
|
|
|
|
* @param array $payload
|
|
|
|
|
|
* @param string $sessionKey 需要用户态签名的接口才传
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function postXpay($uri, array $payload, $sessionKey = '')
|
|
|
|
|
|
{
|
|
|
|
|
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
|
|
if ($body === false) {
|
|
|
|
|
|
throw new \Exception('虚拟支付参数编码失败');
|
|
|
|
|
|
}
|
2026-10-27 18:52:26 +08:00
|
|
|
|
$service = new EasyWeChatService();
|
|
|
|
|
|
$result = self::requestXpay($service, $uri, $body, $sessionKey);
|
|
|
|
|
|
$errcode = (int)($result['errcode'] ?? 0);
|
|
|
|
|
|
// 40001 凭证不是最新,42001 凭证过期。刷新后只重试一次
|
|
|
|
|
|
if ($errcode === 40001 || $errcode === 42001) {
|
|
|
|
|
|
Log::info('虚拟支付 access_token 失效,刷新后重试 ' . $uri);
|
|
|
|
|
|
$service->refreshMiniAccessToken(true);
|
|
|
|
|
|
$result = self::requestXpay($service, $uri, $body, $sessionKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
Log::info('虚拟支付请求 ' . $uri . ' ' . json_encode([
|
|
|
|
|
|
'payload' => $payload,
|
|
|
|
|
|
'res' => $result,
|
|
|
|
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
|
|
|
|
|
|
return is_array($result) ? $result : [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param EasyWeChatService $service
|
|
|
|
|
|
* @param string $uri
|
|
|
|
|
|
* @param string $body
|
|
|
|
|
|
* @param string $sessionKey
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function requestXpay(EasyWeChatService $service, $uri, $body, $sessionKey)
|
|
|
|
|
|
{
|
2026-10-27 16:19:46 +08:00
|
|
|
|
// access_token 由小程序 HTTP 客户端自动附在 query 上,不参与 pay_sig
|
|
|
|
|
|
$query = [
|
|
|
|
|
|
'pay_sig' => hash_hmac('sha256', $uri . '&' . $body, self::appKey()),
|
|
|
|
|
|
];
|
|
|
|
|
|
if ($sessionKey !== '') {
|
|
|
|
|
|
$query['signature'] = hash_hmac('sha256', $body, $sessionKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
$response = $service->app->getClient()->post($uri, [
|
|
|
|
|
|
'query' => $query,
|
|
|
|
|
|
'headers' => ['Content-Type' => 'application/json'],
|
|
|
|
|
|
'body' => $body,
|
|
|
|
|
|
]);
|
|
|
|
|
|
$result = $response->toArray(false);
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
|
Log::error('虚拟支付请求异常 ' . $uri . ' ' . $e->getMessage());
|
|
|
|
|
|
throw new \Exception('虚拟支付请求失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return is_array($result) ? $result : [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param string $openid
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function sessionKeyByOpenid($openid)
|
|
|
|
|
|
{
|
|
|
|
|
|
$userId = User::where('openid', $openid)->value('id');
|
|
|
|
|
|
$sessionKey = $userId ? Cache::get(self::SESSION_PREFIX . $userId) : '';
|
|
|
|
|
|
if (!$sessionKey) {
|
|
|
|
|
|
throw new \Exception('登录态已过期,请退出小程序后重新进入再支付');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (string)$sessionKey;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:51:41 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ThinkPHP 5 的 config() 只按第一个点拆成两级,三级键会读成空
|
|
|
|
|
|
* @return array
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function payConfig()
|
|
|
|
|
|
{
|
|
|
|
|
|
$config = config('common.virtual_pay');
|
|
|
|
|
|
|
|
|
|
|
|
return is_array($config) ? $config : [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:47:23 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* OfferID 为空时记下 .env 有没有被读到,不记录密钥内容
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function logMissingOfferId()
|
|
|
|
|
|
{
|
|
|
|
|
|
$envFile = (defined('ROOT_PATH') ? ROOT_PATH : '') . '.env';
|
|
|
|
|
|
$exists = $envFile !== '.env' && is_file($envFile);
|
|
|
|
|
|
$section = '';
|
|
|
|
|
|
$found = null;
|
|
|
|
|
|
if ($exists) {
|
|
|
|
|
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES);
|
|
|
|
|
|
if (is_array($lines)) {
|
|
|
|
|
|
foreach ($lines as $line) {
|
|
|
|
|
|
$trim = trim($line);
|
|
|
|
|
|
if ($trim === '' || $trim[0] === ';' || $trim[0] === '#') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (preg_match('/^\[(.+)\]$/', $trim, $sectionMatch)) {
|
|
|
|
|
|
$section = $sectionMatch[1];
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (preg_match('/^vp_offer_id\s*=\s*(.*)$/', $trim, $valueMatch)) {
|
|
|
|
|
|
$value = trim($valueMatch[1], " \t\"'");
|
|
|
|
|
|
$found = [
|
|
|
|
|
|
'section' => $section,
|
|
|
|
|
|
'len' => strlen($value),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$fromEnv = getenv('PHP_WECHAT_VP_OFFER_ID');
|
|
|
|
|
|
$virtualPay = config('common.virtual_pay');
|
|
|
|
|
|
Log::error('虚拟支付 OfferID 为空 ' . json_encode([
|
|
|
|
|
|
'env_file' => $envFile,
|
|
|
|
|
|
'env_exists' => $exists,
|
|
|
|
|
|
'file_item' => $found ?: 'missing',
|
|
|
|
|
|
'getenv' => $fromEnv === false ? 'false' : ('len:' . strlen(trim((string)$fromEnv))),
|
|
|
|
|
|
'config_keys' => is_array($virtualPay) ? array_keys($virtualPay) : gettype($virtualPay),
|
|
|
|
|
|
], JSON_UNESCAPED_UNICODE));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:19:46 +08:00
|
|
|
|
/**
|
2026-10-27 18:30:40 +08:00
|
|
|
|
* 代币数量,保留两位小数。1元=1代币时,0.01元对应 0.01 个代币
|
2026-10-27 16:19:46 +08:00
|
|
|
|
* @param float|string $amountYuan
|
2026-10-27 18:30:40 +08:00
|
|
|
|
* @return float
|
2026-10-27 16:19:46 +08:00
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function coinQuantity($amountYuan)
|
|
|
|
|
|
{
|
|
|
|
|
|
$fen = self::amountFen($amountYuan);
|
|
|
|
|
|
if ($fen < 1) {
|
|
|
|
|
|
throw new \Exception('支付金额不能小于0.01元');
|
|
|
|
|
|
}
|
2026-10-27 16:51:41 +08:00
|
|
|
|
$coinPerYuan = (int)(self::payConfig()['coin_per_yuan'] ?? 0);
|
2026-10-27 16:19:46 +08:00
|
|
|
|
if ($coinPerYuan < 1) {
|
|
|
|
|
|
$coinPerYuan = 1;
|
|
|
|
|
|
}
|
2026-10-27 18:30:40 +08:00
|
|
|
|
$quantity = bcdiv(bcmul((string)$fen, (string)$coinPerYuan, 0), '100', 2);
|
2026-10-27 16:19:46 +08:00
|
|
|
|
|
2026-10-27 18:30:40 +08:00
|
|
|
|
return (float)$quantity;
|
2026-10-27 16:19:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param float|string $amountYuan
|
|
|
|
|
|
* @return int
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function amountFen($amountYuan)
|
|
|
|
|
|
{
|
|
|
|
|
|
return (int)bcmul(sprintf('%.2f', $amountYuan), '100', 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @return int
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function env()
|
|
|
|
|
|
{
|
2026-10-27 16:51:41 +08:00
|
|
|
|
return (int)(self::payConfig()['env'] ?? 0) === 1 ? 1 : 0;
|
2026-10-27 16:19:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function appKey()
|
|
|
|
|
|
{
|
2026-10-27 16:51:41 +08:00
|
|
|
|
$config = self::payConfig();
|
2026-10-27 16:19:46 +08:00
|
|
|
|
$key = self::env() === 1 ? (string)($config['sandbox_app_key'] ?? '') : (string)($config['app_key'] ?? '');
|
|
|
|
|
|
if ($key === '') {
|
|
|
|
|
|
throw new \Exception('未配置虚拟支付 AppKey');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function userIp()
|
|
|
|
|
|
{
|
|
|
|
|
|
$ip = request()->ip();
|
|
|
|
|
|
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
|
|
|
|
return '127.0.0.1';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $ip;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|