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; } }