Files

795 lines
30 KiB
PHP
Raw Permalink 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\api\controller;
use addons\epay\library\Service;
use fast\Random;
use think\addons\Controller;
use app\common\exception\Exception;
use app\common\model\order\Order;
use think\Db;
use app\common\controller\Api;
use app\common\model\User;
use app\common\library\pay\Balance;
use app\common\library\pay\VirtualPayService;
class Pay extends Api
{
protected $noNeedLogin = ['prepay', 'notifyx', 'notifyr', 'alipay','searchOrder','getNotifyParams'];
protected $noNeedRight = ['*'];
/**
* 获取支付渠道
* @return void
*/
public function getPayType(){
$config = \app\common\model\config\System::getConfig('payment_basic');
$payType = $config['pay_type'];
$data = [];
foreach ($payType as $index=> $item){
$temp = [
'name'=>__('pay_'.$item),
'type'=>$item,
'index'=>$index
];
if($item == 'balance'){
$temp['subname'] = "可用余额 ¥{$this->auth->money}";
}
if($item == 'wechat'){
$platform = \app\common\library\Platform::getPlatform();
$wechatAvailable = false;
// 检查支付账户池是否有可用商户号(仅验证商户号有效性,平台可用性统一由系统配置决定)
if (\app\admin\library\project\App::isInstall('paymentpool')
&& \app\common\library\app\paymentpool\Service::isEnable()) {
$activeAccount = \app\common\library\app\paymentpool\Service::getActiveAccount('wechat');
if ($activeAccount && !empty($activeAccount['config']['app_id'])) {
$wechatAvailable = true;
}
}
// 账户池未启用或无可用商户号时,回退到系统配置
if (!$wechatAvailable) {
$wechatConfig = \app\common\model\config\System::getConfig('wechat');
if(!in_array($platform, $wechatConfig['platform'] ?? [])){
//微信支付未开启当前平台
continue;
}
$wechatAvailable = true;
}
if (!$wechatAvailable) {
continue;
}
}
if($item == 'alipay'){
$platform = \app\common\library\Platform::getPlatform();
$alipayAvailable = false;
// 检查支付账户池是否有可用商户号(仅验证商户号有效性,平台可用性统一由系统配置决定)
if (\app\admin\library\project\App::isInstall('paymentpool')
&& \app\common\library\app\paymentpool\Service::isEnable()) {
$activeAccount = \app\common\library\app\paymentpool\Service::getActiveAccount('alipay');
if ($activeAccount && !empty($activeAccount['config']['app_id'])) {
$alipayAvailable = true;
}
}
// 账户池未启用或无可用商户号时,回退到系统配置
if (!$alipayAvailable) {
$alipayConfig = \app\common\model\config\System::getConfig('alipay');
if(empty($alipayConfig) || empty($alipayConfig['app_id'])){
continue;
}
if(!in_array($platform, $alipayConfig['platform'] ?? ['wxOfficialAccount','H5'])){
continue;
}
$alipayAvailable = true;
}
if (!$alipayAvailable) {
continue;
}
}
if($item == 'douyinpay'){
if(\app\common\library\Platform::getPlatform() != 'dyMiniProgram'){
continue;
}
}
if($item == 'virtual_pay'){
$virtualPayConfig = \app\common\model\config\System::getConfig('virtual_pay');
if(empty($virtualPayConfig) || ($virtualPayConfig['status'] ?? 'close') !== 'open'){
continue;
}
if(\app\common\library\Platform::getPlatform() != 'wxMiniProgram'){
continue;
}
}
$data[] = $temp;
}
$this->success('获取成功', $data);
}
/**
* 支付宝网页支付
*/
public function alipay()
{
$orderNo = $this->request->get('orderNo');
$order = Order::where('orderNo', $orderNo)->find();
try {
if (!$order) {
throw new \Exception("订单不存在");
}
if ($order->status > 0) {
throw new \Exception("订单已支付");
}
if ($order->status < 0) {
throw new \Exception("订单已失效");
}
$notify_url = $this->request->root(true) . '/pay/notifyx/pay_type/alipay/platform/H5/uniacid/'.UNIACID;
$pay = new \app\common\library\pay\PayService('alipay', 'url', $notify_url);
$order_data = [
'out_trade_no' => $order->orderNo,
'total_fee' => $order->total_fee,
'subject' => '商城订单支付',
];
$result = $pay->create($order_data);
$result = $result->getContent();
echo $result;
} catch (\Exception $e) {
echo $e->getMessage();
}
}
/**
* 拉起支付
*/
public function handle()
{
$orderNo = $this->request->post('order_no');
$pay_type = $this->request->post('pay_type');
$openid = $this->request->post('openid', '');
$code = $this->request->post('code', '');
$platform = request()->header('platform');
$order = Order::where([
'order_no'=>$orderNo,
'status'=>'unpaid'
])->find();
if (!$order) {
throw new Exception("订单不存在");
}
if (!$pay_type || !in_array($pay_type, ['wechat', 'douyinpay', 'alipay', 'balance', 'virtual_pay'])) {
throw new Exception("支付类型不能为空");
}
if($order['user_id'] != $this->auth->id){
throw new Exception("无法支付此订单");
}
// 购买课程前表单支付校验(防止绕过前端直接调支付)
if (\app\admin\library\project\App::isInstall('form')) {
$orderItems = \app\common\model\order\Item::getItemList($orderNo);
foreach ($orderItems as $item) {
// 通过 item_id 匹配,不按 goods_type(课程类商品的 goods_type 是 course
// 而 bind 表存储的实际类型如 column/video 等,直接匹配会查不到)
$bindForms = \app\admin\model\app\form\Bind::alias('bind')
->join('app_form f', 'bind.form_id = f.id')
->where('bind.content_id', $item['item_id'])
->where('f.bind_type', 'before_buy')
->where('f.status', 1)
->field('bind.form_id, f.name')
->select();
if (!empty($bindForms)) {
foreach ($bindForms as $bindForm) {
$submitted = \app\admin\model\app\form\Log::where([
'form_id' => $bindForm['form_id'],
'user_id' => $this->auth->id
])->count();
if (!$submitted) {
throw new Exception("请先完成购买前表单:{$bindForm['name']}");
}
}
}
}
}
if ($pay_type == 'balance') {
$this->balancePay($order, $pay_type, $platform);
}
$isVirtualPay = !empty($order['is_virtual_pay']) && $pay_type !== 'balance';
if ($isVirtualPay) {
$pay_type = 'virtual_pay';
}
$order_data = [
'out_trade_no' => $order->order_no,
'total_fee' => $order->real_price,
];
// 微信公众号,小程序支付,必须有 openid
switch ($pay_type){
case 'wechat':
if (in_array($platform, ['wxOfficialAccount', 'wxMiniProgram'])) {
if (isset($openid) && $openid) {
// 如果传的有 openid
$order_data['openid'] = $openid;
} else {
// 没有 openid 默认拿下单人的 openid
$oauth = \app\common\model\user\Oauth::where([
'user_id' => $order->user_id,
'provider' => 'Wechat',
'platform' => $platform
])->find();
$order_data['openid'] = $oauth ? $oauth->openid : '';
}
if (empty($order_data['openid'])) {
// 缺少 openid
return $this->success('缺少 openid', 'no_openid');
}
}
$order_data['body'] = '商城订单支付';
break;
case 'douyinpay':
if (!$openid) {
// 缺少 openid
return $this->success('缺少 openid', 'no_openid');
}
$order_data['openid'] = $openid;
break;
case 'virtual_pay':
if ($openid) {
$oauth = \app\common\model\user\Oauth::where([
'openid' => $openid,
'provider' => 'Wechat',
'platform' => 'wxMiniProgram'
])->find();
} else {
$oauth = \app\common\model\user\Oauth::where([
'user_id' => $order->user_id,
'provider' => 'Wechat',
'platform' => 'wxMiniProgram'
])->find();
}
if (!$oauth || empty($oauth->openid)) {
return $this->success('缺少openid', 'no_openid');
}
$resumeResult = $this->checkExistingVirtualPayOrder($order, $oauth->openid, $isVirtualPay ? 1 : 0);
if ($resumeResult !== null) {
return $resumeResult;
}
if (empty($code)) {
return $this->success('请先登录后再支付', 'no_session_key');
}
$wechat = new \app\common\library\Wechat('wxMiniProgram');
$session = $wechat->code($code);
if (empty($session['session_key'])) {
return $this->success('获取登录信息失败:' . ($session['errmsg'] ?? '未知错误'), 'no_session_key');
}
if (!empty($session['openid']) && $session['openid'] !== $oauth->openid) {
return $this->success('微信登录态与当前用户不一致,请重新登录', 'no_session_key');
}
$sessionKey = $session['session_key'];
$oauth->session_key = $sessionKey;
$oauth->expiretime = time() + 7200;
$oauth->save();
$order_data['openid'] = $oauth->openid;
$order_data['session_key'] = $sessionKey;
$order_data['mode'] = $this->request->post('mode', 'short_series_coin');
$order_data['product_id'] = $this->request->post('product_id', '');
\app\common\model\order\VirtualPay::saveRelation($order->order_no, $oauth->openid);
break;
default:
$order_data['subject'] = '商城订单支付';
}
$notify_url = $this->getNotifyUrl($pay_type,$platform,$openid);
if ($pay_type == 'alipay') {
$userAgent = strtolower($this->request->server('HTTP_USER_AGENT', ''));
$isMobile = preg_match('/(mobile|android|iphone|ipad|ipod|windows phone)/i', $userAgent);
if (!$isMobile) {
$platform = 'pc';
}
}
try{
$payResult = $this->createPaymentWithPool($pay_type, $platform, $notify_url, $order_data, $order);
}catch (\Throwable $e){
// 使用 Throwable 捕获 \Error 和 \ExceptionPHP 7+ openssl_sign 等可能抛 Error
// 内部错误(如 openssl_sign 私钥错误)不直接对外暴露,统一返回通用提示
throw new Exception('支付遇到错误,请重试');
}
$result = $payResult['result'];
if ($platform == 'App') {
$result = $result->getContent();
}
if ($pay_type == 'alipay' || ($platform == 'H5' && $pay_type == 'wechat')) {
$result = $result->getContent();
}
$data = [
'is_virtual_pay' => $isVirtualPay ? 1 : 0,
'pay_data' => $result
];
return $this->success('拉取支付参数成功', $data);
}
/**
* 创建支付参数(接入支付账户池失败自动切换重试)
*
* 仅 wechat / alipay 类型会触发账户池逻辑;
* 其它支付方式(douyinpay / virtual_pay 等)保持原有行为。
*
* 流程:
* 1. 若账户池未启用,则直接调用一次 PayService::create()
* 2. 若启用,则循环选取"使用中"/"暂未启用"商户号尝试发起,
* 返回错误码时标记该商户号为"不可用"并切换到下一个,
* 成功后绑定订单与商户号关系并累加使用次数;
* 3. 全部商户号都失败时回退到系统配置(即不传 forceAccountId)。
*
* @param string $pay_type
* @param string $platform
* @param string $notify_url
* @param array $order_data
* @param \app\common\model\order\Order $order
* @return array ['result' => mixed, 'account_id' => int|null]
*/
private function createPaymentWithPool($pay_type, $platform, $notify_url, $order_data, $order)
{
// 非微信/支付宝支付直接走系统配置
if (!in_array($pay_type, ['wechat', 'alipay'], true)) {
$pay = new \app\common\library\pay\PayService($pay_type, $platform, $notify_url);
$result = $pay->create($order_data);
return ['result' => $result, 'account_id' => null];
}
$poolEnable = \app\admin\library\project\App::isInstall('paymentpool')
&& \app\common\library\app\paymentpool\Service::isEnable();
if (!$poolEnable) {
// 账户池未启用,按原流程使用系统配置
$pay = new \app\common\library\pay\PayService($pay_type, $platform, $notify_url);
$result = $pay->create($order_data);
return ['result' => $result, 'account_id' => null];
}
// 账户池启用:循环切换商户号尝试
$excludeIds = [];
$lastError = '';
$maxRetry = 5;
for ($i = 0; $i < $maxRetry; $i++) {
$account = \app\common\library\app\paymentpool\Service::chooseNextAccount($pay_type, $excludeIds);
if (!$account) {
break;
}
try {
$pay = new \app\common\library\pay\PayService($pay_type, $platform, $notify_url, $account['id']);
$result = $pay->create($order_data);
// 校验返回结果中是否包含错误码(微信返回数组 / 支付宝返回 Response
$errInfo = $this->detectPayError($pay_type, $result);
if ($errInfo) {
// 标记不可用 + 切换
\app\common\library\app\paymentpool\Service::markInvalid(
$account['id'],
$errInfo['code'],
$errInfo['msg'],
$order['order_no']
);
$excludeIds[] = $account['id'];
$lastError = $errInfo['msg'];
continue;
}
// 成功:绑定订单和累加使用次数
\app\common\library\app\paymentpool\Service::bindOrder($order['order_no'], $account['id'], $pay_type);
\app\common\library\app\paymentpool\Service::incrUseCount($account['id']);
return ['result' => $result, 'account_id' => $account['id']];
} catch (\Throwable $e) {
// 调用异常:标记该商户号不可用,继续切换
// 使用 Throwable 捕获 \Error 和 \ExceptionPHP 7+ openssl_sign 等可能抛 Error
\app\common\library\app\paymentpool\Service::markInvalid(
$account['id'],
'EXCEPTION',
$e->getMessage(),
$order['order_no']
);
$excludeIds[] = $account['id'];
$lastError = $e->getMessage();
}
}
// 账户池全部失败,回退到系统配置兜底
try {
$pay = new \app\common\library\pay\PayService($pay_type, $platform, $notify_url);
$result = $pay->create($order_data);
return ['result' => $result, 'account_id' => null];
} catch (\Throwable $e) {
// 必须抛出 PHP 原生 \Exception,让外层 handle() 的 catch 能拦截到,
// 避免 app\common\exception\Exception 构造函数直接 send 响应导致原始错误外泄
throw new \Exception($lastError ?: $e->getMessage());
}
}
/**
* 检测 PayService::create() 返回结果中是否包含错误码
*
* @param string $pay_type wechat|alipay
* @param mixed $result
* @return array|null ['code' => string, 'msg' => string],无错误返回 null
*/
private function detectPayError($pay_type, $result)
{
if ($pay_type === 'wechat') {
// Yansongda 微信 mp/miniapp 返回 Collection(数组式),异常时含 return_code=FAIL
$data = is_object($result) && method_exists($result, 'all') ? $result->all() : (array)$result;
if (isset($data['return_code']) && $data['return_code'] !== 'SUCCESS') {
return [
'code' => $data['err_code'] ?? ($data['return_code'] ?? 'FAIL'),
'msg' => $data['err_code_des'] ?? ($data['return_msg'] ?? '微信支付下单失败'),
];
}
if (isset($data['result_code']) && $data['result_code'] !== 'SUCCESS') {
return [
'code' => $data['err_code'] ?? 'FAIL',
'msg' => $data['err_code_des'] ?? '微信支付下单失败',
];
}
return null;
}
if ($pay_type === 'alipay') {
// 支付宝 wap/web 正常返回 Response 对象(HTML 表单),不会带错误码;
// 异常时 SDK 会抛出 GatewayException,在 catch 中处理。
// 但部分错误(如 INVALID_PARAMETER)可能以数组形式返回,需兼容处理。
$data = is_object($result) && method_exists($result, 'toArray')
? $result->toArray()
: (is_array($result) ? $result : null);
if (is_array($data)) {
// 兼容支付宝错误响应结构
$response = $data['alipay_trade_wap_pay_response']
?? $data['alipay_trade_page_pay_response']
?? $data;
if (isset($response['code']) && $response['code'] !== '10000') {
return [
'code' => $response['code'],
'msg' => $response['sub_msg'] ?? ($response['msg'] ?? '支付宝支付下单失败'),
];
}
}
return null;
}
return null;
}
/**
* 历史订单再次发起虚拟支付前,先检查微信侧是否已存在同订单号的支付单。
* 命中旧单时不再继续返回 requestVirtualPayment 参数,避免触发 ORDER_NO_DUP。
* @param Order $order
* @param string $openid
* @param int $isVirtualPay
* @return mixed|null
*/
private function checkExistingVirtualPayOrder($order, $openid, $isVirtualPay)
{
try {
$virtualPayService = new VirtualPayService();
$queryResult = $virtualPayService->queryOrder($openid, $order->order_no);
} catch (\Exception $e) {
return null;
}
if (isset($queryResult['errcode']) && $queryResult['errcode'] !== 0) {
return null;
}
$wxOrder = $queryResult['order'] ?? [];
$status = intval($wxOrder['status'] ?? -1);
if (in_array($status, [2, 3, 4], true)) {
return $this->error('订单已存在微信支付记录,请刷新订单状态');
}
if ($status >= 0) {
return $this->error('该订单已存在微信侧支付单,请先查询支付结果;若需重新支付,请重新下单');
}
return null;
}
public function getNotifyUrl($pay_type,$platform,$openId){
global $_W;
if(\app\common\library\Platform::getSystemType() == 'single'){
return MODULE_URL .'/api/pay/notifyx/pay_type/' . $pay_type . '/platform/' . $platform.'/open_id/' . $openId."/uniacid/".UNIACID;
}else{
return $this->request->domain() .'/addons/tuzi_v1/payment/notify/wechat.php/pay_type/' . $pay_type.'/open_id/' . $openId . '/platform/' . $platform."/uniacid/".UNIACID;
}
}
// 余额支付
public function balancePay ($order, $type, $method) {
$order = Db::transaction(function () use ($order, $type, $method) {
$balanceLibrary = new Balance();
$userInfo = $balanceLibrary->getOrderUser($order['order_no']);
if(!$userInfo){
throw new Exception('获取用户信息失败');
}
//余额不足
if($userInfo['money'] < $order['real_price']){
throw new Exception('余额不足');
}
User::money(($order['real_price'] * -1), $userInfo['id'], "余额支付");
// 支付后流程
$notify = [
'order_no' => $order['order_no'],
'transaction_id' => '',
'pay_time'=>time(),
'real_pay_price' => $order['real_price'],
'pay_type' => 'balance' // 支付方式
];
$notify['payment_json'] = json_encode($notify);
$order->paySuccess($order, $notify);
return $order;
});
$this->success('支付成功', $order);
}
/**
* 获取回调中的参数
* @return array
*/
public function getNotifyParams(){
$paramArr = explode('/', $this->request->pathinfo());
$result = [];
for ($i = 0; $i < count($paramArr); $i += 2) {
$key = $paramArr[$i];
$value = isset($paramArr[$i + 1]) ? $paramArr[$i + 1] : null;
$result[$key] = $value;
}
return $result;
}
/**
* 支付成功回调
*/
public function notifyx()
{
$params = $this->request->param(true);
$pay_type = isset($params['pay_type']) ? $params['pay_type'] : '';
$platform = isset($params['platform']) ? $params['platform'] : '';
$pay = new \app\common\library\pay\PayService($pay_type, $platform);
if($pay_type == 'douyinpay'){
$result = 'false';
try{
$openId = isset($params['open_id']) ? $params['open_id'] : '';
$content = file_get_contents('php://input');
if(empty($content)) return false;
$paymentConfig = \app\common\model\config\System::getConfig($pay_type);
$paymentConfig['notify_url'] = $this->getNotifyUrl($pay_type,$platform,$openId);
$content=json_decode($content,true);
$sign = \douyin\Byte::init($paymentConfig)->verifySignature($paymentConfig['rsa_public_key']);
if($sign){
$data = json_decode($content['msg'],true);
$data['open_id'] = $openId;
$result = $this->successHandle($data, $pay, $pay_type, $platform);
}
} catch (\Exception $e) {
}
}else{
$result = $pay->notify(function ($data, $pay) use ($pay_type, $platform) {
$this->successHandle($data, $pay, $pay_type, $platform);
});
}
return $result;
}
/**
* 支付成功回调处理
* @param $data
* @param $pay
* @param $pay_type
* @param $platform
* @return false|string
*/
public function successHandle($data, $pay,$pay_type, $platform){
try {
switch ($pay_type){
case 'wechat':
$pay_fee = $data['cash_fee'] / 100;
$orderNo = $data['out_trade_no'];
$transactionId = $data['transaction_id'];
break;
case 'douyinpay':
$pay_fee = $data['total_amount'] / 100;
$orderNo = $data['out_order_no'];
$transactionId = $data['order_id'];
break;
case 'alipay':
$dataArr = is_object($data) ? $data->toArray() : $data;
$pay_fee = isset($dataArr['total_amount']) ? $dataArr['total_amount'] : 0;
$orderNo = isset($dataArr['out_trade_no']) ? $dataArr['out_trade_no'] : '';
$transactionId = isset($dataArr['trade_no']) ? $dataArr['trade_no'] : '';
break;
}
$order = Order::where('order_no', $orderNo)->find();
if (!$order || $order->status != \app\common\constant\order\Status::STATUS_UNPAID) {
if (!$order && strpos($orderNo, 'TEST') === 0
&& \app\admin\library\project\App::isInstall('paymentpool')) {
$testRecord = \app\admin\model\app\paymentpool\Test::where('test_no', $orderNo)->find();
if ($testRecord && $testRecord->status == \app\admin\model\app\paymentpool\Test::STATUS_PENDING) {
$testRecord->status = \app\admin\model\app\paymentpool\Test::STATUS_SUCCESS;
$testRecord->save();
}
}
return $pay->success()->send();
}
Db::transaction(function () use ($order,$orderNo,$transactionId, $data, $pay_type, $platform, $pay_fee) {
$paymentJson = $pay_type == 'douyinpay' ? json_encode($data) : (is_object($data) ? json_encode($data->toArray()) : json_encode($data));
$notify = [
'order_no' => $orderNo,
'pay_time'=>time(),
'transaction_id' => $transactionId,
'payment_json' => $paymentJson,
'real_pay_price' => $pay_fee,
'pay_type' => $pay_type
];
$order->paySuccess($order, $notify);
});
if($pay_type == 'douyinpay'){
return '{"err_no": 0,"err_tips": "success"}';
}
return $pay->success()->send();
} catch (\Exception $e) {
}
return false;
}
/**
* 退款成功回调
*/
public function notifyr()
{
$params = $this->request->param(true);
$pay_type = $params['pay_type'];
$platform = $params['platform'];
$pay = new \app\common\library\pay\PayService($pay_type, $platform);
if($pay_type == 'douyinpay'){
$result = 'false';
// try{
// $content = file_get_contents('php://input');
// if(empty($content)) return '{"err_no": 0,"err_tips": "success"}';
// $paymentConfig = \app\common\model\config\System::getConfig($pay_type);
// $paymentConfig['notify_url'] = $this->getNotifyUrl($pay_type,$platform,$openId);
// $content=json_decode($content,true);
// $sign = \douyin\Byte::init($paymentConfig)->checkSign($content);
//
// if($sign == $content['msg_signature']){
// $data = json_decode($content['msg'],true);
// $data['open_id'] = $openId;
// $result = $this->successHandle($data, $pay, $pay_type, $platform);
// }
// } catch (\Exception $e) {
// file_put_contents("./err1.txt",$e->getMessage());
// }
return '{"err_no": 0,"err_tips": "success"}';
}else{
$result = $pay->notifyRefund(function ($data, $pay) use ($pay_type, $platform) {
try {
$out_refund_no = $data['out_refund_no'];
$out_trade_no = $data['out_trade_no'];
// 编写退款逻辑
$order = Order::where([
'order_no'=> $out_trade_no
])->find();
$refundLog = \app\admin\model\order\RefundLog::where([
'refund_no'=> $out_refund_no
])->order("id","desc")->find();
file_put_contents("./refund_fail.txt",json_encode([!$order , !$refundLog , $refundLog['status']])."\n\n",FILE_APPEND);
if (!$order || !$refundLog || $refundLog['status'] != 0) {
// 订单不存在,或者订单已支付
return $pay->success()->send();
}
$item = \app\common\model\order\Item::where('id', $refundLog['order_item_id'])->find();
Db::transaction(function () use ($order, $item, $refundLog) {
//退款成功
\app\admin\model\order\Refund::refundFinish($order, $item, $refundLog);
});
return $pay->success()->send();
} catch (\Exception $e) {
file_put_contents("./refund_fail.txt",$e->getMessage()."\n\n",FILE_APPEND);
}
});
return $result;
}
}
}