初始化项目:添加后端代码、ThinkPHP框架、前端资源

This commit is contained in:
amb
2026-09-03 12:42:39 +08:00
commit 482bece22e
3726 changed files with 416708 additions and 0 deletions
@@ -0,0 +1,850 @@
<?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;
}
}