Files
amb_wechatapp/application/common/library/pay/VirtualPayService.php
T

851 lines
26 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
namespace app\common\library\pay;
use app\common\exception\Exception;
use think\Cache;
/**
* 微信小程序虚拟支付服务类
* 文档地址: https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment.html
*/
class VirtualPayService
{
/**
* 虚拟支付配置
* @var array
*/
protected $config;
/**
* 小程序配置
* @var array
*/
protected $miniappConfig;
/**
* 微信API基础地址
* @var string
*/
const API_BASE = 'https://api.weixin.qq.com';
/**
* 构造函数,初始化虚拟支付配置
*/
public function __construct()
{
$this->config = \app\common\model\config\System::getConfig('virtual_pay');
$this->miniappConfig = \app\common\model\config\System::getConfig('wxMiniProgram');
if (!$this->config || !$this->miniappConfig) {
throw new Exception('虚拟支付配置未初始化');
}
}
/**
* 获取access_token(使用稳定版API
* @param bool $forceRefresh 是否强制刷新
* @return string
* @throws Exception
*/
public function getAccessToken($forceRefresh = false)
{
$cacheKey = 'virtual_pay_access_token:' . UNIACID;
if (!$forceRefresh) {
$token = Cache::get($cacheKey);
if ($token) {
return $token;
}
}
$url = self::API_BASE . '/cgi-bin/stable_token';
$params = [
'grant_type' => 'client_credential',
'appid' => $this->miniappConfig['app_id'],
'secret' => $this->miniappConfig['secret'],
];
$result = $this->httpPost($url, json_encode($params, JSON_UNESCAPED_UNICODE));
if (isset($result['access_token'])) {
Cache::set($cacheKey, $result['access_token'], $result['expires_in'] - 300);
return $result['access_token'];
}
throw new Exception('获取access_token失败: ' . ($result['errmsg'] ?? '未知错误'));
}
/**
* 查询用户代币余额
* @param string $openid 用户openid
* @return array
*/
public function queryUserBalance($openid)
{
$uri = '/xpay/query_user_balance';
$accessToken = $this->getAccessToken();
$params = [
'openid' => $openid,
'env' => intval($this->config['env']),
'user_ip' => request()->ip(),
];
$body = $this->encodeBody($params);
$paySig = $this->generatePaySig($uri, $body);
$signature = $this->getServerSignature($openid, $body);
$url = self::API_BASE . $uri;
$url .= '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig) . '&signature=' . rawurlencode($signature);
$result = $this->httpPost($url, $body);
return $result;
}
/**
* 扣减代币(代币支付)
* @param string $openid 用户openid
* @param int $amount 扣减数量
* @param string $orderId 商户订单号
* @param array $payItem 支付项信息
* @return array
*/
public function currencyPay($openid, $amount, $orderId, $payItem = [])
{
$uri = '/xpay/currency_pay';
$accessToken = $this->getAccessToken();
$params = [
'openid' => $openid,
'env' => intval($this->config['env']),
'user_ip' => request()->ip(),
'amount' => intval($amount),
'order_id' => $orderId,
'payitem' => json_encode($payItem, JSON_UNESCAPED_UNICODE),
'remark' => '代币支付',
];
$body = $this->encodeBody($params);
$paySig = $this->generatePaySig($uri, $body);
$signature = $this->getServerSignature($openid, $body);
$url = self::API_BASE . $uri;
$url .= '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig) . '&signature=' . rawurlencode($signature);
$result = $this->httpPost($url, $body);
return $result;
}
/**
* 代币支付退款
* @param string $openid 用户openid
* @param int $amount 退还数量
* @param string $orderId 原订单号
* @param string $refundId 退款单号
* @return array
*/
public function cancelCurrencyPay($openid, $amount, $orderId, $refundId)
{
$uri = '/xpay/cancel_currency_pay';
$accessToken = $this->getAccessToken();
$params = [
'openid' => $openid,
'env' => intval($this->config['env']),
'user_ip' => request()->ip(),
'pay_order_id' => $orderId,
'order_id' => $refundId,
'amount' => intval($amount),
];
$body = $this->encodeBody($params);
$paySig = $this->generatePaySig($uri, $body);
$signature = $this->getServerSignature($openid, $body);
$url = self::API_BASE . $uri;
$url .= '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig) . '&signature=' . rawurlencode($signature);
$result = $this->httpPost($url, $body);
return $result;
}
/**
* 查询订单(现金订单)
* @param string $openid 用户openid
* @param string $outTradeNo 商户订单号
* @param string $wxOrderId 微信订单号
* @param string $refundOrderId 退款单号(查询退款单状态时使用)
* @return array
*/
public function queryOrder($openid, $outTradeNo = '', $wxOrderId = '', $refundOrderId = '')
{
if (empty($outTradeNo) && empty($wxOrderId) && empty($refundOrderId)) {
return ['errcode' => -1, 'errmsg' => '订单号不能为空'];
}
$uri = '/xpay/query_order';
$params = [
'openid' => $openid,
'env' => intval($this->config['env']),
];
if (!empty($refundOrderId)) {
$params['order_id'] = $refundOrderId;
} elseif (!empty($outTradeNo)) {
$params['order_id'] = $outTradeNo;
} else {
$params['wx_order_id'] = $wxOrderId;
}
$body = $this->encodeBody($params);
$result = $this->callApiWithRetry(function ($accessToken) use ($uri, $body) {
$paySig = $this->generatePaySig($uri, $body);
$url = self::API_BASE . $uri . '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig);
return $this->httpPost($url, $body);
});
return $result;
}
/**
* 通知发货完成(现金订单)
* @param string $outTradeNo 商户订单号
* @return array
*/
public function notifyProvideGoods($outTradeNo)
{
$uri = '/xpay/notify_provide_goods';
$params = [
'order_id' => $outTradeNo,
'env' => intval($this->config['env']),
];
$body = $this->encodeBody($params);
$result = $this->callApiWithRetry(function ($accessToken) use ($uri, $body) {
$paySig = $this->generatePaySig($uri, $body);
$url = self::API_BASE . $uri . '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig);
return $this->httpPost($url, $body);
});
return $result;
}
/**
* 启动订单退款任务(现金订单)
* @param string $openid 下单用户openid
* @param string $outTradeNo 商户订单号
* @param string $refundOrderId 退款单号
* @param int $leftFee 当前剩余可退金额(分)
* @param int $refundFee 本次退款金额(分)
* @param string $reason 退款原因枚举
* @param string $reqFrom 退款来源枚举
* @return array
*/
public function refundOrder($openid, $outTradeNo, $refundOrderId, $leftFee, $refundFee, $reason = '0', $reqFrom = '3')
{
$uri = '/xpay/refund_order';
$params = [
'openid' => $openid,
'order_id' => $outTradeNo,
'refund_order_id' => $refundOrderId,
'left_fee' => intval($leftFee),
'refund_fee' => intval($refundFee),
'biz_meta' => $outTradeNo,
'refund_reason' => strval($reason),
'req_from' => strval($reqFrom),
'env' => intval($this->config['env']),
];
$body = $this->encodeBody($params);
$result = $this->callApiWithRetry(function ($accessToken) use ($uri, $body) {
$paySig = $this->generatePaySig($uri, $body);
$url = self::API_BASE . $uri . '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig);
return $this->httpPost($url, $body);
});
return $result;
}
/**
* 查询退款单状态(现金订单退款)
* 退款状态枚举:5-已退款 7-退款失败 8-用户退款完成
* @param string $openid 用户openid
* @param string $refundOrderId 退款单号
* @return array 包含 status 和 order 信息
*/
public function queryRefundOrder($openid, $refundOrderId)
{
$result = $this->queryOrder($openid, '', '', $refundOrderId);
return $result;
}
/**
* 轮询查询退款状态,直到退款完成或超时
* @param string $openid 用户openid
* @param string $refundOrderId 退款单号
* @param int $maxRetries 最大重试次数
* @param int $intervalMs 重试间隔(毫秒)
* @return array ['success' => bool, 'status' => int, 'message' => string]
*/
public function pollRefundStatus($openid, $refundOrderId, $maxRetries = 6, $intervalMs = 3000)
{
for ($i = 0; $i < $maxRetries; $i++) {
usleep($intervalMs * 1000);
$result = $this->queryOrder($openid, '', '', $refundOrderId);
\think\Log::info('[VirtualPay] 轮询退款状态 refund_order_id=' . $refundOrderId . ', 第' . ($i + 1) . '次, result=' . json_encode($result));
if (isset($result['errcode']) && $result['errcode'] !== 0) {
if ($result['errcode'] === 268490011) {
continue;
}
return [
'success' => false,
'status' => -1,
'message' => '查询退款状态失败:' . ($result['errmsg'] ?? '未知错误'),
];
}
$status = isset($result['order']['status']) ? intval($result['order']['status']) : -1;
if ($status === 8) {
return [
'success' => true,
'status' => $status,
'message' => '退款完成',
];
}
if ($status === 7) {
return [
'success' => false,
'status' => $status,
'message' => '退款失败',
];
}
if ($status === 5) {
return [
'success' => true,
'status' => $status,
'message' => '订单已退款',
];
}
}
return [
'success' => false,
'status' => -1,
'message' => '退款状态查询超时,退款可能仍在处理中',
];
}
/**
* 查询商家账户可提现余额
* @return array
*/
public function queryBizBalance()
{
$uri = '/xpay/query_biz_balance';
$accessToken = $this->getAccessToken();
$params = [
'env' => intval($this->config['env']),
];
$body = $this->encodeBody($params);
$paySig = $this->generatePaySig($uri, $body);
$url = self::API_BASE . $uri;
$url .= '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig);
$result = $this->httpPost($url, $body);
return $result;
}
/**
* 创建提现单
* @param string $withdrawId 提现单号(长度[8,32],只允许字母、数字、_、-)
* @param string $amount 提现金额(单位元,例如 "0.01",不传则全额提现)
* @return array
*/
public function createWithdrawOrder($withdrawId, $amount = '')
{
$uri = '/xpay/create_withdraw_order';
$accessToken = $this->getAccessToken();
$params = [
'withdraw_no' => $withdrawId,
'env' => intval($this->config['env']),
];
if ($amount !== '') {
$params['withdraw_amount'] = $amount;
}
$body = $this->encodeBody($params);
$paySig = $this->generatePaySig($uri, $body);
$url = self::API_BASE . $uri;
$url .= '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig);
$result = $this->httpPost($url, $body);
return $result;
}
/**
* 查询提现单
* @param string $withdrawId 提现单号
* @return array
*/
public function queryWithdrawOrder($withdrawId)
{
$uri = '/xpay/query_withdraw_order';
$accessToken = $this->getAccessToken();
$params = [
'withdraw_no' => $withdrawId,
'env' => intval($this->config['env']),
];
$body = $this->encodeBody($params);
$paySig = $this->generatePaySig($uri, $body);
$url = self::API_BASE . $uri;
$url .= '?access_token=' . rawurlencode($accessToken) . '&pay_sig=' . rawurlencode($paySig);
$result = $this->httpPost($url, $body);
return $result;
}
/**
* 生成提现单号
* @return string
*/
public function generateWithdrawNo()
{
return 'WD' . date('YmdHis') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
}
/**
* 获取虚拟支付客户端配置(供小程序端调用wx.requestVirtualPayment
* @param string $openid 用户openid
* @param string $sessionKey 用户session_key
* @param string $outTradeNo 商户订单号
* @param int $amount 支付金额(分)
* @param string $mode 支付模式 short_series_goods|short_series_coin
* @param string $productId 道具ID(道具直购时必填)
* @return array
*/
public function getClientPayConfig($openid, $sessionKey, $outTradeNo, $amount, $mode = 'short_series_coin', $productId = '')
{
$env = $this->getClientPayEnv();
$signData = [
'offerId' => $this->config['offer_id'],
'buyQuantity' => $this->getBuyQuantity($amount, $mode),
'env' => $env,
'currencyType' => 'CNY',
'outTradeNo' => $outTradeNo,
'attach' => $outTradeNo,
];
if ($mode === 'short_series_goods') {
if (empty($productId)) {
throw new Exception('道具直购模式缺少product_id');
}
$signData['productId'] = $productId;
$signData['goodsPrice'] = $this->yuanToFen($amount);
}
$signDataJson = $this->encodeClientSignData($signData);
$paySig = $this->generatePaySig('requestVirtualPayment', $signDataJson, $env);
if (empty($sessionKey)) {
throw new Exception('用户session_key不存在,请重新登录');
}
$signature = $this->generateUserSignature($sessionKey, $signDataJson);
return [
'signData' => $signDataJson,
'paySig' => $paySig,
'signature' => $signature,
'mode' => $mode,
];
}
/**
* 生成支付签名(pay_sig
* @param string $uri 请求路径
* @param string $body 实际请求体或signData字符串
* @return string
*/
public function generatePaySig($uri, $body, $env = null)
{
$appKey = $this->getAppKey($env);
$signStr = $uri . '&' . $body;
return hash_hmac('sha256', $signStr, $appKey);
}
/**
* 获取当前环境的AppKey。
* 沙箱AppKey未配置时兼容使用正式AppKey,和后台配置提示保持一致。
* @return string
* @throws Exception
*/
protected function getAppKey($env = null)
{
$env = $env === null ? intval($this->config['env'] ?? 0) : intval($env);
$appKey = $env === 0
? ($this->config['app_key'] ?? '')
: ($this->config['sandbox_app_key'] ?? '');
if ($env !== 0 && trim((string)$appKey) === '') {
$appKey = $this->config['app_key'] ?? '';
}
$appKey = trim((string)$appKey);
if ($appKey === '') {
throw new Exception('虚拟支付AppKey未配置');
}
return $appKey;
}
/**
* 获取客户端拉起虚拟支付环境。
* iOS端由微信路由到Apple支付,而Apple支付不支持沙箱环境,只能使用现网环境。
* @return int
*/
protected function getClientPayEnv()
{
if (\app\common\library\Platform::getDeviceEnd() === 'ios') {
return 0;
}
return intval($this->config['env'] ?? 0);
}
/**
* 获取服务端用户态签名(需要从数据库获取session_key
* @param string $openid 用户openid
* @param string $body 实际请求体
* @return string
*/
protected function getServerSignature($openid, $body)
{
$oauth = \app\common\model\user\Oauth::where([
'openid' => $openid,
'provider' => 'Wechat',
'platform' => 'wxMiniProgram'
])->find();
if (!$oauth || empty($oauth->session_key)) {
throw new Exception('用户session_key不存在,请重新登录');
}
return $this->generateUserSignature($oauth->session_key, $body);
}
/**
* 生成用户态签名(signature
* @param string $sessionKey 用户session_key
* @param string $body 实际请求体或signData字符串
* @return string
*/
public function generateUserSignature($sessionKey, $body)
{
return hash_hmac('sha256', $body, $sessionKey);
}
/**
* 带自动重试的API调用(access_token失效时自动刷新重试)
* @param callable $apiCall API调用闭包,接收 access_token 参数
* @return array
*/
protected function callApiWithRetry($apiCall)
{
$accessToken = $this->getAccessToken();
$result = $apiCall($accessToken);
if (isset($result['errcode']) && in_array($result['errcode'], [40001, 40014, 42001], true)) {
\think\Log::warning('[VirtualPay] access_token失效,强制刷新重试 errcode=' . $result['errcode']);
$accessToken = $this->getAccessToken(true);
$result = $apiCall($accessToken);
}
return $result;
}
/**
* 使用同一份JSON字符串参与签名和实际请求,避免序列化差异导致验签失败。
* @param array $params
* @return string
*/
protected function encodeBody($params)
{
return json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* 编码wx.requestVirtualPayment使用的signData。
* signData会逐字节参与paySig和signature计算,必须把同一个字符串原样传给前端。
* @param array $params
* @return string
*/
protected function encodeClientSignData($params)
{
$items = [];
foreach ($params as $key => $value) {
$items[] = json_encode((string)$key, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
. ': '
. json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return '{' . implode(', ', $items) . '}';
}
/**
* 获取wx.requestVirtualPayment购买数量。
* 代币模式下需要带上后台配置的兑换比例,否则微信侧仍会按旧比例展示金币数量。
* @param mixed $amount
* @param string $mode
* @return int
* @throws Exception
*/
protected function getBuyQuantity($amount, $mode)
{
if ($mode === 'short_series_goods') {
return 1;
}
$originalPrice = floatval($amount);
if ($originalPrice < 1 && $originalPrice > 0) {
$originalPrice = 1;
}
$quantity = (int)round($originalPrice * self::getExchangeRate());
if ($quantity <= 0) {
throw new Exception('虚拟支付金额不合法');
}
return $quantity;
}
/**
* 将系统订单金额(元)转换为微信接口要求的分。
* @param mixed $amount
* @return int
*/
protected function yuanToFen($amount)
{
return intval(round(floatval($amount) * 100));
}
/**
* 生成随机字符串
* @param int $length 长度
* @return string
*/
protected function generateNonce($length = 32)
{
return \fast\Random::alnum($length);
}
/**
* 生成商户订单号
* @return string
*/
public function generateOutTradeNo()
{
return 'VP' . date('YmdHis') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
}
/**
* HTTP GET 请求
* @param string $url 请求地址
* @param array $query 查询参数
* @return array
*/
protected function httpGet($url, $query = [])
{
if (!empty($query)) {
$url .= (strpos($url, '?') !== false ? '&' : '?') . http_build_query($query);
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return ['errcode' => -1, 'errmsg' => 'HTTP请求失败: ' . $error];
}
return json_decode($response, true);
}
/**
* HTTP POST 请求
* @param string $url 请求地址
* @param string $body 请求体
* @return array
*/
protected function httpPost($url, $body)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return ['errcode' => -1, 'errmsg' => 'HTTP请求失败: ' . $error];
}
return json_decode($response, true);
}
/**
* 判断当前是否为虚拟支付商品
* @param string $goodsType 商品类型
* @return bool
*/
public static function isVirtualPayGoods($goodsType)
{
$config = \app\common\model\config\System::getConfig('virtual_pay');
if (!$config) {
return false;
}
$virtualGoodsTypes = self::normalizeVirtualGoodsTypes($config['virtual_goods_types'] ?? []);
return in_array($goodsType, $virtualGoodsTypes, true);
}
/**
* 判断当前平台和商品是否应使用虚拟支付
* @param string $goodsType 商品类型
* @return int 1-使用虚拟支付 0-不使用
*/
public static function isUseVirtualPay($goodsType)
{
$config = \app\common\model\config\System::getConfig('virtual_pay');
if (!$config || ($config['status'] ?? 'close') !== 'open') {
return 0;
}
if (\app\common\library\Platform::getPlatform() !== 'wxMiniProgram') {
return 0;
}
$virtualGoodsTypes = self::normalizeVirtualGoodsTypes($config['virtual_goods_types'] ?? []);
if (empty($virtualGoodsTypes)) {
return 0;
}
$result = in_array($goodsType, $virtualGoodsTypes, true) ? 1 : 0;
\think\Log::info('[VirtualPay] isUseVirtualPay 判断 goods_type=' . $goodsType . ', virtual_goods_types=' . json_encode($virtualGoodsTypes) . ', result=' . $result);
return $result;
}
/**
* 获取人民币与代币兑换比例。
* @return int
*/
public static function getExchangeRate()
{
$config = \app\common\model\config\System::getConfig('virtual_pay');
$rate = isset($config['exchange_rate']) ? intval($config['exchange_rate']) : 1;
return in_array($rate, [1, 10, 100], true) ? $rate : 1;
}
/**
* 按虚拟支付兑换比例转换价格。
* @param mixed $price
* @return string
*/
public static function convertPrice($price)
{
$originalPrice = floatval($price);
if ($originalPrice < 1 && $originalPrice > 0) {
$originalPrice = 1;
}
return number_format($originalPrice * self::getExchangeRate(), 2, '.', '');
}
/**
* 按虚拟支付兑换比例批量转换数组中的价格字段。
* @param array $data
* @param array $fields
* @return array
*/
public static function convertPriceFields($data, $fields = ['price', 'price_marking'])
{
foreach ($fields as $field) {
if (isset($data[$field]) && $data[$field] !== '') {
$data[$field] = self::convertPrice($data[$field]);
}
}
return $data;
}
/**
* 兼容历史配置中复选框值为数组、JSON字符串或逗号字符串的情况。
* @param mixed $value
* @return array
*/
protected static function normalizeVirtualGoodsTypes($value)
{
if (is_string($value)) {
$value = trim($value);
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$value = $decoded;
} elseif ($value === '') {
$value = [];
} else {
$value = explode(',', $value);
}
}
if (!is_array($value)) {
return [];
}
$list = [];
foreach ($value as $item) {
$item = trim((string)$item);
if ($item !== '') {
$list[] = $item;
}
}
return array_values(array_unique($list));
}
/**
* 获取虚拟支付配置
* @return array|false
*/
public function getConfig()
{
return $this->config;
}
}