Przeglądaj źródła

Add 人工支付通知 互动消息通知

兔姬桑 4 lat temu
rodzic
commit
559c9651cf

+ 258 - 0
app/Channels/Components/WeChat.php

@@ -0,0 +1,258 @@
+<?php
+
+namespace App\Channels\Components;
+
+use Exception;
+
+class WeChat
+{
+    public function VerifyURL($sMsgSignature, $sTimeStamp, $sNonce, $sEchoStr, &$sReplyEchoStr)
+    { // 验证URL
+        //verify msg_signature
+        $array = $this->getSHA1($sTimeStamp, $sNonce, $sEchoStr);
+        $ret = $array[0];
+
+        if ($ret !== 0) {
+            return $ret;
+        }
+
+        $signature = $array[1];
+        if ($signature !== $sMsgSignature) {
+            return -40001; // ValidateSignatureError
+        }
+
+        $result = (new Prpcrypt())->decrypt($sEchoStr);
+        if ($result[0] !== 0) {
+            return $result[0];
+        }
+        $sReplyEchoStr = $result[1];
+
+        return 0;
+    }
+
+    public function getSHA1($timestamp, $nonce, $encrypt_msg)
+    {
+        //排序
+        try {
+            $array = [$encrypt_msg, sysConfig('wechat_token'), $timestamp, $nonce];
+            sort($array, SORT_STRING);
+
+            return [0, sha1(implode($array))];
+        } catch (Exception $e) {
+            echo $e->__toString()."\n";
+
+            return [-40003, null]; // ComputeSignatureError
+        }
+    }
+
+    public function EncryptMsg($sReplyMsg, $sTimeStamp, $sNonce, &$sEncryptMsg)
+    { //将公众平台回复用户的消息加密打包.
+        //加密
+        $array = (new Prpcrypt())->encrypt($sReplyMsg);
+        $ret = $array[0];
+        if ($ret !== 0) {
+            return $ret;
+        }
+
+        if ($sTimeStamp === null) {
+            $sTimeStamp = time();
+        }
+        $encrypt = $array[1];
+
+        //生成安全签名
+        $array = $this->getSHA1($sTimeStamp, $sNonce, $encrypt);
+        $ret = $array[0];
+        if ($ret !== 0) {
+            return $ret;
+        }
+        $signature = $array[1];
+
+        //生成发送的xml
+        $sEncryptMsg = $this->generate($encrypt, $signature, $sTimeStamp, $sNonce);
+
+        return 0;
+    }
+
+    /**
+     * 生成xml消息.
+     *
+     * @param  string  $encrypt  加密后的消息密文
+     * @param  string  $signature  安全签名
+     * @param  string  $timestamp  时间戳
+     * @param  string  $nonce  随机字符串
+     */
+    public function generate($encrypt, $signature, $timestamp, $nonce)
+    {
+        $format = '<xml>
+<Encrypt><![CDATA[%s]]></Encrypt>
+<MsgSignature><![CDATA[%s]]></MsgSignature>
+<TimeStamp>%s</TimeStamp>
+<Nonce><![CDATA[%s]]></Nonce>
+</xml>';
+
+        return sprintf($format, $encrypt, $signature, $timestamp, $nonce);
+    }
+
+    public function DecryptMsg($sMsgSignature, $sTimeStamp = null, $sNonce, $sPostData, &$sMsg)
+    { // 检验消息的真实性,并且获取解密后的明文.
+        //提取密文
+        $array = $this->extract($sPostData);
+        $ret = $array[0];
+
+        if ($ret !== 0) {
+            return $ret;
+        }
+
+        if ($sTimeStamp === null) {
+            $sTimeStamp = time();
+        }
+
+        $encrypt = $array[1];
+
+        //验证安全签名
+        $array = $this->getSHA1($sTimeStamp, $sNonce, $encrypt);
+        $ret = $array[0];
+
+        if ($ret !== 0) {
+            return $ret;
+        }
+
+        $signature = $array[1];
+        if ($signature !== $sMsgSignature) {
+            return -40001; // ValidateSignatureError
+        }
+        $result = (new Prpcrypt())->decrypt($encrypt);
+        if ($result[0] !== 0) {
+            return $result[0];
+        }
+        $sMsg = $result[1];
+
+        return 0;
+    }
+
+    /**
+     * 提取出xml数据包中的加密消息.
+     *
+     * @param  string  $xmltext  待提取的xml字符串
+     * @return array 提取出的加密消息字符串
+     */
+    public function extract($xmltext)
+    {
+        try {
+            $xml = new DOMDocument();
+            $xml->loadXML($xmltext);
+            $array_e = $xml->getElementsByTagName('Encrypt');
+            $encrypt = $array_e->item(0)->nodeValue;
+
+            return [0, $encrypt];
+        } catch (Exception $e) {
+            echo $e."\n";
+
+            return [-40002, null]; // ParseXmlError
+        }
+    }
+}
+
+/**
+ * PKCS7Encoder class.
+ *
+ * 提供基于PKCS7算法的加解密接口.
+ */
+class PKCS7Encoder
+{
+    public static $block_size = 32;
+
+    public function encode($text)
+    { // 对需要加密的明文进行填充补位
+        //计算需要填充的位数
+        $amount_to_pad = self::$block_size - (strlen($text) % self::$block_size);
+        if ($amount_to_pad === 0) {
+            $amount_to_pad = self::$block_size;
+        }
+
+        return $text.str_repeat(chr($amount_to_pad), $amount_to_pad); // 获得补位所用的字符
+    }
+
+    public function decode($text)
+    { // 对解密后的明文进行补位删除
+        $pad = ord(substr($text, -1));
+        if ($pad < 1 || $pad > self::$block_size) {
+            $pad = 0;
+        }
+
+        return substr($text, 0, (strlen($text) - $pad));
+    }
+}
+
+/**
+ * Prpcrypt class.
+ *
+ * 提供接收和推送给公众平台消息的加解密接口.
+ */
+class Prpcrypt
+{
+    public $key = null;
+    public $iv = null;
+
+    public function __construct()
+    {
+        $this->key = base64_decode(sysConfig('wechat_encodingAESKey').'=');
+        $this->iv = substr($this->key, 0, 16);
+    }
+
+    /**
+     * 加密.
+     *
+     * @param $text
+     * @return array
+     */
+    public function encrypt($text)
+    {
+        try {
+            //拼接
+            $text = Str::random().pack('N', strlen($text)).$text.sysConfig('wechat_cid');
+            //添加PKCS#7填充
+            $text = (new PKCS7Encoder)->encode($text);
+            //加密
+            $encrypted = openssl_encrypt($text, 'AES-256-CBC', $this->key, OPENSSL_ZERO_PADDING, $this->iv);
+
+            return [0, $encrypted];
+        } catch (Exception $e) {
+            echo $e->__toString();
+
+            return [-40006, null]; // EncryptAESError
+        }
+    }
+
+    public function decrypt($encrypted): array
+    { // 解密
+        try {
+            //解密
+            $decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $this->key, OPENSSL_ZERO_PADDING, $this->iv);
+        } catch (Exception $e) {
+            return [-40007, null]; // DecryptAESError
+        }
+        try {
+            //删除PKCS#7填充
+            $result = (new PKCS7Encoder)->decode($decrypted);
+            if (strlen($result) < 16) {
+                return [];
+            }
+            //拆分
+            $content = substr($result, 16, strlen($result));
+            $len_list = unpack('N', substr($content, 0, 4));
+            $xml_len = $len_list[1];
+            $xml_content = substr($content, 4, $xml_len);
+            $from_receiveId = substr($content, $xml_len + 4);
+        } catch (Exception $e) {
+            echo $e->__toString();
+
+            return [-40008, null]; // IllegalBuffer
+        }
+        if ($from_receiveId !== sysConfig('wechat_cid')) {
+            return [-40005, null]; // ValidateCorpidError
+        }
+
+        return [0, $xml_content];
+    }
+}

+ 55 - 9
app/Channels/WeChatChannel.php

@@ -2,11 +2,15 @@
 
 namespace App\Channels;
 
+use App\Channels\Components\WeChat;
 use Cache;
 use Helpers;
 use Http;
+use Illuminate\Http\Request;
+use Illuminate\Mail\Markdown;
 use Illuminate\Notifications\Notification;
 use Log;
+use Str;
 
 class WeChatChannel
 {
@@ -20,6 +24,7 @@ class WeChatChannel
             // https://work.weixin.qq.com/api/doc/90000/90135/91039
             $response = Http::get('https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid='.sysConfig('wechat_cid').'&corpsecret='.sysConfig('wechat_secret'));
             if ($response->ok() && isset($response->json()['access_token'])) {
+                $this->access_token = $response->json()['access_token'];
                 Cache::put('wechat_access_token', $response->json()['access_token'], 7200); // 2小时
             } else {
                 Log::critical('Wechat消息推送异常:获取access_token失败!'.PHP_EOL.'携带访问参数:'.$response->body());
@@ -33,24 +38,55 @@ class WeChatChannel
         $message = $notification->toCustom($notifiable);
 
         $url = 'https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token='.$this->access_token;
-        $response = Http::timeout(15)->post($url, [
-            'touser'                   => '@all',
-            'agentid'                  => sysConfig('wechat_aid'),
-            'msgtype'                  => 'text',
-            'text'                     => ['content' => $message['content']],
-            'duplicate_check_interval' => 600,
-        ]);
+
+        if (isset($message['button'])) {
+            // https://work.weixin.qq.com/api/doc/90000/90135/90236#%E6%8C%89%E9%92%AE%E4%BA%A4%E4%BA%92%E5%9E%8B
+            $body = [
+                'touser'        => '@all',
+                'msgtype'       => 'template_card',
+                'agentid'       => sysConfig('wechat_aid'),
+                'template_card' => [
+                    'card_type'               => 'button_interaction',
+                    'main_title'              => ['title' => $message['title']],
+                    'horizontal_content_list' => $message['body'],
+                    'task_id'                 => time().Str::random(),
+                    'button_list'             => [
+                        [
+                            'type'  => 1,
+                            'text'  => '否 決',
+                            'style' => 3,
+                            'url'   => $message['button'][0],
+                        ],
+                        [
+                            'type'  => 1,
+                            'text'  => '确 认',
+                            'style' => 1,
+                            'url'   => $message['button'][1],
+                        ],
+                    ],
+                ],
+            ];
+        } else {
+            $body = [
+                'touser'  => '@all',
+                'agentid' => sysConfig('wechat_aid'),
+                'msgtype' => 'text',
+                'text'    => ['content' => Markdown::parse($message['content'])->toHtml()],
+            ];
+        }
+
+        $response = Http::timeout(15)->post($url, $body);
 
         // 发送成功
         if ($response->ok()) {
             $ret = $response->json();
             if (! $ret['errcode'] && $ret['errmsg'] === 'ok') {
-                Helpers::addNotificationLog($message['title'], $message['content'], 5);
+                Helpers::addNotificationLog($message['title'], $message['content'] ?? var_export($message['body'], true), 5);
 
                 return $ret;
             }
             // 发送失败
-            Helpers::addNotificationLog($message['title'], $message['content'], 5, 'admin', -1, $ret ? $ret['errmsg'] : '未知');
+            Helpers::addNotificationLog($message['title'], $message['content'] ?? var_export($message['body'], true), 5, 'admin', -1, $ret ? $ret['errmsg'] : '未知');
 
             return false;
         }
@@ -59,4 +95,14 @@ class WeChatChannel
 
         return false;
     }
+
+    public function verify(Request $request)
+    {
+        $errCode = (new WeChat())->VerifyURL($request->input('msg_signature'), $request->input('timestamp'), $request->input('nonce'), $request->input('echostr'), $sEchoStr);
+        if ($errCode === 0) {
+            exit($sEchoStr);
+        }
+
+        Log::critical('Wechat互动消息推送异常:'.var_export($errCode, true));
+    }
 }

+ 1 - 1
app/Components/DDNS/Aliyun.php

@@ -88,7 +88,7 @@ class Aliyun
     // 签名
     private function computeSignature($parameters): string
     {
-        ksort($parameters);
+        ksort($parameters, SORT_STRING);
 
         $stringToBeSigned = 'POST&%2F&'.urlencode(http_build_query($parameters));
 

+ 8 - 8
app/Components/NetworkDetection.php

@@ -37,7 +37,7 @@ class NetworkDetection
         $msg = null;
         foreach ([1, 6, 14] as $line) {
             $url = "https://api.oioweb.cn/api/hostping.php?host={$ip}&node={$line}"; // https://api.iiwl.cc/api/ping.php?host=
-            $response = Http::timeout(15)->get($url);
+            $response = Http::timeout(20)->get($url);
 
             // 发送成功
             if ($response->ok()) {
@@ -117,7 +117,7 @@ class NetworkDetection
 
         $url = "https://api.50network.com/china-firewall/check/ip/{$type_string}{$ip}{$port}";
 
-        $response = Http::timeout(15)->get($url);
+        $response = Http::timeout(20)->get($url);
 
         if ($response->ok()) {
             $message = $response->json();
@@ -162,8 +162,8 @@ class NetworkDetection
 
         $checkName = $is_icmp ? 'icmp' : 'tcp';
 
-        $response_cn = Http::timeout(15)->get($cn);
-        $response_us = Http::timeout(15)->get($us);
+        $response_cn = Http::timeout(20)->get($cn);
+        $response_us = Http::timeout(20)->get($us);
 
         if ($response_cn->ok() && $response_us->ok()) {
             $cn = $response_cn->json();
@@ -198,7 +198,7 @@ class NetworkDetection
 
         $checkName = $is_icmp ? 'ICMP' : 'TCP';
 
-        $response = Http::timeout(15)->withoutVerifying()->asForm()->post($url, ['ip' => $ip]);
+        $response = Http::timeout(20)->withoutVerifying()->asForm()->post($url, ['ip' => $ip]);
         if ($response->ok()) {
             $message = $response->json();
             if (! $message) {
@@ -237,8 +237,8 @@ class NetworkDetection
         $us = "https://api.idcoffer.com/ipcheck?host={$ip}&port={$port}";
         $checkName = $is_icmp ? 'ping' : 'tcp';
 
-        $response_cn = Http::timeout(15)->get($cn);
-        $response_us = Http::timeout(15)->get($us);
+        $response_cn = Http::timeout(20)->get($cn);
+        $response_us = Http::timeout(20)->get($us);
 
         if ($response_cn->ok() && $response_us->ok()) {
             $cn = $response_cn->json();
@@ -279,7 +279,7 @@ class NetworkDetection
 
         $checkName = $is_icmp ? 'ping_alive' : 'telnet_alive';
 
-        $response = Http::timeout(15)->get($url);
+        $response = Http::timeout(20)->get($url);
 
         if ($response->ok()) {
             $message = $response->json();

+ 1 - 1
app/Http/Controllers/Admin/LogsController.php

@@ -62,7 +62,7 @@ class LogsController extends Controller
     public function changeOrderStatus(Request $request)
     {
         $order = Order::findOrFail($request->input('oid'));
-        $status = $request->input('status');
+        $status = (int) $request->input('status');
 
         if ($order->update(['status' => $status])) {
             return Response::json(['status' => 'success', 'message' => '更新成功']);

+ 1 - 1
app/Http/Controllers/Admin/UserController.php

@@ -280,7 +280,7 @@ class UserController extends Controller
 
         // 加减余额
         if ($user->updateCredit($amount)) {
-            Helpers::addUserCreditLog($user->id, null, $user->credit, $user->credit + $amount, $amount, '后台手动充值');  // 写入余额变动日志
+            Helpers::addUserCreditLog($user->id, null, $user->credit - $amount, $user->credit, $amount, '后台手动充值');  // 写入余额变动日志
 
             return Response::json(['status' => 'success', 'message' => '充值成功']);
         }

+ 1 - 1
app/Http/Controllers/Gateway/BitpayX.php

@@ -55,7 +55,7 @@ class BitpayX extends AbstractPayment
             'secret'            => sysConfig('bitpay_secret'),
             'type'              => 'FIAT',
         ];
-        ksort($data);
+        ksort($data, SORT_STRING);
 
         return http_build_query($data);
     }

+ 35 - 4
app/Notifications/PaymentConfirm.php

@@ -14,22 +14,23 @@ class PaymentConfirm extends Notification
     use Queueable;
 
     private $order;
+    private $sign;
 
     public function __construct(Order $order)
     {
         $this->order = $order;
+        $this->sign = string_encrypt($order->payment->id);
     }
 
     public function via($notifiable)
     {
-        return [TelegramChannel::class];
+        return sysConfig('payment_confirm_notification');
     }
 
     public function toTelegram($notifiable)
     {
         $order = $this->order;
         $goods = $this->order->goods;
-        $sign = string_encrypt($order->payment->id);
         $message = sprintf("🛒 人工支付\n———————————————\n\t\tℹ️ 账号:%s\n\t\t💰 金额:%s\n\t\t📦 商品:%s\n\t\t", $order->user->username, $order->amount, $goods->name ?? '余额充值');
         foreach (User::role('Super Admin')->get() as $admin) {
             if (! $admin->telegram_user_id) {
@@ -40,8 +41,38 @@ class PaymentConfirm extends Notification
                 ->to($admin->telegram_user_id)
                 ->token(sysConfig('telegram_token'))
                 ->content($message)
-                ->button('确 认', route('payment.notify', ['method' => 'manual', 'sign' => $sign, 'status' => 1]))
-                ->button('否 決', route('payment.notify', ['method' => 'manual', 'sign' => $sign, 'status' => 0]));
+                ->button('否 決', route('payment.notify', ['method' => 'manual', 'sign' => $this->sign, 'status' => 0]))
+                ->button('确 认', route('payment.notify', ['method' => 'manual', 'sign' => $this->sign, 'status' => 1]));
         }
+
+        return false;
+    }
+
+    public function toCustom($notifiable)
+    {
+        $order = $this->order;
+        $goods = $this->order->goods;
+
+        return [
+            'title'  => '🛒 人工支付',
+            'body'   => [
+                [
+                    'keyname' => 'ℹ️ 账号',
+                    'value'   => $order->user->username,
+                ],
+                [
+                    'keyname' => '💰 金额',
+                    'value'   => $order->amount,
+                ],
+                [
+                    'keyname' => '📦 商品',
+                    'value'   => $goods->name ?? '余额充值',
+                ],
+            ],
+            'button' => [
+                route('payment.notify', ['method' => 'manual', 'sign' => $this->sign, 'status' => 0]),
+                route('payment.notify', ['method' => 'manual', 'sign' => $this->sign, 'status' => 1]),
+            ],
+        ];
     }
 }

+ 1 - 0
app/Providers/SettingServiceProvider.php

@@ -31,6 +31,7 @@ class SettingServiceProvider extends ServiceProvider
             'node_daily_notification',
             'node_offline_notification',
             'password_reset_notification',
+            'payment_confirm_notification',
             'payment_received_notification',
             'ticket_closed_notification',
             'ticket_created_notification',

+ 3 - 1
composer.json

@@ -44,7 +44,9 @@
     "zbrettonye/geetest": "^1.2",
     "zbrettonye/hcaptcha": "^1.1",
     "zbrettonye/no-captcha": "^1.1",
-    "zoujingli/ip2region": "^1.0"
+    "zoujingli/ip2region": "^1.0",
+    "ext-dom": "*",
+    "ext-mcrypt": "*"
   },
   "require-dev": {
     "arcanedev/laravel-lang": "^8.0",

+ 24 - 0
database/migrations/2021_10_08_222109_add_payment_confirm_notification.php

@@ -0,0 +1,24 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+class AddPaymentConfirmNotification extends Migration
+{
+    protected $configs = [
+        'payment_confirm_notification',
+        'wechat_token',
+        'wechat_encodingAESKey',
+    ];
+
+    public function up()
+    {
+        foreach ($this->configs as $config) {
+            \App\Models\Config::insert(['name' => $config]);
+        }
+    }
+
+    public function down()
+    {
+        \App\Models\Config::destroy($this->configs);
+    }
+}

+ 5 - 0
resources/views/admin/config/system.blade.php

@@ -164,6 +164,8 @@
                             <x-system.input title="微信企业应用ID" :value="$wechat_aid" code="wechat_aid" holder="应用的AgentId"
                                             help="在<a href=https://work.weixin.qq.com/wework_admin/frame#apps arget=_blank>应用管理</a>自建中创建应用 - AgentId"/>
                             <x-system.input-test title="微信企业应用密钥" :value="$wechat_secret" code="wechat_secret" help='应用的Secret(可能需要下载企业微信才能查看)' holder="应用的Secret" test="weChat"/>
+                            <x-system.input title="微信企业应用TOKEN" :value="$wechat_token" code="wechat_token" help="{{'应用管理->应用->设置API接收->TOKEN,URL设置:'.route('wechat.verify')}}"/>
+                            <x-system.input title="微信企业应用EncodingAESKey" :value="$wechat_encodingAESKey" code="wechat_encodingAESKey" help='应用管理->应用->设置API接收->EncodingAESKey'/>
                             <x-system.input-test title="TG酱Token" :value="$tg_chat_token" code="tg_chat_token" help='启用TG酱,请务必填入本值(<a href=https://t.me/realtgchat_bot
                                     target=_blank>申请 Token</a>)' holder="请到Telegram申请" test="tgChat"/>
                             <x-system.input-test title="PushPlus Token" :value="$pushplus_token" code="pushplus_token" help='启用PushPlus,请务必填入本值(<a href=https://www.pushplus.plus/push1.html
@@ -189,6 +191,8 @@
                                                   help="提醒N次后自动下线节点,为0/留空时不限制,不超过12"/>
                             <x-system.select title="支付成功通知" code="payment_received_notification" help="用户支付订单后通知用户订单状态" multiple="1"
                                              :list="['邮箱' => 'mail', '站内通知' => 'database', 'Telegram' => 'telegram']"/>
+                            <x-system.select title="人工支付确认通知" code="payment_confirm_notification" help="用户使用人工支付后通知管理员处理订单"
+                                             :list="['关闭' => '', 'Telegram' => 'telegram', '微信企业' => 'weChat']"/>
                             <x-system.select title="工单关闭通知" code="ticket_closed_notification" help="工单关闭通知用户" multiple="1"
                                              :list="['邮箱' => 'mail', 'Bark' => 'bark', 'ServerChan' => 'serverChan', 'Telegram' => 'telegram', '微信企业' => 'weChat', 'TG酱' =>
                                              'tgChat', 'PushPlus' => 'pushPlus']"/>
@@ -462,6 +466,7 @@
             $('#node_daily_notification').selectpicker('val', {!! $node_daily_notification !!});
             $('#node_offline_notification').selectpicker('val', {!! $node_offline_notification !!});
             $('#password_reset_notification').selectpicker('val', '{{$password_reset_notification}}');
+            $('#payment_confirm_notification').selectpicker('val', '{{$payment_confirm_notification}}');
             $('#payment_received_notification').selectpicker('val', {!! $payment_received_notification !!});
             $('#ticket_closed_notification').selectpicker('val', {!! $ticket_closed_notification !!});
             $('#ticket_created_notification').selectpicker('val', {!! $ticket_created_notification !!});

+ 2 - 2
resources/views/admin/logs/notification.blade.php

@@ -45,11 +45,11 @@
                             <td> {{$log->type_label}} </td>
                             <td> {{$log->address}} </td>
                             <td> {{$log->title}} </td>
-                            <td> {{$log->content}} </td>
+                            <td class="text-break"> {{$log->content}} </td>
                             <td> {{$log->created_at}} </td>
                             <td>
                                 @if($log->status < 0)
-                                    <span class="badge badge-danger"> {{Str::limit($log->error)}} </span>
+                                    <p class="badge badge-danger text-break font-size-14"> {{$log->error}} </p>
                                 @elseif($log->status > 0)
                                     <labe class="badge badge-success">投递成功</labe>
                                 @else

+ 1 - 0
routes/web.php

@@ -10,6 +10,7 @@ if (config('app.key') && config('settings')) {
 
 Route::get('callback/checkout', 'Gateway\PayPal@getCheckout')->name('paypal.checkout'); // 支付回调相关
 Route::post('api/telegram/webhook', 'TelegramController@webhook')->middleware('telegram'); // Telegram fallback
+Route::get('api/wechat/verify', '\App\Channels\WeChatChannel@verify')->name('wechat.verify'); // 微信回调验证
 
 // 登录相关
 Route::middleware(['isForbidden', 'affiliate', 'isMaintenance'])->group(function () {