doge 1 неделя назад
Родитель
Сommit
52f97e656d

+ 7 - 0
app/Http/Controllers/api/Pay.php

@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
 
 use App\Services\PaymentOrderService;
 use App\Services\Payment\NoPayService;
+use App\Services\Payment\ZimuPayService;
 
 class Pay extends Controller
 {
@@ -31,6 +32,9 @@ class Pay extends Controller
             if (NoPayService::getWithdrawMerchantId() !== '' && ($data['appId'] ?? '') === NoPayService::getWithdrawMerchantId()) {
                 return response($res ? 'SUCCESS' : 'fail');
             }
+            if (ZimuPayService::getAppId() !== '' && ($data['appId'] ?? '') === ZimuPayService::getAppId()) {
+                return response($res ? 'success' : 'fail');
+            }
             return response('success');
         } catch (\Throwable $e) {
             Log::error('支付提现回调处理异常', $context + [
@@ -54,6 +58,9 @@ class Pay extends Controller
             if (NoPayService::getDepositMerchantId() !== '' && ($data['appId'] ?? '') === NoPayService::getDepositMerchantId()) {
                 return response($res ? 'SUCCESS' : 'fail');
             }
+            if (ZimuPayService::getAppId() !== '' && ($data['appId'] ?? '') === ZimuPayService::getAppId()) {
+                return response($res ? 'success' : 'fail');
+            }
             return response('success');
         } catch (\Throwable $e) {
             Log::error('支付充值回调处理异常', $context + [

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

@@ -19,6 +19,7 @@ use App\Services\BalanceLogService;
 use App\Services\PaymentOrderService;
 use App\Services\Payment\JdPayService;
 use App\Services\Payment\NoPayService;
+use App\Services\Payment\ZimuPayService;
 use App\Services\QianBaoWithdrawService;
 use App\Services\WithdrawService;
 use Illuminate\Support\Facades\DB;
@@ -55,6 +56,11 @@ class Wallet extends BaseController
                 return !NoPayService::isRechargeChannel($item['value'] ?? '');
             }));
         }
+        if (!ZimuPayService::canUserRecharge(request()->user->id)) {
+            $list = array_values(array_filter($list, function ($item) {
+                return !ZimuPayService::isRechargeChannel($item['value'] ?? '');
+            }));
+        }
         return $this->success([
             'list' => $list,
         ]);
@@ -82,6 +88,9 @@ class Wallet extends BaseController
             if (NoPayService::isRechargeChannel($params['payment_type']) && !NoPayService::canUserRecharge($user->id)) {
                 throw new Exception(lang("不支持此充值方式"));
             }
+            if (ZimuPayService::isRechargeChannel($params['payment_type']) && !ZimuPayService::canUserRecharge($user->id)) {
+                throw new Exception(lang("不支持此充值方式"));
+            }
             //校验是否支持此充值方式
             $check  = RechargeChannel::checkRechargeChannel($params['payment_type'], $user->recharge_channel_group_id);
             if ($check === false) {
@@ -352,14 +361,14 @@ class Wallet extends BaseController
     
 
     /**
-     * 提现(手动到账): DF001 支付宝转卡; DF002 支付宝转支付宝; DF005数字人民币; JDpay JD钱包; NOwithdraw NO钱包; rgtx(人工提现,手动打款)
+     * 提现(手动到账): DF001 支付宝转卡; DF002 支付宝转支付宝; DF005数字人民币; JDpay JD钱包; NOwithdraw NO钱包; ZIMUwithdraw 808币提现;ZIMUcash 人民币代付; 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,NOwithdraw,rgtx'],
+                'channel' => ['required', 'string', 'in:DF001,DF002,DF005,JDpay,jdpay,NOwithdraw,ZIMUwithdraw,ZIMUcash,rgtx'],
                 'bank_name' => ['required', 'string'],
                 'account' => ['required', 'string'],
                 'card_no' => ['required', 'string'],

+ 303 - 0
app/Services/Payment/ZimuPayService.php

@@ -0,0 +1,303 @@
+<?php
+
+namespace App\Services\Payment;
+
+use App\Services\BaseService;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+use Illuminate\Support\Facades\Log;
+
+class ZimuPayService extends BaseService
+{
+    public const CHANNEL_RECHARGE = 'ZIMUpay';
+    public const CHANNEL_WITHDRAW = 'ZIMUwithdraw';
+    public const CHANNEL_CASH = 'ZIMUcash';
+
+    public const PAY_STATUS_SUCCESS = '3';
+    public const PAY_STATUS_FAIL = '2';
+
+    public const WITHDRAW_STATUS_SUCCESS = '2';
+    public const WITHDRAW_STATUS_FAIL = '5';
+
+    public const CASH_STATUS_SUCCESS = '3';
+    public const CASH_STATUS_FAIL = '4';
+
+    public static function isRechargeChannel(?string $channel): bool
+    {
+        return in_array(strtolower((string)$channel), [
+            strtolower(self::CHANNEL_RECHARGE),
+            'zimupay',
+            '808pay',
+            '808支付',
+        ], true);
+    }
+
+    public static function isWithdrawChannel(?string $channel): bool
+    {
+        return strtolower((string)$channel) === strtolower(self::CHANNEL_WITHDRAW);
+    }
+
+    public static function isCashChannel(?string $channel): bool
+    {
+        return strtolower((string)$channel) === strtolower(self::CHANNEL_CASH);
+    }
+
+    public static function isPayoutChannel(?string $channel): bool
+    {
+        return self::isWithdrawChannel($channel) || self::isCashChannel($channel);
+    }
+
+    public static function canUserRecharge($userId): bool
+    {
+        $allowedUserIds = array_values(array_filter(array_map(
+            'trim',
+            explode(',', (string)config('app.zimu_pay_recharge_user_ids', ''))
+        )));
+
+        if (empty($allowedUserIds)) {
+            return true;
+        }
+
+        return in_array((string)$userId, $allowedUserIds, true);
+    }
+
+    public static function amount($amount): string
+    {
+        return number_format((float)$amount, 2, '.', '');
+    }
+
+    public static function getAppId(): string
+    {
+        return (string)config('app.zimu_pay_app_id');
+    }
+
+    public static function getSecret(): string
+    {
+        return (string)config('app.zimu_pay_key');
+    }
+
+    public static function getPayNotifyUrl(): string
+    {
+        return rtrim(config('app.url'), '/') . '/api/pay/harvest';
+    }
+
+    public static function getPayoutNotifyUrl(): string
+    {
+        return rtrim(config('app.url'), '/') . '/api/pay/notify';
+    }
+
+    public static function signature(array $params, ?string $key = null): string
+    {
+        unset($params['sign']);
+        $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 ?? self::getSecret());
+
+        return strtoupper(md5(implode('&', $parts)));
+    }
+
+    public static function pay($amount, string $orderNo, string $member): array
+    {
+        $data = self::signedData([
+            'appId' => self::getAppId(),
+            'mchOrderNo' => $orderNo,
+            'amount' => self::amount($amount),
+            'member' => $member,
+            'notifyUrl' => self::getPayNotifyUrl(),
+            'timestamp' => self::timestamp(),
+            'nonce' => self::nonce(),
+        ]);
+
+        return self::post((string)config('app.zimu_pay_gateway'), $data);
+    }
+
+    public static function queryPayOrder(string $orderNo): array
+    {
+        return self::post((string)config('app.zimu_pay_query_gateway'), self::queryData($orderNo));
+    }
+
+    public static function withdraw($amount, string $orderNo, string $member, string $receiveAccount, string $realName = ''): array
+    {
+        $data = self::signedData([
+            'appId' => self::getAppId(),
+            'member' => $member,
+            'realName' => $realName,
+            'mchOrderNo' => $orderNo,
+            'amount' => self::amount($amount),
+            'notifyUrl' => self::getPayoutNotifyUrl(),
+            'timestamp' => self::timestamp(),
+            'nonce' => self::nonce(),
+            'receiveAccount' => $receiveAccount,
+        ]);
+
+        return self::post((string)config('app.zimu_withdraw_gateway'), $data);
+    }
+
+    public static function queryWithdrawOrder(string $orderNo): array
+    {
+        return self::post((string)config('app.zimu_withdraw_query_gateway'), self::queryData($orderNo));
+    }
+
+    public static function cash($amount, string $orderNo, string $accountName, string $accountNo, string $bank, string $payMethod = ''): array
+    {
+        $data = self::signedData([
+            'appId' => self::getAppId(),
+            'mchOrderNo' => $orderNo,
+            'amount' => self::amount($amount),
+            'notifyUrl' => self::getPayoutNotifyUrl(),
+            'accountName' => $accountName,
+            'accountNo' => $accountNo,
+            'bank' => $bank,
+            'payMethod' => $payMethod,
+            'timestamp' => self::timestamp(),
+            'nonce' => self::nonce(),
+        ]);
+
+        return self::post((string)config('app.zimu_cash_gateway'), $data);
+    }
+
+    public static function queryCashOrder(string $orderNo): array
+    {
+        return self::post((string)config('app.zimu_cash_query_gateway'), self::queryData($orderNo));
+    }
+
+    public static function balance(): array
+    {
+        $data = self::signedData([
+            'appId' => self::getAppId(),
+            'timestamp' => self::timestamp(),
+            'nonce' => self::nonce(),
+        ]);
+
+        return self::post((string)config('app.zimu_balance_gateway'), $data);
+    }
+
+    public static function verifyNotify(array $params): bool
+    {
+        if (self::getAppId() === '' || ($params['appId'] ?? '') !== self::getAppId() || empty($params['sign'])) {
+            return false;
+        }
+
+        return hash_equals(self::signature($params), strtoupper((string)$params['sign']));
+    }
+
+    public static function notifySignatureDiagnostics(array $params): array
+    {
+        $fields = $params;
+        unset($fields['sign']);
+
+        return [
+            'received_sign' => strtoupper((string)($params['sign'] ?? '')),
+            'calculated_sign' => self::signature($params),
+            'signing_fields' => array_keys(array_filter($fields, static function ($value) {
+                return $value !== null && $value !== '';
+            })),
+        ];
+    }
+
+    public static function cashPayMethod(string $bankName): string
+    {
+        if (str_contains($bankName, '微信')) {
+            return '2';
+        }
+        if (str_contains($bankName, '支付宝')) {
+            return '3';
+        }
+        if (str_contains($bankName, '数字')) {
+            return '4';
+        }
+
+        return '1';
+    }
+
+    private static function queryData(string $orderNo): array
+    {
+        return self::signedData([
+            'appId' => self::getAppId(),
+            'mchOrderNo' => $orderNo,
+            'timestamp' => self::timestamp(),
+            'nonce' => self::nonce(),
+        ]);
+    }
+
+    private static function signedData(array $data): array
+    {
+        $data['sign'] = self::signature($data);
+        return $data;
+    }
+
+    private static function timestamp(): string
+    {
+        return (string)round(microtime(true) * 1000);
+    }
+
+    private static function nonce(): string
+    {
+        return substr(str_replace('.', '', uniqid('', true)), 0, 15);
+    }
+
+    private static function post(string $url, array $data): array
+    {
+        $logData = $data;
+        unset($logData['sign']);
+
+        Log::info('ZIMU支付接口请求', [
+            'url' => $url,
+            'app_id' => self::getAppId(),
+            'data' => $logData,
+        ]);
+
+        try {
+            $response = (new Client(['timeout' => 10.0]))->post($url, [
+                'json' => $data,
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                ],
+            ]);
+            $body = $response->getBody()->getContents();
+
+            Log::info('ZIMU支付接口响应', [
+                'url' => $url,
+                'app_id' => self::getAppId(),
+                'http_status' => $response->getStatusCode(),
+                'body' => $body,
+            ]);
+
+            return json_decode($body, true) ?: [];
+        } catch (RequestException $e) {
+            $response = $e->getResponse();
+            $body = $response ? $response->getBody()->getContents() : '';
+
+            Log::error('ZIMU支付接口请求失败', [
+                'url' => $url,
+                'app_id' => self::getAppId(),
+                'http_status' => $response ? $response->getStatusCode() : null,
+                'body' => $body,
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        } catch (\Throwable $e) {
+            Log::error('ZIMU支付接口异常', [
+                'url' => $url,
+                'app_id' => self::getAppId(),
+                'error' => $e->getMessage(),
+            ]);
+
+            throw $e;
+        }
+    }
+
+    public static function getWhere(array $search = []): array
+    {
+        return [];
+    }
+}

+ 321 - 2
app/Services/PaymentOrderService.php

@@ -13,6 +13,7 @@ use App\Services\Payment\JdPayService;
 use App\Services\Payment\NoPayService;
 use App\Services\Payment\QianBaoService;
 use App\Services\Payment\SanJinService;
+use App\Services\Payment\ZimuPayService;
 use App\Services\ConfigService;
 
 /**
@@ -240,7 +241,7 @@ class PaymentOrderService extends BaseService
         }
 
         // 没有找到支付通道
-        if (empty($channel) && !JdPayService::isChannel($paymentType) && !NoPayService::isRechargeChannel($paymentType)) {
+        if (empty($channel) && !JdPayService::isChannel($paymentType) && !NoPayService::isRechargeChannel($paymentType) && !ZimuPayService::isRechargeChannel($paymentType)) {
             // $text = "发起充值失败 \n";
             // $text .= "最低充值:" . $min . " \n";
             // $text .= "最高充值:" . $max . " \n";
@@ -254,6 +255,54 @@ class PaymentOrderService extends BaseService
             return $result;
         }
 
+        if (ZimuPayService::isRechargeChannel($paymentType) || ZimuPayService::isRechargeChannel($channel)) {
+            $channel = ZimuPayService::CHANNEL_RECHARGE;
+            $bankName = $selectedProduct['name'] ?? '808充值';
+            $data = [];
+            $data['type'] = self::TYPE_PAY;
+            $data['member_id'] = $memberId;
+            $data['amount'] = ZimuPayService::amount($amount);
+            $data['channel'] = $channel;
+            $data['fee'] = $amount * $rate;
+            $data['bank_name'] = $bankName;
+            $order_no = self::createOrderNo('zm' . $data['type'] . '_', $memberId);
+            $data['order_no'] = $order_no;
+            $data['callback_url'] = ZimuPayService::getPayNotifyUrl();
+            $data['remark'] = '充值费率:' . $rate;
+            $data['status'] = self::STATUS_STAY;
+
+            $ret = ZimuPayService::pay($amount, $order_no, (string)$memberId);
+            Log::channel('payment')->info('ZIMU支付发起', [
+                'order_no' => $order_no,
+                'member_id' => $memberId,
+                'amount' => $data['amount'],
+                'response' => $ret,
+            ]);
+            if (($ret['code'] ?? -1) == 0) {
+                $item = $ret['data'] ?? [];
+                $payUrl = $item['payUrl'] ?? '';
+                $data['status'] = self::STATUS_PROCESS;
+                $data['pay_no'] = $item['orderNo'] ?? '';
+                $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'] ?? 'ZIMU支付发起失败';
+                $result['code'] = 20002;
+            }
+            return $result;
+        }
+
         if (NoPayService::isRechargeChannel($paymentType) || NoPayService::isRechargeChannel($channel)) {
             $channel = NoPayService::isRechargeChannel($paymentType) ? $paymentType : $channel;
             $bankName = $selectedProduct['name'] ?? 'NO快捷充值';
@@ -394,6 +443,51 @@ class PaymentOrderService extends BaseService
      */
     public static function receivePay($params)
     {
+        if (ZimuPayService::getAppId() !== '' && ($params['appId'] ?? '') === ZimuPayService::getAppId()) {
+            $info = self::findOne(['order_no' => $params['mchOrderNo'] ?? '']);
+            if (!$info || $info->type != self::TYPE_PAY || !ZimuPayService::isRechargeChannel($info->channel)) {
+                Log::error('ZIMU充值回调订单不存在或类型不匹配', [
+                    'mch_order_no' => $params['mchOrderNo'] ?? '',
+                    'order_id' => $info->id ?? null,
+                    'order_type' => $info->type ?? null,
+                    'channel' => $info->channel ?? null,
+                ]);
+                return false;
+            }
+            if (!ZimuPayService::verifyNotify($params)) {
+                Log::error('ZIMU充值回调验签失败', [
+                    'mch_order_no' => $params['mchOrderNo'] ?? '',
+                    'app_id' => $params['appId'] ?? '',
+                    'signature' => ZimuPayService::notifySignatureDiagnostics($params),
+                ]);
+                return false;
+            }
+            if (bccomp(ZimuPayService::amount($info->amount), ZimuPayService::amount($params['amount'] ?? 0), 2) !== 0) {
+                Log::error('ZIMU充值回调金额不一致', [
+                    'mch_order_no' => $params['mchOrderNo'] ?? '',
+                    'order_amount' => $info->amount,
+                    'callback_amount' => $params['amount'] ?? null,
+                ]);
+                return false;
+            }
+            if ($info->status != self::STATUS_PROCESS) {
+                return true;
+            }
+
+            $processed = self::applyPayCallback(
+                $info,
+                ZimuPayService::amount($info->amount),
+                (string)$params['status'],
+                ZimuPayService::PAY_STATUS_SUCCESS,
+                ZimuPayService::PAY_STATUS_FAIL,
+                $params
+            );
+            if ($processed && (string)$params['status'] === ZimuPayService::PAY_STATUS_SUCCESS) {
+                self::notifyUser($info->member_id, "✅ 支付成功 \n充值金额:{$info->amount} RMB \n订单号:{$info->order_no} \n您充值的金额已到账,请注意查收!");
+            }
+            return true;
+        }
+
         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)) {
@@ -656,6 +750,38 @@ class PaymentOrderService extends BaseService
                 }
                 $order->pay_no = $ret['data']['orderNo'] ?? '';
                 $order->pay_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
+            } elseif (ZimuPayService::isPayoutChannel($order->channel)) {
+                self::assertZimuBalanceEnough($amount, [
+                    'order_id' => $order->id,
+                    'order_no' => $order->order_no,
+                    'member_id' => $order->member_id,
+                    'channel' => $order->channel,
+                ]);
+                if (ZimuPayService::isCashChannel($order->channel)) {
+                    $ret = ZimuPayService::cash(
+                        $amount,
+                        $order->order_no,
+                        (string)$order->account,
+                        (string)$order->card_no,
+                        (string)$order->bank_name,
+                        ZimuPayService::cashPayMethod((string)$order->bank_name)
+                    );
+                } else {
+                    $ret = ZimuPayService::withdraw($amount, $order->order_no, (string)$order->member_id, (string)$order->card_no, (string)$order->account);
+                }
+                Log::channel('payment')->info('ZIMU提现/代付接口调用', [
+                    'order_id' => $order->id,
+                    'order_no' => $order->order_no,
+                    'member_id' => $order->member_id,
+                    'amount' => $amount,
+                    'channel' => $order->channel,
+                    'response' => $ret,
+                ]);
+                if (($ret['code'] ?? -1) != 0) {
+                    throw new Exception($ret['msg'] ?? 'ZIMU提现/代付失败', 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,
@@ -767,6 +893,8 @@ class PaymentOrderService extends BaseService
                 $data['callback_url'] = NoPayService::getWithdrawNotifyUrl();
             } elseif (JdPayService::isChannel($channel)) {
                 $data['callback_url'] = JdPayService::getRemitNotifyUrl();
+            } elseif (ZimuPayService::isPayoutChannel($channel)) {
+                $data['callback_url'] = ZimuPayService::getPayoutNotifyUrl();
             } else {
                 $data['callback_url'] = QianBaoService::getNotifyUrl();
             }
@@ -828,6 +956,46 @@ class PaymentOrderService extends BaseService
                     'error' => $e->getMessage(),
                 ]);
             }
+        } elseif (ZimuPayService::isPayoutChannel($channel)) {
+            try {
+                self::assertZimuBalanceEnough($amount, [
+                    'order_no' => $order_no,
+                    'member_id' => $memberId,
+                    'channel' => $channel,
+                ]);
+                if (ZimuPayService::isCashChannel($channel)) {
+                    $ret = ZimuPayService::cash(
+                        $amount,
+                        $order_no,
+                        (string)$account,
+                        (string)$card_no,
+                        (string)$bank_name,
+                        ZimuPayService::cashPayMethod((string)$bank_name)
+                    );
+                } else {
+                    $ret = ZimuPayService::withdraw($amount, $order_no, (string)$memberId, (string)$card_no, (string)$account);
+                }
+                Log::channel('payment')->info('ZIMU提现/代付接口调用', [
+                    'order_no' => $order_no,
+                    'member_id' => $memberId,
+                    'amount' => $amount,
+                    'channel' => $channel,
+                    'response' => $ret,
+                ]);
+                $success = (($ret['code'] ?? -1) == 0);
+                $failureMessage = $ret['msg'] ?? 'ZIMU提现/代付失败';
+            } catch (Exception $e) {
+                $ret = ['msg' => $e->getMessage()];
+                $success = false;
+                $failureMessage = $e->getMessage();
+                Log::channel('payment_error')->error('ZIMU提现/代付接口异常', [
+                    'order_no' => $order_no,
+                    'member_id' => $memberId,
+                    'amount' => $amount,
+                    'channel' => $channel,
+                    'error' => $e->getMessage(),
+                ]);
+            }
         } elseif (JdPayService::isChannel($channel)) {
             try {
                 self::assertJdBalanceEnough($amount, [
@@ -882,7 +1050,7 @@ class PaymentOrderService extends BaseService
             DB::beginTransaction();
             try {
                 $info->status = self::STATUS_PROCESS;
-                if (NoPayService::isWithdrawChannel($channel) || JdPayService::isChannel($channel)) {
+                if (NoPayService::isWithdrawChannel($channel) || JdPayService::isChannel($channel) || ZimuPayService::isPayoutChannel($channel)) {
                     $info->pay_no = $ret['data']['orderNo'] ?? '';
                     $info->pay_data = json_encode($ret, JSON_UNESCAPED_UNICODE);
                 }
@@ -949,6 +1117,37 @@ class PaymentOrderService extends BaseService
      */
     public static function receiveOrder($params)
     {
+        if (ZimuPayService::getAppId() !== '' && ($params['appId'] ?? '') === ZimuPayService::getAppId()) {
+            $info = self::findOne(['order_no' => $params['mchOrderNo'] ?? '']);
+            if (!$info || $info->type != self::TYPE_PAYOUT || !ZimuPayService::isPayoutChannel($info->channel)) {
+                Log::error('ZIMU提现/代付回调订单不存在或类型不匹配', [
+                    'mch_order_no' => $params['mchOrderNo'] ?? '',
+                    'order_id' => $info->id ?? null,
+                    'order_type' => $info->type ?? null,
+                    'channel' => $info->channel ?? null,
+                ]);
+                return false;
+            }
+            if (!ZimuPayService::verifyNotify($params)) {
+                Log::error('ZIMU提现/代付回调验签失败', [
+                    'mch_order_no' => $params['mchOrderNo'] ?? '',
+                    'app_id' => $params['appId'] ?? '',
+                    'signature' => ZimuPayService::notifySignatureDiagnostics($params),
+                ]);
+                return false;
+            }
+            if (bccomp(ZimuPayService::amount($info->amount), ZimuPayService::amount($params['amount'] ?? 0), 2) !== 0) {
+                Log::error('ZIMU提现/代付回调金额不一致', [
+                    'mch_order_no' => $params['mchOrderNo'] ?? '',
+                    'order_amount' => $info->amount,
+                    'callback_amount' => $params['amount'] ?? null,
+                ]);
+                return false;
+            }
+            self::onSubmitZimuPayout($params, $info);
+            return true;
+        }
+
         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)) {
@@ -1131,6 +1330,62 @@ class PaymentOrderService extends BaseService
         }
     }
 
+    public static function onSubmitZimuPayout($params, $info)
+    {
+        $notification = null;
+        $successStatus = ZimuPayService::isCashChannel($info->channel)
+            ? ZimuPayService::CASH_STATUS_SUCCESS
+            : ZimuPayService::WITHDRAW_STATUS_SUCCESS;
+        $failStatus = ZimuPayService::isCashChannel($info->channel)
+            ? ZimuPayService::CASH_STATUS_FAIL
+            : ZimuPayService::WITHDRAW_STATUS_FAIL;
+
+        try {
+            DB::transaction(function () use ($params, $info, &$notification, $successStatus, $failStatus) {
+                $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['status'] === $successStatus) {
+                    $order->status = self::STATUS_SUCCESS;
+                    $order->save();
+                    $accountLabel = ZimuPayService::isCashChannel($order->channel) ? '收款账号' : '808钱包地址';
+                    $notification = "✅ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \n{$accountLabel}:{$order->card_no} \n提现金额:{$order->amount} \n提现成功,金额已到账,请注意查收!";
+                } elseif ((string)$params['status'] === $failStatus) {
+                    $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, ZimuPayService::amount($order->amount), 10);
+                    $wallet->available_balance = $availableBalance;
+                    $wallet->save();
+
+                    BalanceLogService::addLog($order->member_id, $order->amount, $balance, $availableBalance, '三方提现', $order->id, '提现失败退款');
+                    $accountLabel = ZimuPayService::isCashChannel($order->channel) ? '收款账号' : '808钱包地址';
+                    $notification = "❌ 提现通知 \n提现平台:{$order->bank_name} \n收款人:{$order->account} \n{$accountLabel}:{$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('ZIMU提现/代付回调处理异常', [
+                'order_id' => $info->id,
+                'params' => $params,
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+
     public static function onSubmitPayout($params, $info)
     {
         $memberId = $info->member_id;
@@ -1234,6 +1489,41 @@ class PaymentOrderService extends BaseService
                 return $msg;
             }
 
+            if (ZimuPayService::isRechargeChannel($info->channel)) {
+                $ret = ZimuPayService::queryPayOrder($info->order_no);
+                Log::channel('payment')->info('ZIMU支付查询订单', $ret);
+                if (($ret['code'] ?? -1) == 0) {
+                    $item = $ret['data'] ?? [];
+                    if ((string)($item['status'] ?? '') === ZimuPayService::PAY_STATUS_SUCCESS) {
+                        $processed = self::applyPayCallback(
+                            $info,
+                            ZimuPayService::amount($info->amount),
+                            ZimuPayService::PAY_STATUS_SUCCESS,
+                            ZimuPayService::PAY_STATUS_SUCCESS,
+                            ZimuPayService::PAY_STATUS_FAIL,
+                            $ret
+                        );
+                        $msg['code'] = $processed ? self::YES : self::NOT;
+                        $msg['msg'] = $processed ? '支付成功' : '订单已处理';
+                    } elseif ((string)($item['status'] ?? '') === ZimuPayService::PAY_STATUS_FAIL) {
+                        self::applyPayCallback(
+                            $info,
+                            ZimuPayService::amount($info->amount),
+                            ZimuPayService::PAY_STATUS_FAIL,
+                            ZimuPayService::PAY_STATUS_SUCCESS,
+                            ZimuPayService::PAY_STATUS_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);
@@ -1329,6 +1619,35 @@ class PaymentOrderService extends BaseService
         }
     }
 
+    private static function assertZimuBalanceEnough($amount, array $context = []): void
+    {
+        $ret = ZimuPayService::balance();
+        Log::channel('payment')->info('ZIMU余额查询', $context + [
+            'amount' => $amount,
+            'response' => $ret,
+        ]);
+        if (($ret['code'] ?? -1) != 0) {
+            $logContext = $context + [
+                'amount' => $amount,
+                'response' => $ret,
+            ];
+            Log::channel('payment_error')->error('ZIMU余额查询失败', $logContext);
+            Log::error('ZIMU余额查询失败', $logContext);
+            throw new Exception($ret['msg'] ?? 'ZIMU余额查询失败', HttpStatus::CUSTOM_ERROR);
+        }
+        $balance = $ret['data']['balance'] ?? null;
+        if ($balance === null || bccomp((string)$balance, ZimuPayService::amount($amount), 2) < 0) {
+            $logContext = $context + [
+                'amount' => $amount,
+                'balance' => $balance,
+                'response' => $ret,
+            ];
+            Log::channel('payment_error')->error('ZIMU商户余额不足', $logContext);
+            Log::error('ZIMU商户余额不足', $logContext);
+            throw new Exception('ZIMU商户余额不足', HttpStatus::CUSTOM_ERROR);
+        }
+    }
+
     private static function notifyUser($chatId, string $text): void
     {
         if ((int)User::where('member_id', $chatId)->value('from') !== -1) {

+ 3 - 0
app/Services/QianBaoWithdrawService.php

@@ -12,6 +12,7 @@ use App\Models\Wallet;
 use App\Services\Payment\JdPayService;
 use App\Services\Payment\NoPayService;
 use App\Services\Payment\QianBaoService;
+use App\Services\Payment\ZimuPayService;
 use Exception;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
@@ -474,6 +475,8 @@ class QianBaoWithdrawService
                 $data['callback_url'] = NoPayService::getWithdrawNotifyUrl();
             } elseif (JdPayService::isChannel($channel)) {
                 $data['callback_url'] = JdPayService::getRemitNotifyUrl();
+            } elseif (ZimuPayService::isPayoutChannel($channel)) {
+                $data['callback_url'] = ZimuPayService::getPayoutNotifyUrl();
             } else {
                 $data['callback_url'] = QianBaoService::getNotifyUrl();
             }

+ 11 - 0
config/app.php

@@ -35,6 +35,17 @@ return [
     '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', ''),
+    // ZIMU/808支付配置
+    'zimu_pay_app_id' => env('ZIMU_PAY_APP_ID', ''),
+    'zimu_pay_key' => env('ZIMU_PAY_KEY', ''),
+    'zimu_pay_gateway' => env('ZIMU_PAY_GATEWAY', 'https://api.zimu808.com/api/v1/mch/openapi/order/placeOrder'),
+    'zimu_pay_query_gateway' => env('ZIMU_PAY_QUERY_GATEWAY', 'https://api.zimu808.com/api/v1/mch/openapi/order/placeOrderInfo'),
+    'zimu_balance_gateway' => env('ZIMU_BALANCE_GATEWAY', 'https://api.zimu808.com/api/v1/mch/openapi/account/balance'),
+    'zimu_withdraw_gateway' => env('ZIMU_WITHDRAW_GATEWAY', 'https://api.zimu808.com/api/v1/mch/openapi/order/withdrawOrder'),
+    'zimu_withdraw_query_gateway' => env('ZIMU_WITHDRAW_QUERY_GATEWAY', 'https://api.zimu808.com/api/v1/mch/openapi/order/withdrawOrderInfo'),
+    'zimu_cash_gateway' => env('ZIMU_CASH_GATEWAY', 'https://api.zimu808.com/api/v1/mch/openapi/cash/placeCash'),
+    'zimu_cash_query_gateway' => env('ZIMU_CASH_QUERY_GATEWAY', 'https://api.zimu808.com/api/v1/mch/openapi/cash/cashInfo'),
+    'zimu_pay_recharge_user_ids' => env('ZIMU_PAY_RECHARGE_USER_IDS', ''),
     /*
     |--------------------------------------------------------------------------
     | Application Name

+ 84 - 0
database/migrations/2026_06_09_120000_add_zimu_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 = ['ZIMUpay', 'ZIMUwithdraw', 'ZIMUcash'];
+
+    public function up()
+    {
+        if (Schema::hasTable('recharge_channel')) {
+            $this->upsertChannel(1, '808充值', 'ZIMUpay', 0, 10, 49999);
+            $this->upsertChannel(2, '808币提现', 'ZIMUwithdraw', 0, 10, 49999);
+            $this->upsertChannel(2, '808人民币代付', 'ZIMUcash', 0, 10, 49999);
+        }
+
+        if (Schema::hasTable('recharge_channel_group')) {
+            $this->appendGroupTypes('recharge_type', ['ZIMUpay']);
+            $this->appendGroupTypes('withdraw_type', ['ZIMUwithdraw', 'ZIMUcash']);
+        }
+    }
+
+    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', ['ZIMUpay']);
+            $this->removeGroupTypes('withdraw_type', ['ZIMUwithdraw', 'ZIMUcash']);
+        }
+    }
+
+    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' => 97, '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),
+                ]);
+            }
+        });
+    }
+};

+ 13 - 0
example.env

@@ -97,6 +97,19 @@ NO_PAY_WITHDRAW_GATEWAY=https://payz7x2.qb8.app/order/withdrawOrderCreate
 # 留空表示全部用户可用;多个 users.id 使用英文逗号分隔
 NO_PAY_RECHARGE_USER_IDS=
 
+# ZIMU/808支付配置
+ZIMU_PAY_APP_ID=
+ZIMU_PAY_KEY=
+ZIMU_PAY_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/order/placeOrder
+ZIMU_PAY_QUERY_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/order/placeOrderInfo
+ZIMU_BALANCE_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/account/balance
+ZIMU_WITHDRAW_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/order/withdrawOrder
+ZIMU_WITHDRAW_QUERY_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/order/withdrawOrderInfo
+ZIMU_CASH_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/cash/placeCash
+ZIMU_CASH_QUERY_GATEWAY=https://api.zimu808.com/api/v1/mch/openapi/cash/cashInfo
+# 留空表示全部用户可用;多个 users.id 使用英文逗号分隔
+ZIMU_PAY_RECHARGE_USER_IDS=
+
 # 数据库
 DB_CONNECTION=mysql
 DB_HOST=127.0.0.1

+ 42 - 0
tests/Unit/ZimuPayServiceTest.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\Payment\ZimuPayService;
+use PHPUnit\Framework\TestCase;
+
+class ZimuPayServiceTest extends TestCase
+{
+    public function test_signature_uses_ascii_sorted_non_empty_fields(): void
+    {
+        $signature = ZimuPayService::signature([
+            'appId' => 'APP123',
+            'nonce' => 'abcdef',
+            'mchOrderNo' => 'PO1',
+            'amount' => '40.00',
+            'member' => 'user1',
+            'notifyUrl' => 'https://example.com/api/pay/harvest',
+            'extParam' => '',
+            'timestamp' => '1697253582897',
+        ], 'secret');
+
+        $this->assertSame('3C5D52F6411F9153ED34FEC1DAFA74C0', $signature);
+    }
+
+    public function test_channel_helpers_match_zimu_channels(): void
+    {
+        $this->assertTrue(ZimuPayService::isRechargeChannel(ZimuPayService::CHANNEL_RECHARGE));
+        $this->assertTrue(ZimuPayService::isWithdrawChannel(ZimuPayService::CHANNEL_WITHDRAW));
+        $this->assertTrue(ZimuPayService::isCashChannel(ZimuPayService::CHANNEL_CASH));
+        $this->assertTrue(ZimuPayService::isPayoutChannel(ZimuPayService::CHANNEL_WITHDRAW));
+        $this->assertTrue(ZimuPayService::isPayoutChannel(ZimuPayService::CHANNEL_CASH));
+    }
+
+    public function test_cash_pay_method_matches_bank_name(): void
+    {
+        $this->assertSame('1', ZimuPayService::cashPayMethod('中国建设银行'));
+        $this->assertSame('2', ZimuPayService::cashPayMethod('微信'));
+        $this->assertSame('3', ZimuPayService::cashPayMethod('支付宝'));
+        $this->assertSame('4', ZimuPayService::cashPayMethod('数字人民币'));
+    }
+}