doge 1 日 前
コミット
3b34077da4

+ 10 - 1
app/Http/Controllers/api/Pay.php

@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log;
 use Illuminate\Http\Request;
 
 use App\Services\PaymentOrderService;
+use App\Services\Payment\NoPayService;
 
 class Pay extends Controller
 {
@@ -26,6 +27,10 @@ class Pay extends Controller
 
         $res = PaymentOrderService::receiveOrder($data);
         // var_dump($res);
+        if (NoPayService::getWithdrawMerchantId() !== '' && ($data['appId'] ?? '') === NoPayService::getWithdrawMerchantId()) {
+            echo $res ? 'SUCCESS' : 'fail';
+            return;
+        }
         echo 'success';
     }
 
@@ -38,7 +43,11 @@ class Pay extends Controller
 
         $res = PaymentOrderService::receivePay($data);
         // var_dump($res);
+        if (NoPayService::getDepositMerchantId() !== '' && ($data['appId'] ?? '') === NoPayService::getDepositMerchantId()) {
+            echo $res ? 'SUCCESS' : 'fail';
+            return;
+        }
         echo 'success';
     }
 
-}
+}

+ 11 - 2
app/Http/Controllers/api/Wallet.php

@@ -18,6 +18,7 @@ use App\Models\PaymentOrder;
 use App\Services\BalanceLogService;
 use App\Services\PaymentOrderService;
 use App\Services\Payment\JdPayService;
+use App\Services\Payment\NoPayService;
 use App\Services\QianBaoWithdrawService;
 use App\Services\WithdrawService;
 use Illuminate\Support\Facades\DB;
@@ -49,6 +50,11 @@ class Wallet extends BaseController
                 return !JdPayService::isChannel($item['value'] ?? '');
             }));
         }
+        if (!NoPayService::canUserRecharge(request()->user->id)) {
+            $list = array_values(array_filter($list, function ($item) {
+                return !NoPayService::isRechargeChannel($item['value'] ?? '');
+            }));
+        }
         return $this->success([
             'list' => $list,
         ]);
@@ -73,6 +79,9 @@ class Wallet extends BaseController
             if (JdPayService::isChannel($params['payment_type']) && !JdPayService::canUserRecharge($user->id)) {
                 throw new Exception(lang("不支持此充值方式"));
             }
+            if (NoPayService::isRechargeChannel($params['payment_type']) && !NoPayService::canUserRecharge($user->id)) {
+                throw new Exception(lang("不支持此充值方式"));
+            }
             //校验是否支持此充值方式
             $check  = RechargeChannel::checkRechargeChannel($params['payment_type'], $user->recharge_channel_group_id);
             if ($check === false) {
@@ -343,14 +352,14 @@ class Wallet extends BaseController
     
 
     /**
-     * 提现(手动到账): DF001 支付宝转卡; DF002 支付宝转支付宝; DF005数字人民币; JDpay JD钱包; rgtx(人工提现,手动打款)
+     * 提现(手动到账): DF001 支付宝转卡; DF002 支付宝转支付宝; DF005数字人民币; JDpay JD钱包; NOwithdraw NO钱包; rgtx(人工提现,手动打款)
      */
     public function payout() {
         $error_code = -1;
         try {
             $params = request()->validate([
                 'amount' => ['required', 'numeric', 'min:0.01'],
-                'channel' => ['required', 'string', 'in:DF001,DF002,DF005,JDpay,jdpay,rgtx'],
+                'channel' => ['required', 'string', 'in:DF001,DF002,DF005,JDpay,jdpay,NOwithdraw,rgtx'],
                 'bank_name' => ['required', 'string'],
                 'account' => ['required', 'string'],
                 'card_no' => ['required', 'string'],

+ 181 - 0
app/Services/Payment/NoPayService.php

@@ -0,0 +1,181 @@
+<?php
+
+namespace App\Services\Payment;
+
+use App\Services\BaseService;
+use GuzzleHttp\Client;
+
+class NoPayService extends BaseService
+{
+    public const CHANNEL_SCAN = 'NOpay12';
+    public const CHANNEL_BALANCE = 'NOpay13';
+    public const WITHDRAW_CHANNEL = 'NOwithdraw';
+
+    public const STATE_SUCCESS = '9';
+    public const STATE_FAIL = '10';
+
+    public static function isRechargeChannel(?string $channel): bool
+    {
+        return in_array(strtolower((string)$channel), [
+            strtolower(self::CHANNEL_SCAN),
+            strtolower(self::CHANNEL_BALANCE),
+        ], true);
+    }
+
+    public static function isWithdrawChannel(?string $channel): bool
+    {
+        return strtolower((string)$channel) === strtolower(self::WITHDRAW_CHANNEL);
+    }
+
+    public static function canUserRecharge($userId): bool
+    {
+        $allowedUserIds = array_values(array_filter(array_map(
+            'trim',
+            explode(',', (string)config('app.no_pay_recharge_user_ids', ''))
+        )));
+
+        if (empty($allowedUserIds)) {
+            return true;
+        }
+
+        return in_array((string)$userId, $allowedUserIds, true);
+    }
+
+    public static function paymentMethod(string $channel): int
+    {
+        return strtolower($channel) === strtolower(self::CHANNEL_BALANCE) ? 13 : 12;
+    }
+
+    public static function amount($amount): string
+    {
+        return number_format((float)$amount, 2, '.', '');
+    }
+
+    public static function getDepositMerchantId(): string
+    {
+        return (string)config('app.no_pay_deposit_mch_id');
+    }
+
+    public static function getWithdrawMerchantId(): string
+    {
+        return (string)config('app.no_pay_withdraw_mch_id');
+    }
+
+    public static function getDepositNotifyUrl(): string
+    {
+        return rtrim(config('app.url'), '/') . '/api/pay/harvest';
+    }
+
+    public static function getWithdrawNotifyUrl(): string
+    {
+        return rtrim(config('app.url'), '/') . '/api/pay/notify';
+    }
+
+    public static function signature(array $params, string $key): string
+    {
+        unset($params['sign']);
+        $params['version'] = $params['version'] ?? 'v1';
+        $params = array_filter($params, static function ($value) {
+            return $value !== null && $value !== '';
+        });
+        ksort($params, SORT_STRING);
+
+        $parts = [];
+        foreach ($params as $name => $value) {
+            $parts[] = $name . '=' . $value;
+        }
+        $parts[] = 'key=' . $key;
+
+        return hash('sha256', implode('&', $parts));
+    }
+
+    public static function pay($amount, string $orderNo, string $memberNo, string $channel): array
+    {
+        $data = self::signedData([
+            'appId' => self::getDepositMerchantId(),
+            'merchantMemberNo' => $memberNo,
+            'merchantOrderNo' => $orderNo,
+            'amount' => self::amount($amount),
+            'paymentMethod' => self::paymentMethod($channel),
+            'notifyUrl' => self::getDepositNotifyUrl(),
+            'timestamp' => time(),
+            'version' => 'v1',
+        ], (string)config('app.no_pay_deposit_key'));
+
+        return self::post((string)config('app.no_pay_deposit_gateway'), $data, self::getDepositMerchantId());
+    }
+
+    public static function queryPayOrder(string $orderNo, string $memberNo): array
+    {
+        $data = self::signedData([
+            'appId' => self::getDepositMerchantId(),
+            'merchantOrderNo' => $orderNo,
+            'merchantMemberNo' => $memberNo,
+            'timestamp' => time(),
+            'version' => 'v1',
+        ], (string)config('app.no_pay_deposit_key'));
+
+        return self::post((string)config('app.no_pay_deposit_query_gateway'), $data, self::getDepositMerchantId());
+    }
+
+    public static function withdraw($amount, string $orderNo, string $memberNo, string $accountName, string $qAccount): array
+    {
+        $data = self::signedData([
+            'appId' => self::getWithdrawMerchantId(),
+            'merchantOrderNo' => $orderNo,
+            'merchantMemberNo' => $memberNo,
+            'amount' => self::amount($amount),
+            'accountName' => $accountName,
+            'notifyUrl' => self::getWithdrawNotifyUrl(),
+            'qAccount' => $qAccount,
+            'timestamp' => time(),
+            'version' => 'v1',
+        ], (string)config('app.no_pay_withdraw_key'));
+
+        return self::post((string)config('app.no_pay_withdraw_gateway'), $data, self::getWithdrawMerchantId());
+    }
+
+    public static function verifyDepositNotify(array $params): bool
+    {
+        return self::verifyNotify($params, self::getDepositMerchantId(), (string)config('app.no_pay_deposit_key'));
+    }
+
+    public static function verifyWithdrawNotify(array $params): bool
+    {
+        return self::verifyNotify($params, self::getWithdrawMerchantId(), (string)config('app.no_pay_withdraw_key'));
+    }
+
+    private static function signedData(array $data, string $key): array
+    {
+        $data['sign'] = self::signature($data, $key);
+        return $data;
+    }
+
+    private static function verifyNotify(array $params, string $merchantId, string $key): bool
+    {
+        if ($merchantId === '' || ($params['appId'] ?? '') !== $merchantId || empty($params['sign'])) {
+            return false;
+        }
+
+        return hash_equals(self::signature($params, $key), strtolower((string)$params['sign']));
+    }
+
+    private static function post(string $url, array $data, string $merchantId): array
+    {
+        $response = (new Client(['timeout' => 10.0]))->post($url, [
+            'json' => $data,
+            'headers' => [
+                'Accept' => 'application/json',
+                'appId' => $merchantId,
+                'language' => 'zh_CN',
+            ],
+        ]);
+
+        return json_decode($response->getBody()->getContents(), true) ?: [];
+    }
+
+    public static function getWhere(array $search = []): array
+    {
+        return [];
+    }
+}

+ 224 - 5
app/Services/PaymentOrderService.php

@@ -10,6 +10,7 @@ use Exception;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use App\Services\Payment\JdPayService;
+use App\Services\Payment\NoPayService;
 use App\Services\Payment\QianBaoService;
 use App\Services\Payment\SanJinService;
 use App\Services\ConfigService;
@@ -239,7 +240,7 @@ class PaymentOrderService extends BaseService
         }
 
         // 没有找到支付通道
-        if (empty($channel) && !JdPayService::isChannel($paymentType)) {
+        if (empty($channel) && !JdPayService::isChannel($paymentType) && !NoPayService::isRechargeChannel($paymentType)) {
             // $text = "发起充值失败 \n";
             // $text .= "最低充值:" . $min . " \n";
             // $text .= "最高充值:" . $max . " \n";
@@ -253,6 +254,53 @@ class PaymentOrderService extends BaseService
             return $result;
         }
 
+        if (NoPayService::isRechargeChannel($paymentType) || NoPayService::isRechargeChannel($channel)) {
+            $channel = NoPayService::isRechargeChannel($paymentType) ? $paymentType : $channel;
+            $bankName = $selectedProduct['name'] ?? 'NO快捷充值';
+            $data = [];
+            $data['type'] = self::TYPE_PAY;
+            $data['member_id'] = $memberId;
+            $data['amount'] = NoPayService::amount($amount);
+            $data['channel'] = $channel;
+            $data['fee'] = $amount * $rate;
+            $data['bank_name'] = $bankName;
+            $order_no = self::createOrderNo('no' . $data['type'] . '_', $memberId);
+            $data['order_no'] = $order_no;
+            $data['callback_url'] = NoPayService::getDepositNotifyUrl();
+            $data['remark'] = '充值费率:' . $rate;
+            $data['status'] = self::STATUS_STAY;
+
+            $ret = NoPayService::pay($amount, $order_no, (string)$memberId, $channel);
+            Log::channel('payment')->info('NO支付发起', [
+                'order_no' => $order_no,
+                'member_id' => $memberId,
+                'amount' => $data['amount'],
+                'channel' => $channel,
+                'response' => $ret,
+            ]);
+            if (($ret['code'] ?? -1) == 0) {
+                $payUrl = $ret['data']['url'] ?? '';
+                $data['status'] = self::STATUS_PROCESS;
+                $data['pay_url'] = $payUrl;
+                $data['pay_data'] = json_encode($ret, JSON_UNESCAPED_UNICODE);
+                static::$MODEL::create($data);
+
+                if ($payUrl) {
+                    $result['image'] = asset(self::createPaymentQrCode($payUrl));
+                }
+                $result['text'] = "{$bankName}充值确认 \n";
+                $result['text'] .= "请使用浏览器扫码或者复制支付地址到浏览器打开 \n";
+                $result['text'] .= "支付地址:{$payUrl}\n";
+                $result['text'] .= "支付金额:" . $amount . " RMB \n";
+                $result['text'] .= "支付完成后请耐心等待,支付到账会第一时间通知您! \n";
+                $result['url'] = $payUrl;
+            } else {
+                $result['text'] = $ret['msg'] ?? 'NO支付发起失败';
+                $result['code'] = 20002;
+            }
+            return $result;
+        }
+
         if (JdPayService::isChannel($paymentType) || JdPayService::isChannel($channel)) {
             $channel = JdPayService::CHANNEL;
             $bankName = $selectedProduct['name'] ?? 'JD钱包';
@@ -346,6 +394,35 @@ class PaymentOrderService extends BaseService
      */
     public static function receivePay($params)
     {
+        if (NoPayService::getDepositMerchantId() !== '' && ($params['appId'] ?? '') === NoPayService::getDepositMerchantId()) {
+            $info = self::findOne(['order_no' => $params['merchantOrderNo'] ?? '']);
+            if (!$info || $info->type != self::TYPE_PAY || !NoPayService::isRechargeChannel($info->channel)) {
+                return false;
+            }
+            if (!NoPayService::verifyDepositNotify($params)) {
+                return false;
+            }
+            if (bccomp(NoPayService::amount($info->amount), NoPayService::amount($params['amount'] ?? 0), 2) !== 0) {
+                return false;
+            }
+            if ($info->status != self::STATUS_PROCESS) {
+                return true;
+            }
+
+            $processed = self::applyPayCallback(
+                $info,
+                NoPayService::amount($info->amount),
+                (string)$params['state'],
+                NoPayService::STATE_SUCCESS,
+                NoPayService::STATE_FAIL,
+                $params
+            );
+            if ($processed && (string)$params['state'] === NoPayService::STATE_SUCCESS) {
+                self::notifyUser($info->member_id, "✅ 支付成功 \n充值金额:{$info->amount} RMB \n订单号:{$info->order_no} \n您充值的金额已到账,请注意查收!");
+            }
+            return true;
+        }
+
         if (($params['userCode'] ?? '') === JdPayService::getMerchantId()) {
             $info = self::findOne(['order_no' => $params['orderCode'] ?? ''])
                 ?: static::$MODEL::where('pay_no', $params['orderCode'] ?? '')->first();
@@ -548,7 +625,22 @@ class PaymentOrderService extends BaseService
             if (!$order) throw new Exception("订单不存在_{$orderId}", HttpStatus::CUSTOM_ERROR);
             $amount = $order->amount;
             $amount = number_format($amount, 2, '.', '');
-            if (JdPayService::isChannel($order->channel)) {
+            if (NoPayService::isWithdrawChannel($order->channel)) {
+                $ret = NoPayService::withdraw($amount, $order->order_no, (string)$order->member_id, (string)$order->account, (string)$order->card_no);
+                Log::channel('payment')->info('NO提现接口调用', [
+                    'order_id' => $order->id,
+                    'order_no' => $order->order_no,
+                    'member_id' => $order->member_id,
+                    'amount' => $amount,
+                    'q_account' => $order->card_no,
+                    'response' => $ret,
+                ]);
+                if (($ret['code'] ?? -1) != 0) {
+                    throw new Exception($ret['msg'] ?? 'NO提现失败', HttpStatus::CUSTOM_ERROR);
+                }
+                $order->pay_no = $ret['data']['orderNo'] ?? '';
+                $order->pay_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
+            } elseif (JdPayService::isChannel($order->channel)) {
                 self::assertJdBalanceEnough($amount, [
                     'order_id' => $order->id,
                     'order_no' => $order->order_no,
@@ -655,7 +747,13 @@ class PaymentOrderService extends BaseService
             $data['bank_name'] = $bank_name;
             $data['account'] = $account;
             $data['card_no'] = $card_no;
-            $data['callback_url'] = JdPayService::isChannel($channel) ? JdPayService::getRemitNotifyUrl() : QianBaoService::getNotifyUrl();
+            if (NoPayService::isWithdrawChannel($channel)) {
+                $data['callback_url'] = NoPayService::getWithdrawNotifyUrl();
+            } elseif (JdPayService::isChannel($channel)) {
+                $data['callback_url'] = JdPayService::getRemitNotifyUrl();
+            } else {
+                $data['callback_url'] = QianBaoService::getNotifyUrl();
+            }
             $data['status'] = self::STATUS_STAY;
             $data['remark'] = '提现费率:0.2%+2';
 
@@ -690,7 +788,31 @@ class PaymentOrderService extends BaseService
         }
 
         // 调用三方支付接口(在事务外)
-        if (JdPayService::isChannel($channel)) {
+        if (NoPayService::isWithdrawChannel($channel)) {
+            try {
+                $ret = NoPayService::withdraw($amount, $order_no, (string)$memberId, (string)$account, (string)$card_no);
+                Log::channel('payment')->info('NO提现接口调用', [
+                    'order_no' => $order_no,
+                    'member_id' => $memberId,
+                    'amount' => $amount,
+                    'q_account' => $card_no,
+                    'response' => $ret,
+                ]);
+                $success = (($ret['code'] ?? -1) == 0);
+                $failureMessage = $ret['msg'] ?? 'NO提现失败';
+            } catch (Exception $e) {
+                $ret = ['msg' => $e->getMessage()];
+                $success = false;
+                $failureMessage = $e->getMessage();
+                Log::channel('payment_error')->error('NO提现接口异常', [
+                    'order_no' => $order_no,
+                    'member_id' => $memberId,
+                    'amount' => $amount,
+                    'q_account' => $card_no,
+                    'error' => $e->getMessage(),
+                ]);
+            }
+        } elseif (JdPayService::isChannel($channel)) {
             try {
                 self::assertJdBalanceEnough($amount, [
                     'order_no' => $order_no,
@@ -744,7 +866,7 @@ class PaymentOrderService extends BaseService
             DB::beginTransaction();
             try {
                 $info->status = self::STATUS_PROCESS;
-                if (JdPayService::isChannel($channel)) {
+                if (NoPayService::isWithdrawChannel($channel) || JdPayService::isChannel($channel)) {
                     $info->pay_no = $ret['data']['orderNo'] ?? '';
                     $info->pay_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
                 }
@@ -811,6 +933,21 @@ class PaymentOrderService extends BaseService
      */
     public static function receiveOrder($params)
     {
+        if (NoPayService::getWithdrawMerchantId() !== '' && ($params['appId'] ?? '') === NoPayService::getWithdrawMerchantId()) {
+            $info = self::findOne(['order_no' => $params['merchantOrderNo'] ?? '']);
+            if (!$info || $info->type != self::TYPE_PAYOUT || !NoPayService::isWithdrawChannel($info->channel)) {
+                return false;
+            }
+            if (!NoPayService::verifyWithdrawNotify($params)) {
+                return false;
+            }
+            if (bccomp(NoPayService::amount($info->amount), NoPayService::amount($params['amount'] ?? 0), 2) !== 0) {
+                return false;
+            }
+            self::onSubmitNoPayout($params, $info);
+            return true;
+        }
+
         if (($params['userCode'] ?? '') === JdPayService::getMerchantId()) {
             $info = self::findOne(['order_no' => $params['customerOrderCode'] ?? ''])
                 ?: static::$MODEL::where('pay_no', $params['orderCode'] ?? '')->first();
@@ -915,6 +1052,53 @@ class PaymentOrderService extends BaseService
         }
     }
 
+    public static function onSubmitNoPayout($params, $info)
+    {
+        $notification = null;
+        try {
+            DB::transaction(function () use ($params, $info, &$notification) {
+                $order = PaymentOrder::where('id', $info->id)->lockForUpdate()->first();
+                if (!$order || $order->status != self::STATUS_PROCESS) {
+                    return;
+                }
+
+                $order->callback_data = json_encode($params, JSON_UNESCAPED_UNICODE);
+                if ((string)$params['state'] === NoPayService::STATE_SUCCESS) {
+                    $order->status = self::STATUS_SUCCESS;
+                    $order->save();
+                    $notification = "✅ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \nNO钱包账号:{$order->card_no} \n提现金额:{$order->amount} \n提现成功,金额已到账,请注意查收!";
+                } elseif ((string)$params['state'] === NoPayService::STATE_FAIL) {
+                    $order->status = self::STATUS_FAIL;
+                    $order->save();
+
+                    $wallet = WalletModel::where('member_id', $order->member_id)->lockForUpdate()->first();
+                    if (!$wallet) {
+                        throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+                    }
+                    $balance = $wallet->available_balance;
+                    $availableBalance = bcadd($balance, NoPayService::amount($order->amount), 10);
+                    $wallet->available_balance = $availableBalance;
+                    $wallet->save();
+
+                    BalanceLogService::addLog($order->member_id, $order->amount, $balance, $availableBalance, '三方提现', $order->id, '提现失败退款');
+                    $notification = "❌ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \nNO钱包账号:{$order->card_no} \n提现金额:{$order->amount} \n提现失败,金额已返回钱包,请注意查收!";
+                } else {
+                    $order->save();
+                }
+            }, 3);
+
+            if ($notification !== null) {
+                self::notifyUser($info->member_id, $notification);
+            }
+        } catch (Exception $e) {
+            Log::channel('payment_error')->error('NO提现回调处理异常', [
+                'order_id' => $info->id,
+                'params' => $params,
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+
     public static function onSubmitPayout($params, $info)
     {
         $memberId = $info->member_id;
@@ -983,6 +1167,41 @@ class PaymentOrderService extends BaseService
         $msg['code'] = self::NOT;
         $info = self::findOne(['id' => $id]);
         if ($info && $info->status == self::STATUS_PROCESS) {
+            if (NoPayService::isRechargeChannel($info->channel)) {
+                $ret = NoPayService::queryPayOrder($info->order_no, (string)$info->member_id);
+                Log::channel('payment')->info('NO支付查询订单', $ret);
+                if (($ret['code'] ?? -1) == 0) {
+                    $item = $ret['data'] ?? [];
+                    if ((string)($item['state'] ?? '') === NoPayService::STATE_SUCCESS) {
+                        $processed = self::applyPayCallback(
+                            $info,
+                            NoPayService::amount($info->amount),
+                            NoPayService::STATE_SUCCESS,
+                            NoPayService::STATE_SUCCESS,
+                            NoPayService::STATE_FAIL,
+                            $ret
+                        );
+                        $msg['code'] = $processed ? self::YES : self::NOT;
+                        $msg['msg'] = $processed ? '支付成功' : '订单已处理';
+                    } elseif ((string)($item['state'] ?? '') === NoPayService::STATE_FAIL) {
+                        self::applyPayCallback(
+                            $info,
+                            NoPayService::amount($info->amount),
+                            NoPayService::STATE_FAIL,
+                            NoPayService::STATE_SUCCESS,
+                            NoPayService::STATE_FAIL,
+                            $ret
+                        );
+                        $msg['msg'] = '支付失败';
+                    } else {
+                        $msg['msg'] = '支付中';
+                    }
+                } else {
+                    $msg['msg'] = '查询失败:' . ($ret['msg'] ?? '');
+                }
+                return $msg;
+            }
+
             if (JdPayService::isChannel($info->channel)) {
                 $ret = JdPayService::queryPayOrder($info->pay_no ?? '', $info->order_no);
                 Log::channel('payment')->info('JD支付查询订单', $ret);

+ 8 - 1
app/Services/QianBaoWithdrawService.php

@@ -10,6 +10,7 @@ use App\Models\Config;
 use App\Models\PaymentOrder;
 use App\Models\Wallet;
 use App\Services\Payment\JdPayService;
+use App\Services\Payment\NoPayService;
 use App\Services\Payment\QianBaoService;
 use Exception;
 use Illuminate\Support\Facades\Cache;
@@ -469,7 +470,13 @@ class QianBaoWithdrawService
             $data['bank_name'] = $bank_name;
             $data['account'] = $account;
             $data['card_no'] = $card_no;
-            $data['callback_url'] = JdPayService::isChannel($channel) ? JdPayService::getRemitNotifyUrl() : QianBaoService::getNotifyUrl();
+            if (NoPayService::isWithdrawChannel($channel)) {
+                $data['callback_url'] = NoPayService::getWithdrawNotifyUrl();
+            } elseif (JdPayService::isChannel($channel)) {
+                $data['callback_url'] = JdPayService::getRemitNotifyUrl();
+            } else {
+                $data['callback_url'] = QianBaoService::getNotifyUrl();
+            }
             $data['status'] = PaymentOrderService::STATUS_STAY;
             $data['remark'] = '提现费率:0.2%+2';
             // 创建待处理状态的提现记录

+ 9 - 0
config/app.php

@@ -26,6 +26,15 @@ return [
     'jd_remit_query_gateway' => env('JD_REMIT_QUERY_GATEWAY', 'https://openapi.jdpayapi.com/jdpayOpen/api/query/remit-order'),
     'jd_balance_gateway' => env('JD_BALANCE_GATEWAY', 'https://openapi.jdpayapi.com/jdpayOpen/api/balance'),
     'jd_pay_recharge_user_ids' => env('JD_PAY_RECHARGE_USER_IDS', ''),
+    // NO钱包支付配置
+    'no_pay_deposit_mch_id' => env('NO_PAY_DEPOSIT_MCH_ID', ''),
+    'no_pay_deposit_key' => env('NO_PAY_DEPOSIT_KEY', ''),
+    'no_pay_withdraw_mch_id' => env('NO_PAY_WITHDRAW_MCH_ID', ''),
+    'no_pay_withdraw_key' => env('NO_PAY_WITHDRAW_KEY', ''),
+    'no_pay_deposit_gateway' => env('NO_PAY_DEPOSIT_GATEWAY', 'https://payz7x2.qb8.app/order/depositOrderCreate'),
+    'no_pay_deposit_query_gateway' => env('NO_PAY_DEPOSIT_QUERY_GATEWAY', 'https://payz7x2.qb8.app/order/depositOrderQuery'),
+    'no_pay_withdraw_gateway' => env('NO_PAY_WITHDRAW_GATEWAY', 'https://payz7x2.qb8.app/order/withdrawOrderCreate'),
+    'no_pay_recharge_user_ids' => env('NO_PAY_RECHARGE_USER_IDS', ''),
     /*
     |--------------------------------------------------------------------------
     | Application Name

+ 84 - 0
database/migrations/2026_06_04_120000_add_no_pay_channels.php

@@ -0,0 +1,84 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    private const CHANNELS = ['NOpay12', 'NOpay13', 'NOwithdraw'];
+
+    public function up()
+    {
+        if (Schema::hasTable('recharge_channel')) {
+            $this->upsertChannel(1, 'NO快捷充值-扫码支付', 'NOpay12', 0, 10, 49999);
+            $this->upsertChannel(1, 'NO快捷充值-余额支付', 'NOpay13', 0, 10, 49999);
+            $this->upsertChannel(2, 'NO快捷提现', 'NOwithdraw', 0, 10, 49999);
+        }
+
+        if (Schema::hasTable('recharge_channel_group')) {
+            $this->appendGroupTypes('recharge_type', ['NOpay12', 'NOpay13']);
+            $this->appendGroupTypes('withdraw_type', ['NOwithdraw']);
+        }
+    }
+
+    public function down()
+    {
+        if (Schema::hasTable('recharge_channel')) {
+            DB::table('recharge_channel')->whereIn('type', self::CHANNELS)->delete();
+        }
+
+        if (Schema::hasTable('recharge_channel_group')) {
+            $this->removeGroupTypes('recharge_type', ['NOpay12', 'NOpay13']);
+            $this->removeGroupTypes('withdraw_type', ['NOwithdraw']);
+        }
+    }
+
+    private function upsertChannel(int $dataType, string $name, string $type, float $rate, int $min, int $max): void
+    {
+        $row = compact('name', 'type', 'rate', 'min', 'max');
+        $row['data_type'] = $dataType;
+
+        foreach (['key' => $type, 'fixed' => '', 'status' => 1, 'sort' => 98, 'from' => 1] as $column => $value) {
+            if (Schema::hasColumn('recharge_channel', $column)) {
+                $row[$column] = $value;
+            }
+        }
+
+        DB::table('recharge_channel')->updateOrInsert(
+            ['data_type' => $dataType, 'type' => $type],
+            $row
+        );
+    }
+
+    private function appendGroupTypes(string $column, array $newTypes): void
+    {
+        if (!Schema::hasColumn('recharge_channel_group', $column)) {
+            return;
+        }
+
+        DB::table('recharge_channel_group')->orderBy('id')->chunkById(100, function ($groups) use ($column, $newTypes) {
+            foreach ($groups as $group) {
+                $types = array_values(array_filter(explode(',', (string)$group->{$column})));
+                DB::table('recharge_channel_group')->where('id', $group->id)->update([
+                    $column => implode(',', array_values(array_unique(array_merge($types, $newTypes)))),
+                ]);
+            }
+        });
+    }
+
+    private function removeGroupTypes(string $column, array $removeTypes): void
+    {
+        if (!Schema::hasColumn('recharge_channel_group', $column)) {
+            return;
+        }
+
+        DB::table('recharge_channel_group')->orderBy('id')->chunkById(100, function ($groups) use ($column, $removeTypes) {
+            foreach ($groups as $group) {
+                $types = array_values(array_diff(array_filter(explode(',', (string)$group->{$column})), $removeTypes));
+                DB::table('recharge_channel_group')->where('id', $group->id)->update([
+                    $column => implode(',', $types),
+                ]);
+            }
+        });
+    }
+};

+ 11 - 2
example.env

@@ -86,6 +86,17 @@ JD_REMIT_QUERY_GATEWAY=https://openapi.jdpayapi.com/jdpayOpen/api/query/remit-or
 JD_BALANCE_GATEWAY=https://openapi.jdpayapi.com/jdpayOpen/api/balance
 JD_PAY_RECHARGE_USER_IDS=
 
+# NO钱包支付配置
+NO_PAY_DEPOSIT_MCH_ID=
+NO_PAY_DEPOSIT_KEY=
+NO_PAY_WITHDRAW_MCH_ID=
+NO_PAY_WITHDRAW_KEY=
+NO_PAY_DEPOSIT_GATEWAY=https://payz7x2.qb8.app/order/depositOrderCreate
+NO_PAY_DEPOSIT_QUERY_GATEWAY=https://payz7x2.qb8.app/order/depositOrderQuery
+NO_PAY_WITHDRAW_GATEWAY=https://payz7x2.qb8.app/order/withdrawOrderCreate
+# 留空表示全部用户可用;多个 users.id 使用英文逗号分隔
+NO_PAY_RECHARGE_USER_IDS=
+
 # 数据库
 DB_CONNECTION=mysql
 DB_HOST=127.0.0.1
@@ -154,5 +165,3 @@ VITE_PUSHER_PORT="${PUSHER_PORT}"
 VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
 VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
 
-
-

+ 48 - 0
tests/Unit/NoPayServiceTest.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\Payment\NoPayService;
+use PHPUnit\Framework\TestCase;
+
+class NoPayServiceTest extends TestCase
+{
+    public function test_signature_matches_document_example(): void
+    {
+        $signature = NoPayService::signature([
+            'appId' => 'CZJS4BABJ1ZAPUN8',
+            'amount' => '50',
+            'merchantMemberNo' => 'shuri',
+            'merchantOrderNo' => 'order_01',
+            'notifyUrl' => 'www.http://weihao.com',
+            'paymentMethod' => 12,
+            'timestamp' => 1698043692,
+        ], 'CZKb4DB0WNHx5K3ajvcVCeH3ykBCuDIS');
+
+        $this->assertSame(
+            '2ce6c3b7637266b74432faa1dd6a50de9ad358570759b86d9555c8d1a8decd13',
+            $signature
+        );
+    }
+
+    public function test_payment_method_matches_recharge_channel(): void
+    {
+        $this->assertSame(12, NoPayService::paymentMethod(NoPayService::CHANNEL_SCAN));
+        $this->assertSame(13, NoPayService::paymentMethod(NoPayService::CHANNEL_BALANCE));
+    }
+
+    public function test_empty_recharge_user_allowlist_allows_every_user(): void
+    {
+        config(['app.no_pay_recharge_user_ids' => '']);
+
+        $this->assertTrue(NoPayService::canUserRecharge(123));
+    }
+
+    public function test_recharge_user_allowlist_only_allows_configured_users(): void
+    {
+        config(['app.no_pay_recharge_user_ids' => '12, 34']);
+
+        $this->assertTrue(NoPayService::canUserRecharge(12));
+        $this->assertFalse(NoPayService::canUserRecharge(56));
+    }
+}