wupengfei 2 سال پیش
والد
کامیت
e31651eb95
100فایلهای تغییر یافته به همراه12861 افزوده شده و 0 حذف شده
  1. BIN
      1.3.4.20220530 (2).zip
  2. 1 0
      addons/epay/.addonrc
  3. 69 0
      addons/epay/Epay.php
  4. 0 0
      addons/epay/certs/alipayCertPublicKey.crt
  5. 0 0
      addons/epay/certs/alipayRootCert.crt
  6. 0 0
      addons/epay/certs/apiclient_cert.pem
  7. 0 0
      addons/epay/certs/apiclient_key.pem
  8. 0 0
      addons/epay/certs/appCertPublicKey.crt
  9. 378 0
      addons/epay/config.html
  10. 56 0
      addons/epay/config.php
  11. 243 0
      addons/epay/controller/Api.php
  12. 111 0
      addons/epay/controller/Index.php
  13. 10 0
      addons/epay/info.ini
  14. 18 0
      addons/epay/library/Collection.php
  15. 16 0
      addons/epay/library/OrderException.php
  16. 1856 0
      addons/epay/library/QRCode.php
  17. 58 0
      addons/epay/library/RedirectResponse.php
  18. 26 0
      addons/epay/library/Response.php
  19. 310 0
      addons/epay/library/Service.php
  20. 110 0
      addons/epay/library/Wechat.php
  21. 83 0
      addons/epay/library/Yansongda/Pay/Contracts/GatewayApplicationInterface.php
  22. 20 0
      addons/epay/library/Yansongda/Pay/Contracts/GatewayInterface.php
  23. 98 0
      addons/epay/library/Yansongda/Pay/Events.php
  24. 31 0
      addons/epay/library/Yansongda/Pay/Events/ApiRequested.php
  25. 31 0
      addons/epay/library/Yansongda/Pay/Events/ApiRequesting.php
  26. 40 0
      addons/epay/library/Yansongda/Pay/Events/Event.php
  27. 33 0
      addons/epay/library/Yansongda/Pay/Events/MethodCalled.php
  28. 31 0
      addons/epay/library/Yansongda/Pay/Events/PayStarted.php
  29. 23 0
      addons/epay/library/Yansongda/Pay/Events/PayStarting.php
  30. 25 0
      addons/epay/library/Yansongda/Pay/Events/RequestReceived.php
  31. 25 0
      addons/epay/library/Yansongda/Pay/Events/SignFailed.php
  32. 19 0
      addons/epay/library/Yansongda/Pay/Exceptions/BusinessException.php
  33. 44 0
      addons/epay/library/Yansongda/Pay/Exceptions/Exception.php
  34. 20 0
      addons/epay/library/Yansongda/Pay/Exceptions/GatewayException.php
  35. 19 0
      addons/epay/library/Yansongda/Pay/Exceptions/InvalidArgumentException.php
  36. 19 0
      addons/epay/library/Yansongda/Pay/Exceptions/InvalidConfigException.php
  37. 19 0
      addons/epay/library/Yansongda/Pay/Exceptions/InvalidGatewayException.php
  38. 19 0
      addons/epay/library/Yansongda/Pay/Exceptions/InvalidSignException.php
  39. 422 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay.php
  40. 38 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/AppGateway.php
  41. 40 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/Gateway.php
  42. 46 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/MiniGateway.php
  43. 47 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/PosGateway.php
  44. 21 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/RefundGateway.php
  45. 41 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/ScanGateway.php
  46. 452 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/Support.php
  47. 49 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/TransferGateway.php
  48. 26 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/WapGateway.php
  49. 104 0
      addons/epay/library/Yansongda/Pay/Gateways/Alipay/WebGateway.php
  50. 366 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat.php
  51. 62 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/AppGateway.php
  52. 88 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/Gateway.php
  53. 57 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/GroupRedpackGateway.php
  54. 35 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/MiniappGateway.php
  55. 59 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/MpGateway.php
  56. 44 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/PosGateway.php
  57. 61 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/RedpackGateway.php
  58. 50 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/RefundGateway.php
  59. 44 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/ScanGateway.php
  60. 449 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/Support.php
  61. 80 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/TransferGateway.php
  62. 47 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/WapGateway.php
  63. 86 0
      addons/epay/library/Yansongda/Pay/Gateways/Wechat/WebGateway.php
  64. 20 0
      addons/epay/library/Yansongda/Pay/LICENSE
  65. 114 0
      addons/epay/library/Yansongda/Pay/Listeners/KernelLogSubscriber.php
  66. 49 0
      addons/epay/library/Yansongda/Pay/Log.php
  67. 131 0
      addons/epay/library/Yansongda/Pay/Pay.php
  68. 605 0
      addons/epay/library/Yansongda/Supports/Arr.php
  69. 363 0
      addons/epay/library/Yansongda/Supports/Collection.php
  70. 7 0
      addons/epay/library/Yansongda/Supports/Config.php
  71. 20 0
      addons/epay/library/Yansongda/Supports/LICENSE
  72. 91 0
      addons/epay/library/Yansongda/Supports/Log.php
  73. 240 0
      addons/epay/library/Yansongda/Supports/Logger.php
  74. 36 0
      addons/epay/library/Yansongda/Supports/Logger/StdoutHandler.php
  75. 570 0
      addons/epay/library/Yansongda/Supports/Str.php
  76. 142 0
      addons/epay/library/Yansongda/Supports/Traits/Accessable.php
  77. 32 0
      addons/epay/library/Yansongda/Supports/Traits/Arrayable.php
  78. 229 0
      addons/epay/library/Yansongda/Supports/Traits/HasHttpRequest.php
  79. 85 0
      addons/epay/library/Yansongda/Supports/Traits/Serializable.php
  80. 147 0
      addons/epay/library/Yansongda/Supports/Traits/ShouldThrottle.php
  81. 47 0
      addons/epay/view/api/alipay.html
  82. 90 0
      addons/epay/view/api/wechat.html
  83. 12 0
      addons/epay/view/index/index.html
  84. 103 0
      addons/epay/view/layout/default.html
  85. 0 0
      addons/kefu/.addonrc
  86. 297 0
      addons/kefu/Kefu.php
  87. 80 0
      addons/kefu/bootstrap.js
  88. 144 0
      addons/kefu/config.php
  89. 24 0
      addons/kefu/controller/Base.php
  90. 473 0
      addons/kefu/controller/Index.php
  91. 28 0
      addons/kefu/example/stand_out.html
  92. 14 0
      addons/kefu/example/uni-customer/App.vue
  93. 633 0
      addons/kefu/example/uni-customer/components/jyf-parser/jyf-parser.vue
  94. 97 0
      addons/kefu/example/uni-customer/components/jyf-parser/libs/CssHandler.js
  95. 534 0
      addons/kefu/example/uni-customer/components/jyf-parser/libs/MpHtmlParser.js
  96. 93 0
      addons/kefu/example/uni-customer/components/jyf-parser/libs/config.js
  97. 20 0
      addons/kefu/example/uni-customer/components/jyf-parser/libs/handler.wxs
  98. 519 0
      addons/kefu/example/uni-customer/components/jyf-parser/libs/trees.vue
  99. 11 0
      addons/kefu/example/uni-customer/main.js
  100. 86 0
      addons/kefu/example/uni-customer/manifest.json

BIN
1.3.4.20220530 (2).zip


+ 1 - 0
addons/epay/.addonrc

@@ -0,0 +1 @@
+{"files":["application\/admin\/controller\/Epay.php","public\/assets\/addons\/epay\/js\/common.js","public\/assets\/addons\/epay\/images\/wechat.png","public\/assets\/addons\/epay\/images\/screenshot-alipay.png","public\/assets\/addons\/epay\/images\/alipay.png","public\/assets\/addons\/epay\/images\/paid.png","public\/assets\/addons\/epay\/images\/logo-wechat.png","public\/assets\/addons\/epay\/images\/screenshot-wechat.png","public\/assets\/addons\/epay\/images\/scan.png","public\/assets\/addons\/epay\/images\/expired.png","public\/assets\/addons\/epay\/images\/logo-alipay.png","public\/assets\/addons\/epay\/less\/epay.less","public\/assets\/addons\/epay\/less\/common.less","public\/assets\/addons\/epay\/css\/epay.css","public\/assets\/addons\/epay\/css\/common.css"],"license":"regular","licenseto":"52871","licensekey":"96ePGC5wNTgIYnq2 SQfYqYxdkTCop8H+0tXxbA==","domains":["hdlkeji.com"],"licensecodes":[],"validations":["629b583abe0e6955e04ae79d8a2c42db"]}

+ 69 - 0
addons/epay/Epay.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace addons\epay;
+
+use think\Addons;
+use think\Config;
+use think\Loader;
+
+/**
+ * 微信支付宝整合插件
+ */
+class Epay extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+
+        return true;
+    }
+
+    /**
+     * 添加命名空间
+     */
+    public function appInit()
+    {
+        //添加命名空间
+        if (!class_exists('\Yansongda\Pay\Pay')) {
+            Loader::addNamespace('Yansongda\Pay', ADDON_PATH . 'epay' . DS . 'library' . DS . 'Yansongda' . DS . 'Pay' . DS);
+        }
+        if (!class_exists('\Yansongda\Supports\Logger')) {
+            Loader::addNamespace('Yansongda\Supports', ADDON_PATH . 'epay' . DS . 'library' . DS . 'Yansongda' . DS . 'Supports' . DS);
+        }
+    }
+
+}

+ 0 - 0
addons/epay/certs/alipayCertPublicKey.crt


+ 0 - 0
addons/epay/certs/alipayRootCert.crt


+ 0 - 0
addons/epay/certs/apiclient_cert.pem


+ 0 - 0
addons/epay/certs/apiclient_key.pem


+ 0 - 0
addons/epay/certs/appCertPublicKey.crt


+ 378 - 0
addons/epay/config.html

@@ -0,0 +1,378 @@
+<form id="config-form" class="edit-form form-horizontal" role="form" data-toggle="validator" method="POST" action="">
+
+    <div class="panel panel-default panel-intro">
+        <div class="panel-heading">
+            <ul class="nav nav-tabs nav-group">
+                <li class="active"><a href="#wechat" data-toggle="tab">微信支付</a></li>
+                <li><a href="#alipay" data-toggle="tab">支付宝</a></li>
+            </ul>
+        </div>
+
+        <div class="panel-body">
+            <div id="myTabContent" class="tab-content">
+                {foreach $addon.config as $item}
+                {if $item.name=='wechat'}
+                <div class="tab-pane fade active in" id="wechat">
+                    <table class="table table-striped table-config">
+                        <tbody>
+                        <tr>
+                            <td width="15%">APP的app_id</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][appid]" value="{$item.value.appid|default=''}" class="form-control" data-rule="" data-tip="APP应用中支付时使用"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>公众号的app_id</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][app_id]" value="{$item.value.app_id|default=''}" class="form-control" data-rule="" data-tip="公众号中支付时使用"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>公众号的app_secret</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][app_secret]" value="{$item.value.app_secret|default=''}" class="form-control" data-rule="" data-tip="仅在需要获取Openid时使用"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>小程序的app_id</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][miniapp_id]" value="{$item.value.miniapp_id|default=''}" class="form-control" data-rule="" data-tip="仅在小程序支付时使用"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>微信支付商户号ID</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][mch_id]" value="{$item.value.mch_id|default=''}" class="form-control" data-rule="" data-tip=""/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>微信支付商户的密钥</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][key]" value="{$item.value.key|default=''}" class="form-control" data-rule="" data-tip=""/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>支付模式</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        {:Form::radios('row[wechat][mode]',['normal'=>'正式环境','dev'=>'沙箱环境','service'=>'服务商模式'],$item.value.mode??'normal')}
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr data-type="service" class="{:$item.value.mode!='service'?'hidden':''}">
+                            <td>子商户商户号ID</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][sub_mch_id]" value="{$item.value.sub_mch_id|default=''}" class="form-control" data-rule="" data-tip="如果未用到子商户,请勿填写"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr data-type="service" class="{:$item.value.mode!='service'?'hidden':''}">
+                            <td>子商户APP的app_id</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][sub_appid]" value="{$item.value.sub_appid|default=''}" class="form-control" data-rule="" data-tip="如果未用到子商户,请勿填写"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr data-type="service" class="{:$item.value.mode!='service'?'hidden':''}">
+                            <td>子商户公众号的app_id</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][sub_app_id]" value="{$item.value.sub_app_id|default=''}" class="form-control" data-rule="" data-tip="如果未用到子商户,请勿填写"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr data-type="service" class="{:$item.value.mode!='service'?'hidden':''}">
+                            <td>子商户小程序的app_id</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][sub_miniapp_id]" value="{$item.value.sub_miniapp_id|default=''}" class="form-control" data-rule="" data-tip="如果未用到子商户,请勿填写"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>回调通知地址</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[wechat][notify_url]" value="{$item.value.notify_url|default=''}" class="form-control" data-rule="" data-tip="请勿随意修改,实际以逻辑代码中请求的为准"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>微信支付API证书cert</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <div class="input-group">
+                                            <input id="c-cert_client" class="form-control" size="50" name="row[wechat][cert_client]" type="text" value="{$item.value.cert_client|htmlentities}" data-tip="可选, 仅在退款、红包等情况时需要用到">
+                                            <div class="input-group-addon no-border no-padding">
+                                                <span><button type="button" id="faupload-cert_client" class="btn btn-danger faupload" data-url="epay/upload" data-multipart='{"certname":"cert_client"}' data-mimetype="pem" data-input-id="c-cert_client" data-multiple="false"><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
+                                            </div>
+                                            <span class="msg-box n-right" for="c-cert_client"></span>
+                                        </div>
+                                        <div style="margin-top:5px;"><a href="https://pay.weixin.qq.com" target="_blank"><i class="fa fa-question-circle"></i> 如何获取微信支付API证书?</a></div>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>微信支付API证书key</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <div class="input-group">
+                                            <input id="c-cert_key" class="form-control" size="50" name="row[wechat][cert_key]" type="text" value="{$item.value.cert_key|htmlentities}" data-tip="可选, 仅在退款、红包等情况时需要用到">
+                                            <div class="input-group-addon no-border no-padding">
+                                                <span><button type="button" id="faupload-cert_key" class="btn btn-danger faupload" data-url="epay/upload" data-multipart='{"certname":"cert_key"}' data-mimetype="pem" data-input-id="c-cert_key" data-multiple="false"><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
+                                            </div>
+                                            <span class="msg-box n-right" for="c-cert_key"></span>
+                                        </div>
+                                        <div style="margin-top:5px;"><a href="https://pay.weixin.qq.com" target="_blank"><i class="fa fa-question-circle"></i> 如何获取微信支付API证书?</a></div>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <td>记录日志</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        {:Form::radios('row[wechat][log]',['1'=>'开启','0'=>'关闭'],$item.value.log)}
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
+                {elseif $item.name=='alipay'}
+                <div class="tab-pane fade" id="alipay">
+                    <table class="table table-striped table-config">
+                        <tbody>
+                        <tr>
+                            <td width="15%">应用ID(app_id)</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[alipay][app_id]" value="{$item.value.app_id|default=''}" class="form-control" data-rule="" data-tip=""/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>支付模式</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        {:Form::radios('row[alipay][mode]',['normal'=>'正式环境','dev'=>'沙箱环境'],$item.value.mode??'normal')}
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>回调通知地址</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[alipay][notify_url]" value="{$item.value.notify_url|default=''}" class="form-control" data-rule="" data-tip="请勿随意修改,实际以逻辑代码中请求的为准"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>支付跳转地址</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[alipay][return_url]" value="{$item.value.return_url|default=''}" class="form-control" data-rule="" data-tip="请勿随意修改,实际以逻辑代码中请求的为准"/>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>应用私钥(private_key)</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <input type="text" name="row[alipay][private_key]" value="{$item.value.private_key|default=''}" class="form-control" data-rule=""/>
+                                        <div style="margin-top:5px;"><a href="https://opensupport.alipay.com/support/helpcenter/207/201602469554" target="_blank"><i class="fa fa-question-circle"></i> 如何获取应用私钥?</a></div>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>签名方式</td>
+                            <td>
+                                <div>
+                                    <div class="radio">
+                                        <label for="row[alipay][signtype]-publickey"><input id="row[alipay][signtype]-publickey" name="row[alipay][signtype]" type="radio" value="publickey"> 公钥</label>
+                                        <label for="row[alipay][signtype]-cert"><input id="row[alipay][signtype]-cert" checked="checked" name="row[alipay][signtype]" type="radio" value="cert"> 公钥证书</label>
+                                    </div>
+                                </div>
+                                如果要使用转账、提现功能,则必须使用公钥证书
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>支付宝公钥路径(ali_public_key)</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <div class="input-group">
+                                            <input id="c-ali_public_key" class="form-control" size="50" name="row[alipay][ali_public_key]" type="text" value="{$item.value.ali_public_key|default=''|htmlentities}" placeholder="公钥请直接粘贴,公钥证书请点击右侧的上传">
+                                            <div class="input-group-addon no-border no-padding">
+                                                <span><button type="button" id="faupload-ali_public_key" class="btn btn-danger faupload" data-url="epay/upload" data-multipart='{"certname":"ali_public_key"}' data-mimetype="crt" data-input-id="c-ali_public_key" data-multiple="false"><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
+                                            </div>
+                                            <span class="msg-box n-right" for="c-ali_public_key"></span>
+                                        </div>
+                                        <div style="margin-top:5px;"><a href="https://opensupport.alipay.com/support/helpcenter/271/201602474998" target="_blank"><i class="fa fa-question-circle"></i> 如何获取支付宝公钥证书?</a></div>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>应用公钥证书路径(app_cert_public_key)</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <div class="input-group">
+                                            <input id="c-app_cert_public_key" class="form-control" size="50" name="row[alipay][app_cert_public_key]" type="text" value="{$item.value.app_cert_public_key|default=''|htmlentities}">
+                                            <div class="input-group-addon no-border no-padding">
+                                                <span><button type="button" id="faupload-app_cert_public_key" class="btn btn-danger faupload" data-url="epay/upload" data-multipart='{"certname":"app_cert_public_key"}' data-mimetype="crt" data-input-id="c-app_cert_public_key" data-multiple="false"><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
+                                            </div>
+                                            <span class="msg-box n-right" for="c-app_cert_public_key"></span>
+                                        </div>
+                                        <div style="margin-top:5px;"><a href="https://opensupport.alipay.com/support/helpcenter/271/201602474998" target="_blank"><i class="fa fa-question-circle"></i> 如何获取应用公钥证书?</a></div>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <td>支付宝根证书路径(alipay_root_cert)</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        <div class="input-group">
+                                            <input id="c-alipay_root_cert" class="form-control" size="50" name="row[alipay][alipay_root_cert]" type="text" value="{$item.value.alipay_root_cert|default=''|htmlentities}">
+                                            <div class="input-group-addon no-border no-padding">
+                                                <span><button type="button" id="faupload-alipay_root_cert" class="btn btn-danger faupload" data-url="epay/upload" data-multipart='{"certname":"alipay_root_cert"}' data-mimetype="crt" data-input-id="c-alipay_root_cert" data-multiple="false"><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
+                                            </div>
+                                            <span class="msg-box n-right" for="c-alipay_root_cert"></span>
+                                        </div>
+                                        <div style="margin-top:5px;"><a href="https://opensupport.alipay.com/support/helpcenter/271/201602474998" target="_blank"><i class="fa fa-question-circle"></i> 如何获取支付宝证书?</a></div>
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <td>记录日志</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        {:Form::radios('row[alipay][log]',['1'=>'开启','0'=>'关闭'],$item.value.log)}
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <td>PC端使用扫码支付</td>
+                            <td>
+                                <div class="row">
+                                    <div class="col-sm-8 col-xs-12">
+                                        {:Form::radios('row[alipay][scanpay]',['1'=>'开启','0'=>'关闭'],$item.value.scanpay??0)}
+                                    </div>
+                                    <div class="col-sm-4"></div>
+                                </div>
+                            </td>
+                        </tr>
+                        </tbody>
+                    </table>
+                </div>
+                {/if}
+                {/foreach}
+                <div class="form-group layer-footer">
+                    <label class="control-label col-xs-12 col-sm-2"></label>
+                    <div class="col-xs-12 col-sm-8">
+                        <button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
+                        <button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</form>
+<script>
+    document.querySelectorAll("input[name='row[wechat][mode]']").forEach(function (i, j) {
+        i.addEventListener("click", function () {
+            document.querySelectorAll("#wechat table tr[data-type]").forEach(function (m, n) {
+                m.classList.add("hidden");
+            });
+            document.querySelectorAll("#wechat table tr[data-type='" + this.value + "']").forEach(function (m, n) {
+                m.classList.remove("hidden");
+            });
+        });
+    });
+</script>

+ 56 - 0
addons/epay/config.php

@@ -0,0 +1,56 @@
+<?php
+
+return [
+    [
+        'name' => 'wechat',
+        'title' => '微信',
+        'type' => 'array',
+        'content' => [],
+        'value' => [
+            'appid' => '',
+            'app_id' => '',
+            'app_secret' => '',
+            'miniapp_id' => 'wx3d434cd04d4f7a27',
+            'mch_id' => '1628505093',
+            'key' => 'nRoSFVLzuFRPCg1B4bIixHHGrR8ljdK4',
+            'mode' => 'normal',
+            'sub_mch_id' => '',
+            'sub_appid' => '',
+            'sub_app_id' => '',
+            'sub_miniapp_id' => '',
+            'notify_url' => '/addons/epay/api/notifyx/type/wechat',
+            'cert_client' => '/addons/epay/certs/apiclient_cert.pem',
+            'cert_key' => '/addons/epay/certs/apiclient_key.pem',
+            'log' => '1',
+        ],
+        'rule' => '',
+        'msg' => '',
+        'tip' => '微信参数配置',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'alipay',
+        'title' => '支付宝',
+        'type' => 'array',
+        'content' => [],
+        'value' => [
+            'app_id' => '',
+            'mode' => 'normal',
+            'notify_url' => '/addons/epay/api/notifyx/type/alipay',
+            'return_url' => '/addons/epay/api/returnx/type/alipay',
+            'private_key' => '',
+            'signtype' => 'cert',
+            'ali_public_key' => '',
+            'app_cert_public_key' => '',
+            'alipay_root_cert' => '',
+            'log' => '1',
+            'scanpay' => '0',
+        ],
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '支付宝参数配置',
+        'ok' => '',
+        'extend' => '',
+    ],
+];

+ 243 - 0
addons/epay/controller/Api.php

@@ -0,0 +1,243 @@
+<?php
+
+namespace addons\epay\controller;
+
+use addons\epay\library\QRCode;
+use addons\epay\library\Service;
+use addons\epay\library\Wechat;
+use addons\third\model\Third;
+use app\common\library\Auth;
+use think\addons\Controller;
+use think\Response;
+use think\Session;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Pay;
+
+/**
+ * API接口控制器
+ *
+ * @package addons\epay\controller
+ */
+class Api extends Controller
+{
+
+    protected $layout = 'default';
+    protected $config = [];
+
+    /**
+     * 默认方法
+     */
+    public function index()
+    {
+        return;
+    }
+
+    /**
+     * 外部提交
+     */
+    public function submit()
+    {
+        $this->request->filter('trim');
+        $out_trade_no = $this->request->request("out_trade_no");
+        $title = $this->request->request("title");
+        $amount = $this->request->request('amount');
+        $type = $this->request->request('type');
+        $method = $this->request->request('method', 'web');
+        $openid = $this->request->request('openid', '');
+        $auth_code = $this->request->request('auth_code', '');
+        $notifyurl = $this->request->request('notifyurl', '');
+        $returnurl = $this->request->request('returnurl', '');
+
+        if (!$amount || $amount < 0) {
+            $this->error("支付金额必须大于0");
+        }
+
+        if (!$type || !in_array($type, ['alipay', 'wechat'])) {
+            $this->error("支付类型错误");
+        }
+
+        $params = [
+            'type'         => $type,
+            'out_trade_no' => $out_trade_no,
+            'title'        => $title,
+            'amount'       => $amount,
+            'method'       => $method,
+            'openid'       => $openid,
+            'auth_code'    => $auth_code,
+            'notifyurl'    => $notifyurl,
+            'returnurl'    => $returnurl,
+        ];
+        return Service::submitOrder($params);
+    }
+
+    /**
+     * 微信支付(公众号支付&PC扫码支付)
+     * @return string
+     */
+    public function wechat()
+    {
+        $config = Service::getConfig('wechat');
+
+        $isWechat = stripos($this->request->server('HTTP_USER_AGENT'), 'MicroMessenger') !== false;
+        $isMobile = $this->request->isMobile();
+        $this->view->assign("isWechat", $isWechat);
+        $this->view->assign("isMobile", $isMobile);
+
+        //发起PC支付(Scan支付)(PC扫码模式)
+        if ($this->request->isAjax()) {
+            $pay = Pay::wechat($config);
+            $orderid = $this->request->post("orderid");
+            try {
+                $result = $pay->find($orderid);
+                if ($result['return_code'] == 'SUCCESS' && $result['result_code'] == 'SUCCESS') {
+                    $this->success("", "", ['status' => $result['trade_state']]);
+                } else {
+                    $this->error("查询失败");
+                }
+            } catch (GatewayException $e) {
+                $this->error("查询失败");
+            }
+        }
+
+        $orderData = Session::get("wechatorderdata");
+        if (!$orderData) {
+            $this->error("请求参数错误");
+        }
+        if ($isWechat) {
+            //发起公众号(jsapi支付),openid必须
+
+            //如果没有openid,则自动去获取openid
+            if (!isset($orderData['openid']) || !$orderData['openid']) {
+                $orderData['openid'] = Service::getOpenid();
+            }
+
+            $orderData['method'] = 'mp';
+            $type = 'jsapi';
+            $payData = Service::submitOrder($orderData);
+            if (!isset($payData['paySign'])) {
+                $this->error("创建订单失败,请返回重试", "");
+            }
+        } else {
+            $orderData['method'] = 'scan';
+            $type = 'pc';
+            $payData = Service::submitOrder($orderData);
+            if (!isset($payData['code_url'])) {
+                $this->error("创建订单失败,请返回重试", "");
+            }
+        }
+        $this->view->assign("orderData", $orderData);
+        $this->view->assign("payData", $payData);
+        $this->view->assign("type", $type);
+
+        $this->view->assign("title", "微信支付");
+        return $this->view->fetch();
+    }
+
+    /**
+     * 支付宝支付(PC扫码支付)
+     * @return string
+     */
+    public function alipay()
+    {
+        $config = Service::getConfig('alipay');
+
+        $isWechat = stripos($this->request->server('HTTP_USER_AGENT'), 'MicroMessenger') !== false;
+        $isMobile = $this->request->isMobile();
+        $this->view->assign("isWechat", $isWechat);
+        $this->view->assign("isMobile", $isMobile);
+
+        if ($this->request->isAjax()) {
+            $orderid = $this->request->post("orderid");
+            $pay = Pay::alipay($config);
+            try {
+                $result = $pay->find($orderid);
+                if ($result['code'] == '10000' && $result['trade_status'] == 'TRADE_SUCCESS') {
+                    $this->success("", "", ['status' => $result['trade_status']]);
+                } else {
+                    $this->error("查询失败");
+                }
+            } catch (GatewayException $e) {
+                $this->error("查询失败");
+            }
+        }
+
+        //发起PC支付(Scan支付)(PC扫码模式)
+        $orderData = Session::get("alipayorderdata");
+        if (!$orderData) {
+            $this->error("请求参数错误");
+        }
+
+        $orderData['method'] = 'scan';
+        $payData = Service::submitOrder($orderData);
+        if (!isset($payData['qr_code'])) {
+            $this->error("创建订单失败,请返回重试");
+        }
+
+        $type = 'pc';
+        $this->view->assign("orderData", $orderData);
+        $this->view->assign("payData", $payData);
+        $this->view->assign("type", $type);
+        $this->view->assign("title", "支付宝支付");
+        return $this->view->fetch();
+    }
+
+    /**
+     * 支付成功回调
+     */
+    public function notifyx()
+    {
+        $type = $this->request->param('type');
+        if (!Service::checkNotify($type)) {
+            echo '签名错误';
+            return;
+        }
+
+        //你可以在这里你的业务处理逻辑,比如处理你的订单状态、给会员加余额等等功能
+        //下面这句必须要执行,且在此之前不能有任何输出
+        echo "success";
+        return;
+    }
+
+    /**
+     * 支付成功返回
+     */
+    public function returnx()
+    {
+        $type = $this->request->param('type');
+        if (Service::checkReturn($type)) {
+            echo '签名错误';
+            return;
+        }
+
+        //你可以在这里定义你的提示信息,但切记不可在此编写逻辑
+        $this->success("恭喜你!支付成功!", addon_url("epay/index/index"));
+
+        return;
+    }
+
+    /**
+     * 生成二维码
+     */
+    public function qrcode()
+    {
+        $text = $this->request->get('text', 'hello world');
+
+        //如果有安装二维码插件,则调用插件的生成方法
+        if (class_exists("\addons\qrcode\library\Service") && get_addon_info('qrcode')['state']) {
+            $qrCode = \addons\qrcode\library\Service::qrcode(['text' => $text]);
+            $response = Response::create()->header("Content-Type", "image/png");
+
+            header('Content-Type: ' . $qrCode->getContentType());
+            $response->content($qrCode->writeString());
+            return $response;
+        } else {
+            $qr = QRCode::getMinimumQRCode($text);
+            $im = $qr->createImage(8, 5);
+            header("Content-type: image/png");
+            imagepng($im);
+            imagedestroy($im);
+            return;
+        }
+    }
+
+}

+ 111 - 0
addons/epay/controller/Index.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace addons\epay\controller;
+
+use addons\epay\library\Service;
+use fast\Random;
+use think\addons\Controller;
+use Exception;
+
+/**
+ * 微信支付宝插件首页
+ *
+ * 此控制器仅用于开发展示说明和体验,建议自行添加一个新的控制器进行处理返回和回调事件,同时删除此控制器文件
+ *
+ * Class Index
+ * @package addons\epay\controller
+ */
+class Index extends Controller
+{
+
+    protected $layout = 'default';
+
+    protected $config = [];
+
+    public function _initialize()
+    {
+        parent::_initialize();
+        if (!config("app_debug")) {
+            $this->error("仅在开发环境下查看");
+        }
+    }
+
+    public function index()
+    {
+        $this->view->assign("title", "微信支付宝整合插件");
+        return $this->view->fetch();
+    }
+
+    /**
+     * 体验,仅供开发测试
+     */
+    public function experience()
+    {
+        $amount = $this->request->request('amount');
+        $type = $this->request->request('type');
+        $method = $this->request->request('method');
+
+        if (!$amount || $amount < 0) {
+            $this->error("支付金额必须大于0");
+        }
+
+        if (!$type || !in_array($type, ['alipay', 'wechat'])) {
+            $this->error("支付类型不能为空");
+        }
+
+        //订单号
+        $out_trade_no = date("YmdHis") . mt_rand(100000, 999999);
+
+        //订单标题
+        $title = '测试订单';
+
+        //回调链接
+        $notifyurl = $this->request->root(true) . '/addons/epay/index/notifyx/paytype/' . $type;
+        $returnurl = $this->request->root(true) . '/addons/epay/index/returnx/paytype/' . $type . '/out_trade_no/' . $out_trade_no;
+
+        $response = Service::submitOrder($amount, $out_trade_no, $type, $title, $notifyurl, $returnurl, $method);
+
+        return $response;
+    }
+
+    /**
+     * 支付成功,仅供开发测试
+     */
+    public function notifyx()
+    {
+        $paytype = $this->request->param('paytype');
+        $pay = Service::checkNotify($paytype);
+        if (!$pay) {
+            echo '签名错误';
+            return;
+        }
+        $data = $pay->verify();
+        try {
+            $payamount = $paytype == 'alipay' ? $data['total_amount'] : $data['total_fee'] / 100;
+            $out_trade_no = $data['out_trade_no'];
+
+            //你可以在此编写订单逻辑
+        } catch (Exception $e) {
+        }
+        echo $pay->success();
+    }
+
+    /**
+     * 支付返回,仅供开发测试
+     */
+    public function returnx()
+    {
+        $paytype = $this->request->param('paytype');
+        $out_trade_no = $this->request->param('out_trade_no');
+        $pay = Service::checkReturn($paytype);
+        if (!$pay) {
+            $this->error('签名错误', '');
+        }
+
+        //你可以在这里通过out_trade_no去验证订单状态
+        //但是不可以在此编写订单逻辑!!!
+
+        $this->success("请返回网站查看支付结果", addon_url("epay/index/index"));
+    }
+
+}

+ 10 - 0
addons/epay/info.ini

@@ -0,0 +1,10 @@
+name = epay
+title = 微信支付宝整合
+intro = 可用于快速整合企业微信、支付宝支付功能
+author = FastAdmin
+website = https://www.fastadmin.net
+version = 1.2.6
+state = 1
+url = /addons/epay
+license = regular
+licenseto = 52871

+ 18 - 0
addons/epay/library/Collection.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace addons\epay\library;
+
+class Collection extends \Yansongda\Supports\Collection
+{
+
+    /**
+     * 创建 Collection 实例
+     * @access public
+     * @param  array $items 数据
+     * @return static
+     */
+    public static function make($items = [])
+    {
+        return new static($items);
+    }
+}

+ 16 - 0
addons/epay/library/OrderException.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace addons\epay\library;
+
+use think\Exception;
+
+class OrderException extends Exception
+{
+    public function __construct($message = "", $code = 0, $data = [])
+    {
+        $this->message = $message;
+        $this->code = $code;
+        $this->data = $data;
+    }
+
+}

+ 1856 - 0
addons/epay/library/QRCode.php

@@ -0,0 +1,1856 @@
+<?php
+
+namespace addons\epay\library;
+
+//---------------------------------------------------------------
+// QRCode for PHP5
+//
+// Copyright (c) 2009 Kazuhiko Arase
+//
+// URL: http://www.d-project.com/
+//
+// Licensed under the MIT license:
+//   http://www.opensource.org/licenses/mit-license.php
+//
+// The word "QR Code" is registered trademark of
+// DENSO WAVE INCORPORATED
+//   http://www.denso-wave.com/qrcode/faqpatent-e.html
+//
+//---------------------------------------------------------------------
+
+//---------------------------------------------------------------
+// QRCode
+//---------------------------------------------------------------
+
+define("QR_PAD0", 0xEC);
+define("QR_PAD1", 0x11);
+
+class QRCode
+{
+
+    var $typeNumber;
+
+    var $modules;
+
+    var $moduleCount;
+
+    var $errorCorrectLevel;
+
+    var $qrDataList;
+
+    function __construct()
+    {
+        $this->typeNumber = 1;
+        $this->errorCorrectLevel = QR_ERROR_CORRECT_LEVEL_H;
+        $this->qrDataList = array();
+    }
+
+    function getTypeNumber()
+    {
+        return $this->typeNumber;
+    }
+
+    function setTypeNumber($typeNumber)
+    {
+        $this->typeNumber = $typeNumber;
+    }
+
+    function getErrorCorrectLevel()
+    {
+        return $this->errorCorrectLevel;
+    }
+
+    function setErrorCorrectLevel($errorCorrectLevel)
+    {
+        $this->errorCorrectLevel = $errorCorrectLevel;
+    }
+
+    function addData($data, $mode = 0)
+    {
+
+        if ($mode == 0) {
+            $mode = QRUtil::getMode($data);
+        }
+
+        switch ($mode) {
+
+            case QR_MODE_NUMBER :
+                $this->addDataImpl(new QRNumber($data));
+                break;
+
+            case QR_MODE_ALPHA_NUM :
+                $this->addDataImpl(new QRAlphaNum($data));
+                break;
+
+            case QR_MODE_8BIT_BYTE :
+                $this->addDataImpl(new QR8BitByte($data));
+                break;
+
+            case QR_MODE_KANJI :
+                $this->addDataImpl(new QRKanji($data));
+                break;
+
+            default :
+                trigger_error("mode:$mode", E_USER_ERROR);
+        }
+    }
+
+    function clearData()
+    {
+        $this->qrDataList = array();
+    }
+
+    function addDataImpl($qrData)
+    {
+        $this->qrDataList[] = $qrData;
+    }
+
+    function getDataCount()
+    {
+        return count($this->qrDataList);
+    }
+
+    function getData($index)
+    {
+        return $this->qrDataList[$index];
+    }
+
+    function isDark($row, $col)
+    {
+        if ($this->modules[$row][$col] !== null) {
+            return $this->modules[$row][$col];
+        } else {
+            return false;
+        }
+    }
+
+    function getModuleCount()
+    {
+        return $this->moduleCount;
+    }
+
+    // used for converting fg/bg colors (e.g. #0000ff = 0x0000FF)
+    // added 2015.07.27 ~ DoktorJ
+    function hex2rgb($hex = 0x0)
+    {
+        return array(
+            'r' => floor($hex / 65536),
+            'g' => floor($hex / 256) % 256,
+            'b' => $hex % 256
+        );
+    }
+
+    function make()
+    {
+        $this->makeImpl(false, $this->getBestMaskPattern());
+    }
+
+    function getBestMaskPattern()
+    {
+
+        $minLostPoint = 0;
+        $pattern = 0;
+
+        for ($i = 0; $i < 8; $i++) {
+
+            $this->makeImpl(true, $i);
+
+            $lostPoint = QRUtil::getLostPoint($this);
+
+            if ($i == 0 || $minLostPoint > $lostPoint) {
+                $minLostPoint = $lostPoint;
+                $pattern = $i;
+            }
+        }
+
+        return $pattern;
+    }
+
+    function createNullArray($length)
+    {
+        $nullArray = array();
+        for ($i = 0; $i < $length; $i++) {
+            $nullArray[] = null;
+        }
+        return $nullArray;
+    }
+
+    function makeImpl($test, $maskPattern)
+    {
+
+        $this->moduleCount = $this->typeNumber * 4 + 17;
+
+        $this->modules = array();
+        for ($i = 0; $i < $this->moduleCount; $i++) {
+            $this->modules[] = QRCode::createNullArray($this->moduleCount);
+        }
+
+        $this->setupPositionProbePattern(0, 0);
+        $this->setupPositionProbePattern($this->moduleCount - 7, 0);
+        $this->setupPositionProbePattern(0, $this->moduleCount - 7);
+
+        $this->setupPositionAdjustPattern();
+        $this->setupTimingPattern();
+
+        $this->setupTypeInfo($test, $maskPattern);
+
+        if ($this->typeNumber >= 7) {
+            $this->setupTypeNumber($test);
+        }
+
+        $dataArray = $this->qrDataList;
+
+        $data = QRCode::createData($this->typeNumber, $this->errorCorrectLevel, $dataArray);
+
+        $this->mapData($data, $maskPattern);
+    }
+
+    function mapData(&$data, $maskPattern)
+    {
+
+        $inc = -1;
+        $row = $this->moduleCount - 1;
+        $bitIndex = 7;
+        $byteIndex = 0;
+
+        for ($col = $this->moduleCount - 1; $col > 0; $col -= 2) {
+
+            if ($col == 6) $col--;
+
+            while (true) {
+
+                for ($c = 0; $c < 2; $c++) {
+
+                    if ($this->modules[$row][$col - $c] === null) {
+
+                        $dark = false;
+
+                        if ($byteIndex < count($data)) {
+                            $dark = ((($data[$byteIndex] >> $bitIndex) & 1) == 1);
+                        }
+
+                        if (QRUtil::getMask($maskPattern, $row, $col - $c)) {
+                            $dark = !$dark;
+                        }
+
+                        $this->modules[$row][$col - $c] = $dark;
+                        $bitIndex--;
+
+                        if ($bitIndex == -1) {
+                            $byteIndex++;
+                            $bitIndex = 7;
+                        }
+                    }
+                }
+
+                $row += $inc;
+
+                if ($row < 0 || $this->moduleCount <= $row) {
+                    $row -= $inc;
+                    $inc = -$inc;
+                    break;
+                }
+            }
+        }
+    }
+
+    function setupPositionAdjustPattern()
+    {
+
+        $pos = QRUtil::getPatternPosition($this->typeNumber);
+
+        for ($i = 0; $i < count($pos); $i++) {
+
+            for ($j = 0; $j < count($pos); $j++) {
+
+                $row = $pos[$i];
+                $col = $pos[$j];
+
+                if ($this->modules[$row][$col] !== null) {
+                    continue;
+                }
+
+                for ($r = -2; $r <= 2; $r++) {
+
+                    for ($c = -2; $c <= 2; $c++) {
+                        $this->modules[$row + $r][$col + $c] =
+                            $r == -2 || $r == 2 || $c == -2 || $c == 2 || ($r == 0 && $c == 0);
+                    }
+                }
+            }
+        }
+    }
+
+    function setupPositionProbePattern($row, $col)
+    {
+
+        for ($r = -1; $r <= 7; $r++) {
+
+            for ($c = -1; $c <= 7; $c++) {
+
+                if ($row + $r <= -1 || $this->moduleCount <= $row + $r
+                    || $col + $c <= -1 || $this->moduleCount <= $col + $c) {
+                    continue;
+                }
+
+                $this->modules[$row + $r][$col + $c] =
+                    (0 <= $r && $r <= 6 && ($c == 0 || $c == 6))
+                    || (0 <= $c && $c <= 6 && ($r == 0 || $r == 6))
+                    || (2 <= $r && $r <= 4 && 2 <= $c && $c <= 4);
+            }
+        }
+    }
+
+    function setupTimingPattern()
+    {
+
+        for ($i = 8; $i < $this->moduleCount - 8; $i++) {
+
+            if ($this->modules[$i][6] !== null || $this->modules[6][$i] !== null) {
+                continue;
+            }
+
+            $this->modules[$i][6] = ($i % 2 == 0);
+            $this->modules[6][$i] = ($i % 2 == 0);
+        }
+    }
+
+    function setupTypeNumber($test)
+    {
+
+        $bits = QRUtil::getBCHTypeNumber($this->typeNumber);
+
+        for ($i = 0; $i < 18; $i++) {
+            $mod = (!$test && (($bits >> $i) & 1) == 1);
+            $this->modules[(int)floor($i / 3)][$i % 3 + $this->moduleCount - 8 - 3] = $mod;
+            $this->modules[$i % 3 + $this->moduleCount - 8 - 3][floor($i / 3)] = $mod;
+        }
+    }
+
+    function setupTypeInfo($test, $maskPattern)
+    {
+
+        $data = ($this->errorCorrectLevel << 3) | $maskPattern;
+        $bits = QRUtil::getBCHTypeInfo($data);
+
+        for ($i = 0; $i < 15; $i++) {
+
+            $mod = (!$test && (($bits >> $i) & 1) == 1);
+
+            if ($i < 6) {
+                $this->modules[$i][8] = $mod;
+            } else if ($i < 8) {
+                $this->modules[$i + 1][8] = $mod;
+            } else {
+                $this->modules[$this->moduleCount - 15 + $i][8] = $mod;
+            }
+
+            if ($i < 8) {
+                $this->modules[8][$this->moduleCount - $i - 1] = $mod;
+            } else if ($i < 9) {
+                $this->modules[8][15 - $i - 1 + 1] = $mod;
+            } else {
+                $this->modules[8][15 - $i - 1] = $mod;
+            }
+        }
+
+        $this->modules[$this->moduleCount - 8][8] = !$test;
+    }
+
+    function createData($typeNumber, $errorCorrectLevel, $dataArray)
+    {
+
+        $rsBlocks = QRRSBlock::getRSBlocks($typeNumber, $errorCorrectLevel);
+
+        $buffer = new QRBitBuffer();
+
+        for ($i = 0; $i < count($dataArray); $i++) {
+            /** @var \QRData $data */
+            $data = $dataArray[$i];
+            $buffer->put($data->getMode(), 4);
+            $buffer->put($data->getLength(), $data->getLengthInBits($typeNumber));
+            $data->write($buffer);
+        }
+
+        $totalDataCount = 0;
+        for ($i = 0; $i < count($rsBlocks); $i++) {
+            $totalDataCount += $rsBlocks[$i]->getDataCount();
+        }
+
+        if ($buffer->getLengthInBits() > $totalDataCount * 8) {
+            trigger_error("code length overflow. ("
+                . $buffer->getLengthInBits()
+                . ">"
+                . $totalDataCount * 8
+                . ")", E_USER_ERROR);
+        }
+
+        // end code.
+        if ($buffer->getLengthInBits() + 4 <= $totalDataCount * 8) {
+            $buffer->put(0, 4);
+        }
+
+        // padding
+        while ($buffer->getLengthInBits() % 8 != 0) {
+            $buffer->putBit(false);
+        }
+
+        // padding
+        while (true) {
+
+            if ($buffer->getLengthInBits() >= $totalDataCount * 8) {
+                break;
+            }
+            $buffer->put(QR_PAD0, 8);
+
+            if ($buffer->getLengthInBits() >= $totalDataCount * 8) {
+                break;
+            }
+            $buffer->put(QR_PAD1, 8);
+        }
+
+        return QRCode::createBytes($buffer, $rsBlocks);
+    }
+
+    /**
+     * @param \QRBitBuffer $buffer
+     * @param \QRRSBlock[] $rsBlocks
+     *
+     * @return array
+     */
+    function createBytes(&$buffer, &$rsBlocks)
+    {
+
+        $offset = 0;
+
+        $maxDcCount = 0;
+        $maxEcCount = 0;
+
+        $dcdata = QRCode::createNullArray(count($rsBlocks));
+        $ecdata = QRCode::createNullArray(count($rsBlocks));
+
+        $rsBlockCount = count($rsBlocks);
+        for ($r = 0; $r < $rsBlockCount; $r++) {
+
+            $dcCount = $rsBlocks[$r]->getDataCount();
+            $ecCount = $rsBlocks[$r]->getTotalCount() - $dcCount;
+
+            $maxDcCount = max($maxDcCount, $dcCount);
+            $maxEcCount = max($maxEcCount, $ecCount);
+
+            $dcdata[$r] = QRCode::createNullArray($dcCount);
+            $dcDataCount = count($dcdata[$r]);
+            for ($i = 0; $i < $dcDataCount; $i++) {
+                $bdata = $buffer->getBuffer();
+                $dcdata[$r][$i] = 0xff & $bdata[$i + $offset];
+            }
+            $offset += $dcCount;
+
+            $rsPoly = QRUtil::getErrorCorrectPolynomial($ecCount);
+            $rawPoly = new QRPolynomial($dcdata[$r], $rsPoly->getLength() - 1);
+
+            $modPoly = $rawPoly->mod($rsPoly);
+            $ecdata[$r] = QRCode::createNullArray($rsPoly->getLength() - 1);
+
+            $ecDataCount = count($ecdata[$r]);
+            for ($i = 0; $i < $ecDataCount; $i++) {
+                $modIndex = $i + $modPoly->getLength() - count($ecdata[$r]);
+                $ecdata[$r][$i] = ($modIndex >= 0) ? $modPoly->get($modIndex) : 0;
+            }
+        }
+
+        $totalCodeCount = 0;
+        for ($i = 0; $i < $rsBlockCount; $i++) {
+            $totalCodeCount += $rsBlocks[$i]->getTotalCount();
+        }
+
+        $data = QRCode::createNullArray($totalCodeCount);
+
+        $index = 0;
+
+        for ($i = 0; $i < $maxDcCount; $i++) {
+            for ($r = 0; $r < $rsBlockCount; $r++) {
+                if ($i < count($dcdata[$r])) {
+                    $data[$index++] = $dcdata[$r][$i];
+                }
+            }
+        }
+
+        for ($i = 0; $i < $maxEcCount; $i++) {
+            for ($r = 0; $r < $rsBlockCount; $r++) {
+                if ($i < count($ecdata[$r])) {
+                    $data[$index++] = $ecdata[$r][$i];
+                }
+            }
+        }
+
+        return $data;
+    }
+
+    static function getMinimumQRCode($data, $errorCorrectLevel = QR_ERROR_CORRECT_LEVEL_L)
+    {
+
+        $mode = QRUtil::getMode($data);
+
+        $qr = new QRCode();
+        $qr->setErrorCorrectLevel($errorCorrectLevel);
+        $qr->addData($data, $mode);
+
+        $qrData = $qr->getData(0);
+        $length = $qrData->getLength();
+
+        for ($typeNumber = 1; $typeNumber <= 40; $typeNumber++) {
+            if ($length <= QRUtil::getMaxLength($typeNumber, $mode, $errorCorrectLevel)) {
+                $qr->setTypeNumber($typeNumber);
+                break;
+            }
+        }
+
+        $qr->make();
+
+        return $qr;
+    }
+
+    // added $fg (foreground), $bg (background), and $bgtrans (use transparent bg) parameters
+    // also added some simple error checking on parameters
+    // updated 2015.07.27 ~ DoktorJ
+    function createImage($size = 2, $margin = 2, $fg = 0x000000, $bg = 0xFFFFFF, $bgtrans = false)
+    {
+
+        // size/margin EC
+        if (!is_numeric($size)) $size = 2;
+        if (!is_numeric($margin)) $margin = 2;
+        if ($size < 1) $size = 1;
+        if ($margin < 0) $margin = 0;
+
+        $image_size = $this->getModuleCount() * $size + $margin * 2;
+
+        $image = imagecreatetruecolor($image_size, $image_size);
+
+        // fg/bg EC
+        if ($fg < 0 || $fg > 0xFFFFFF) $fg = 0x0;
+        if ($bg < 0 || $bg > 0xFFFFFF) $bg = 0xFFFFFF;
+
+        // convert hexadecimal RGB to arrays for imagecolorallocate
+        $fgrgb = $this->hex2rgb($fg);
+        $bgrgb = $this->hex2rgb($bg);
+
+        // replace $black and $white with $fgc and $bgc
+        $fgc = imagecolorallocate($image, $fgrgb['r'], $fgrgb['g'], $fgrgb['b']);
+        $bgc = imagecolorallocate($image, $bgrgb['r'], $bgrgb['g'], $bgrgb['b']);
+        if ($bgtrans) imagecolortransparent($image, $bgc);
+
+        // update $white to $bgc
+        imagefilledrectangle($image, 0, 0, $image_size, $image_size, $bgc);
+
+        for ($r = 0; $r < $this->getModuleCount(); $r++) {
+            for ($c = 0; $c < $this->getModuleCount(); $c++) {
+                if ($this->isDark($r, $c)) {
+
+                    // update $black to $fgc
+                    imagefilledrectangle($image,
+                        $margin + $c * $size,
+                        $margin + $r * $size,
+                        $margin + ($c + 1) * $size - 1,
+                        $margin + ($r + 1) * $size - 1,
+                        $fgc);
+                }
+            }
+        }
+
+        return $image;
+    }
+
+    function printHTML($size = "2px")
+    {
+
+        $style = "border-style:none;border-collapse:collapse;margin:0px;padding:0px;";
+
+        print("<table style='$style'>");
+
+        for ($r = 0; $r < $this->getModuleCount(); $r++) {
+
+            print("<tr style='$style'>");
+
+            for ($c = 0; $c < $this->getModuleCount(); $c++) {
+                $color = $this->isDark($r, $c) ? "#000000" : "#ffffff";
+                print("<td style='$style;width:$size;height:$size;background-color:$color'></td>");
+            }
+
+            print("</tr>");
+        }
+
+        print("</table>");
+    }
+}
+
+//---------------------------------------------------------------
+// QRUtil
+//---------------------------------------------------------------
+
+define("QR_G15", (1 << 10) | (1 << 8) | (1 << 5)
+    | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0));
+
+define("QR_G18", (1 << 12) | (1 << 11) | (1 << 10)
+    | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0));
+
+define("QR_G15_MASK", (1 << 14) | (1 << 12) | (1 << 10)
+    | (1 << 4) | (1 << 1));
+
+class QRUtil
+{
+
+    static $QR_MAX_LENGTH = array(
+        array(array(41, 25, 17, 10), array(34, 20, 14, 8), array(27, 16, 11, 7), array(17, 10, 7, 4)),
+        array(array(77, 47, 32, 20), array(63, 38, 26, 16), array(48, 29, 20, 12), array(34, 20, 14, 8)),
+        array(array(127, 77, 53, 32), array(101, 61, 42, 26), array(77, 47, 32, 20), array(58, 35, 24, 15)),
+        array(array(187, 114, 78, 48), array(149, 90, 62, 38), array(111, 67, 46, 28), array(82, 50, 34, 21)),
+        array(array(255, 154, 106, 65), array(202, 122, 84, 52), array(144, 87, 60, 37), array(106, 64, 44, 27)),
+        array(array(322, 195, 134, 82), array(255, 154, 106, 65), array(178, 108, 74, 45), array(139, 84, 58, 36)),
+        array(array(370, 224, 154, 95), array(293, 178, 122, 75), array(207, 125, 86, 53), array(154, 93, 64, 39)),
+        array(array(461, 279, 192, 118), array(365, 221, 152, 93), array(259, 157, 108, 66), array(202, 122, 84, 52)),
+        array(array(552, 335, 230, 141), array(432, 262, 180, 111), array(312, 189, 130, 80), array(235, 143, 98, 60)),
+        array(array(652, 395, 271, 167), array(513, 311, 213, 131), array(364, 221, 151, 93), array(288, 174, 119, 74))
+    );
+
+    static $QR_PATTERN_POSITION_TABLE = array(
+        array(),
+        array(6, 18),
+        array(6, 22),
+        array(6, 26),
+        array(6, 30),
+        array(6, 34),
+        array(6, 22, 38),
+        array(6, 24, 42),
+        array(6, 26, 46),
+        array(6, 28, 50),
+        array(6, 30, 54),
+        array(6, 32, 58),
+        array(6, 34, 62),
+        array(6, 26, 46, 66),
+        array(6, 26, 48, 70),
+        array(6, 26, 50, 74),
+        array(6, 30, 54, 78),
+        array(6, 30, 56, 82),
+        array(6, 30, 58, 86),
+        array(6, 34, 62, 90),
+        array(6, 28, 50, 72, 94),
+        array(6, 26, 50, 74, 98),
+        array(6, 30, 54, 78, 102),
+        array(6, 28, 54, 80, 106),
+        array(6, 32, 58, 84, 110),
+        array(6, 30, 58, 86, 114),
+        array(6, 34, 62, 90, 118),
+        array(6, 26, 50, 74, 98, 122),
+        array(6, 30, 54, 78, 102, 126),
+        array(6, 26, 52, 78, 104, 130),
+        array(6, 30, 56, 82, 108, 134),
+        array(6, 34, 60, 86, 112, 138),
+        array(6, 30, 58, 86, 114, 142),
+        array(6, 34, 62, 90, 118, 146),
+        array(6, 30, 54, 78, 102, 126, 150),
+        array(6, 24, 50, 76, 102, 128, 154),
+        array(6, 28, 54, 80, 106, 132, 158),
+        array(6, 32, 58, 84, 110, 136, 162),
+        array(6, 26, 54, 82, 110, 138, 166),
+        array(6, 30, 58, 86, 114, 142, 170)
+    );
+
+    static function getPatternPosition($typeNumber)
+    {
+        return self::$QR_PATTERN_POSITION_TABLE[$typeNumber - 1];
+    }
+
+    static function getMaxLength($typeNumber, $mode, $errorCorrectLevel)
+    {
+
+        $t = $typeNumber - 1;
+        $e = 0;
+        $m = 0;
+
+        switch ($errorCorrectLevel) {
+            case QR_ERROR_CORRECT_LEVEL_L :
+                $e = 0;
+                break;
+            case QR_ERROR_CORRECT_LEVEL_M :
+                $e = 1;
+                break;
+            case QR_ERROR_CORRECT_LEVEL_Q :
+                $e = 2;
+                break;
+            case QR_ERROR_CORRECT_LEVEL_H :
+                $e = 3;
+                break;
+            default :
+                trigger_error("e:$errorCorrectLevel", E_USER_ERROR);
+        }
+
+        switch ($mode) {
+            case QR_MODE_NUMBER    :
+                $m = 0;
+                break;
+            case QR_MODE_ALPHA_NUM :
+                $m = 1;
+                break;
+            case QR_MODE_8BIT_BYTE :
+                $m = 2;
+                break;
+            case QR_MODE_KANJI     :
+                $m = 3;
+                break;
+            default :
+                trigger_error("m:$mode", E_USER_ERROR);
+        }
+
+        return self::$QR_MAX_LENGTH[$t][$e][$m];
+    }
+
+    static function getErrorCorrectPolynomial($errorCorrectLength)
+    {
+
+        $a = new QRPolynomial(array(1));
+
+        for ($i = 0; $i < $errorCorrectLength; $i++) {
+            $a = $a->multiply(new QRPolynomial(array(1, QRMath::gexp($i))));
+        }
+
+        return $a;
+    }
+
+    static function getMask($maskPattern, $i, $j)
+    {
+
+        switch ($maskPattern) {
+
+            case QR_MASK_PATTERN000 :
+                return ($i + $j) % 2 == 0;
+            case QR_MASK_PATTERN001 :
+                return $i % 2 == 0;
+            case QR_MASK_PATTERN010 :
+                return $j % 3 == 0;
+            case QR_MASK_PATTERN011 :
+                return ($i + $j) % 3 == 0;
+            case QR_MASK_PATTERN100 :
+                return (floor($i / 2) + floor($j / 3)) % 2 == 0;
+            case QR_MASK_PATTERN101 :
+                return ($i * $j) % 2 + ($i * $j) % 3 == 0;
+            case QR_MASK_PATTERN110 :
+                return (($i * $j) % 2 + ($i * $j) % 3) % 2 == 0;
+            case QR_MASK_PATTERN111 :
+                return (($i * $j) % 3 + ($i + $j) % 2) % 2 == 0;
+
+            default :
+                trigger_error("mask:$maskPattern", E_USER_ERROR);
+        }
+    }
+
+    /**
+     * @param \QRCode $qrCode
+     *
+     * @return float|int
+     */
+    static function getLostPoint($qrCode)
+    {
+
+        $moduleCount = $qrCode->getModuleCount();
+
+        $lostPoint = 0;
+
+
+        // LEVEL1
+
+        for ($row = 0; $row < $moduleCount; $row++) {
+
+            for ($col = 0; $col < $moduleCount; $col++) {
+
+                $sameCount = 0;
+                $dark = $qrCode->isDark($row, $col);
+
+                for ($r = -1; $r <= 1; $r++) {
+
+                    if ($row + $r < 0 || $moduleCount <= $row + $r) {
+                        continue;
+                    }
+
+                    for ($c = -1; $c <= 1; $c++) {
+
+                        if (($col + $c < 0 || $moduleCount <= $col + $c) || ($r == 0 && $c == 0)) {
+                            continue;
+                        }
+
+                        if ($dark == $qrCode->isDark($row + $r, $col + $c)) {
+                            $sameCount++;
+                        }
+                    }
+                }
+
+                if ($sameCount > 5) {
+                    $lostPoint += (3 + $sameCount - 5);
+                }
+            }
+        }
+
+        // LEVEL2
+
+        for ($row = 0; $row < $moduleCount - 1; $row++) {
+            for ($col = 0; $col < $moduleCount - 1; $col++) {
+                $count = 0;
+                if ($qrCode->isDark($row, $col)) $count++;
+                if ($qrCode->isDark($row + 1, $col)) $count++;
+                if ($qrCode->isDark($row, $col + 1)) $count++;
+                if ($qrCode->isDark($row + 1, $col + 1)) $count++;
+                if ($count == 0 || $count == 4) {
+                    $lostPoint += 3;
+                }
+            }
+        }
+
+        // LEVEL3
+
+        for ($row = 0; $row < $moduleCount; $row++) {
+            for ($col = 0; $col < $moduleCount - 6; $col++) {
+                if ($qrCode->isDark($row, $col)
+                    && !$qrCode->isDark($row, $col + 1)
+                    && $qrCode->isDark($row, $col + 2)
+                    && $qrCode->isDark($row, $col + 3)
+                    && $qrCode->isDark($row, $col + 4)
+                    && !$qrCode->isDark($row, $col + 5)
+                    && $qrCode->isDark($row, $col + 6)) {
+                    $lostPoint += 40;
+                }
+            }
+        }
+
+        for ($col = 0; $col < $moduleCount; $col++) {
+            for ($row = 0; $row < $moduleCount - 6; $row++) {
+                if ($qrCode->isDark($row, $col)
+                    && !$qrCode->isDark($row + 1, $col)
+                    && $qrCode->isDark($row + 2, $col)
+                    && $qrCode->isDark($row + 3, $col)
+                    && $qrCode->isDark($row + 4, $col)
+                    && !$qrCode->isDark($row + 5, $col)
+                    && $qrCode->isDark($row + 6, $col)) {
+                    $lostPoint += 40;
+                }
+            }
+        }
+
+        // LEVEL4
+
+        $darkCount = 0;
+
+        for ($col = 0; $col < $moduleCount; $col++) {
+            for ($row = 0; $row < $moduleCount; $row++) {
+                if ($qrCode->isDark($row, $col)) {
+                    $darkCount++;
+                }
+            }
+        }
+
+        $ratio = abs(100 * $darkCount / $moduleCount / $moduleCount - 50) / 5;
+        $lostPoint += $ratio * 10;
+
+        return $lostPoint;
+    }
+
+    static function getMode($s)
+    {
+        if (QRUtil::isAlphaNum($s)) {
+            if (QRUtil::isNumber($s)) {
+                return QR_MODE_NUMBER;
+            }
+            return QR_MODE_ALPHA_NUM;
+        } else if (QRUtil::isKanji($s)) {
+            return QR_MODE_KANJI;
+        } else {
+            return QR_MODE_8BIT_BYTE;
+        }
+    }
+
+    static function isNumber($s)
+    {
+        for ($i = 0; $i < strlen($s); $i++) {
+            $c = ord($s[$i]);
+            if (!(QRUtil::toCharCode('0') <= $c && $c <= QRUtil::toCharCode('9'))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    static function isAlphaNum($s)
+    {
+        for ($i = 0; $i < strlen($s); $i++) {
+            $c = ord($s[$i]);
+            if (!(QRUtil::toCharCode('0') <= $c && $c <= QRUtil::toCharCode('9'))
+                && !(QRUtil::toCharCode('A') <= $c && $c <= QRUtil::toCharCode('Z'))
+                && strpos(" $%*+-./:", $s[$i]) === false) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    static function isKanji($s)
+    {
+
+        $data = $s;
+
+        $i = 0;
+
+        while ($i + 1 < strlen($data)) {
+
+            $c = ((0xff & ord($data[$i])) << 8) | (0xff & ord($data[$i + 1]));
+
+            if (!(0x8140 <= $c && $c <= 0x9FFC) && !(0xE040 <= $c && $c <= 0xEBBF)) {
+                return false;
+            }
+
+            $i += 2;
+        }
+
+        if ($i < strlen($data)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    static function toCharCode($s)
+    {
+        return ord($s[0]);
+    }
+
+    static function getBCHTypeInfo($data)
+    {
+        $d = $data << 10;
+        while (QRUtil::getBCHDigit($d) - QRUtil::getBCHDigit(QR_G15) >= 0) {
+            $d ^= (QR_G15 << (QRUtil::getBCHDigit($d) - QRUtil::getBCHDigit(QR_G15)));
+        }
+        return (($data << 10) | $d) ^ QR_G15_MASK;
+    }
+
+    static function getBCHTypeNumber($data)
+    {
+        $d = $data << 12;
+        while (QRUtil::getBCHDigit($d) - QRUtil::getBCHDigit(QR_G18) >= 0) {
+            $d ^= (QR_G18 << (QRUtil::getBCHDigit($d) - QRUtil::getBCHDigit(QR_G18)));
+        }
+        return ($data << 12) | $d;
+    }
+
+    static function getBCHDigit($data)
+    {
+
+        $digit = 0;
+
+        while ($data != 0) {
+            $digit++;
+            $data >>= 1;
+        }
+
+        return $digit;
+    }
+}
+
+//---------------------------------------------------------------
+// QRRSBlock
+//---------------------------------------------------------------
+
+class QRRSBlock
+{
+
+    var $totalCount;
+    var $dataCount;
+
+    static $QR_RS_BLOCK_TABLE = array(
+
+        // L
+        // M
+        // Q
+        // H
+
+        // 1
+        array(1, 26, 19),
+        array(1, 26, 16),
+        array(1, 26, 13),
+        array(1, 26, 9),
+
+        // 2
+        array(1, 44, 34),
+        array(1, 44, 28),
+        array(1, 44, 22),
+        array(1, 44, 16),
+
+        // 3
+        array(1, 70, 55),
+        array(1, 70, 44),
+        array(2, 35, 17),
+        array(2, 35, 13),
+
+        // 4
+        array(1, 100, 80),
+        array(2, 50, 32),
+        array(2, 50, 24),
+        array(4, 25, 9),
+
+        // 5
+        array(1, 134, 108),
+        array(2, 67, 43),
+        array(2, 33, 15, 2, 34, 16),
+        array(2, 33, 11, 2, 34, 12),
+
+        // 6
+        array(2, 86, 68),
+        array(4, 43, 27),
+        array(4, 43, 19),
+        array(4, 43, 15),
+
+        // 7
+        array(2, 98, 78),
+        array(4, 49, 31),
+        array(2, 32, 14, 4, 33, 15),
+        array(4, 39, 13, 1, 40, 14),
+
+        // 8
+        array(2, 121, 97),
+        array(2, 60, 38, 2, 61, 39),
+        array(4, 40, 18, 2, 41, 19),
+        array(4, 40, 14, 2, 41, 15),
+
+        // 9
+        array(2, 146, 116),
+        array(3, 58, 36, 2, 59, 37),
+        array(4, 36, 16, 4, 37, 17),
+        array(4, 36, 12, 4, 37, 13),
+
+        // 10
+        array(2, 86, 68, 2, 87, 69),
+        array(4, 69, 43, 1, 70, 44),
+        array(6, 43, 19, 2, 44, 20),
+        array(6, 43, 15, 2, 44, 16),
+
+        // 11
+        array(4, 101, 81),
+        array(1, 80, 50, 4, 81, 51),
+        array(4, 50, 22, 4, 51, 23),
+        array(3, 36, 12, 8, 37, 13),
+
+        // 12
+        array(2, 116, 92, 2, 117, 93),
+        array(6, 58, 36, 2, 59, 37),
+        array(4, 46, 20, 6, 47, 21),
+        array(7, 42, 14, 4, 43, 15),
+
+        // 13
+        array(4, 133, 107),
+        array(8, 59, 37, 1, 60, 38),
+        array(8, 44, 20, 4, 45, 21),
+        array(12, 33, 11, 4, 34, 12),
+
+        // 14
+        array(3, 145, 115, 1, 146, 116),
+        array(4, 64, 40, 5, 65, 41),
+        array(11, 36, 16, 5, 37, 17),
+        array(11, 36, 12, 5, 37, 13),
+
+        // 15
+        array(5, 109, 87, 1, 110, 88),
+        array(5, 65, 41, 5, 66, 42),
+        array(5, 54, 24, 7, 55, 25),
+        array(11, 36, 12, 7, 37, 13),
+
+        // 16
+        array(5, 122, 98, 1, 123, 99),
+        array(7, 73, 45, 3, 74, 46),
+        array(15, 43, 19, 2, 44, 20),
+        array(3, 45, 15, 13, 46, 16),
+
+        // 17
+        array(1, 135, 107, 5, 136, 108),
+        array(10, 74, 46, 1, 75, 47),
+        array(1, 50, 22, 15, 51, 23),
+        array(2, 42, 14, 17, 43, 15),
+
+        // 18
+        array(5, 150, 120, 1, 151, 121),
+        array(9, 69, 43, 4, 70, 44),
+        array(17, 50, 22, 1, 51, 23),
+        array(2, 42, 14, 19, 43, 15),
+
+        // 19
+        array(3, 141, 113, 4, 142, 114),
+        array(3, 70, 44, 11, 71, 45),
+        array(17, 47, 21, 4, 48, 22),
+        array(9, 39, 13, 16, 40, 14),
+
+        // 20
+        array(3, 135, 107, 5, 136, 108),
+        array(3, 67, 41, 13, 68, 42),
+        array(15, 54, 24, 5, 55, 25),
+        array(15, 43, 15, 10, 44, 16),
+
+        // 21
+        array(4, 144, 116, 4, 145, 117),
+        array(17, 68, 42),
+        array(17, 50, 22, 6, 51, 23),
+        array(19, 46, 16, 6, 47, 17),
+
+        // 22
+        array(2, 139, 111, 7, 140, 112),
+        array(17, 74, 46),
+        array(7, 54, 24, 16, 55, 25),
+        array(34, 37, 13),
+
+        // 23
+        array(4, 151, 121, 5, 152, 122),
+        array(4, 75, 47, 14, 76, 48),
+        array(11, 54, 24, 14, 55, 25),
+        array(16, 45, 15, 14, 46, 16),
+
+        // 24
+        array(6, 147, 117, 4, 148, 118),
+        array(6, 73, 45, 14, 74, 46),
+        array(11, 54, 24, 16, 55, 25),
+        array(30, 46, 16, 2, 47, 17),
+
+        // 25
+        array(8, 132, 106, 4, 133, 107),
+        array(8, 75, 47, 13, 76, 48),
+        array(7, 54, 24, 22, 55, 25),
+        array(22, 45, 15, 13, 46, 16),
+
+        // 26
+        array(10, 142, 114, 2, 143, 115),
+        array(19, 74, 46, 4, 75, 47),
+        array(28, 50, 22, 6, 51, 23),
+        array(33, 46, 16, 4, 47, 17),
+
+        // 27
+        array(8, 152, 122, 4, 153, 123),
+        array(22, 73, 45, 3, 74, 46),
+        array(8, 53, 23, 26, 54, 24),
+        array(12, 45, 15, 28, 46, 16),
+
+        // 28
+        array(3, 147, 117, 10, 148, 118),
+        array(3, 73, 45, 23, 74, 46),
+        array(4, 54, 24, 31, 55, 25),
+        array(11, 45, 15, 31, 46, 16),
+
+        // 29
+        array(7, 146, 116, 7, 147, 117),
+        array(21, 73, 45, 7, 74, 46),
+        array(1, 53, 23, 37, 54, 24),
+        array(19, 45, 15, 26, 46, 16),
+
+        // 30
+        array(5, 145, 115, 10, 146, 116),
+        array(19, 75, 47, 10, 76, 48),
+        array(15, 54, 24, 25, 55, 25),
+        array(23, 45, 15, 25, 46, 16),
+
+        // 31
+        array(13, 145, 115, 3, 146, 116),
+        array(2, 74, 46, 29, 75, 47),
+        array(42, 54, 24, 1, 55, 25),
+        array(23, 45, 15, 28, 46, 16),
+
+        // 32
+        array(17, 145, 115),
+        array(10, 74, 46, 23, 75, 47),
+        array(10, 54, 24, 35, 55, 25),
+        array(19, 45, 15, 35, 46, 16),
+
+        // 33
+        array(17, 145, 115, 1, 146, 116),
+        array(14, 74, 46, 21, 75, 47),
+        array(29, 54, 24, 19, 55, 25),
+        array(11, 45, 15, 46, 46, 16),
+
+        // 34
+        array(13, 145, 115, 6, 146, 116),
+        array(14, 74, 46, 23, 75, 47),
+        array(44, 54, 24, 7, 55, 25),
+        array(59, 46, 16, 1, 47, 17),
+
+        // 35
+        array(12, 151, 121, 7, 152, 122),
+        array(12, 75, 47, 26, 76, 48),
+        array(39, 54, 24, 14, 55, 25),
+        array(22, 45, 15, 41, 46, 16),
+
+        // 36
+        array(6, 151, 121, 14, 152, 122),
+        array(6, 75, 47, 34, 76, 48),
+        array(46, 54, 24, 10, 55, 25),
+        array(2, 45, 15, 64, 46, 16),
+
+        // 37
+        array(17, 152, 122, 4, 153, 123),
+        array(29, 74, 46, 14, 75, 47),
+        array(49, 54, 24, 10, 55, 25),
+        array(24, 45, 15, 46, 46, 16),
+
+        // 38
+        array(4, 152, 122, 18, 153, 123),
+        array(13, 74, 46, 32, 75, 47),
+        array(48, 54, 24, 14, 55, 25),
+        array(42, 45, 15, 32, 46, 16),
+
+        // 39
+        array(20, 147, 117, 4, 148, 118),
+        array(40, 75, 47, 7, 76, 48),
+        array(43, 54, 24, 22, 55, 25),
+        array(10, 45, 15, 67, 46, 16),
+
+        // 40
+        array(19, 148, 118, 6, 149, 119),
+        array(18, 75, 47, 31, 76, 48),
+        array(34, 54, 24, 34, 55, 25),
+        array(20, 45, 15, 61, 46, 16)
+
+    );
+
+    function __construct($totalCount, $dataCount)
+    {
+        $this->totalCount = $totalCount;
+        $this->dataCount = $dataCount;
+    }
+
+    function getDataCount()
+    {
+        return $this->dataCount;
+    }
+
+    function getTotalCount()
+    {
+        return $this->totalCount;
+    }
+
+    static function getRSBlocks($typeNumber, $errorCorrectLevel)
+    {
+
+        $rsBlock = QRRSBlock::getRsBlockTable($typeNumber, $errorCorrectLevel);
+        $length = count($rsBlock) / 3;
+
+        $list = array();
+
+        for ($i = 0; $i < $length; $i++) {
+
+            $count = $rsBlock[$i * 3 + 0];
+            $totalCount = $rsBlock[$i * 3 + 1];
+            $dataCount = $rsBlock[$i * 3 + 2];
+
+            for ($j = 0; $j < $count; $j++) {
+                $list[] = new QRRSBlock($totalCount, $dataCount);
+            }
+        }
+
+        return $list;
+    }
+
+    static function getRsBlockTable($typeNumber, $errorCorrectLevel)
+    {
+
+        switch ($errorCorrectLevel) {
+            case QR_ERROR_CORRECT_LEVEL_L :
+                return self::$QR_RS_BLOCK_TABLE[($typeNumber - 1) * 4 + 0];
+            case QR_ERROR_CORRECT_LEVEL_M :
+                return self::$QR_RS_BLOCK_TABLE[($typeNumber - 1) * 4 + 1];
+            case QR_ERROR_CORRECT_LEVEL_Q :
+                return self::$QR_RS_BLOCK_TABLE[($typeNumber - 1) * 4 + 2];
+            case QR_ERROR_CORRECT_LEVEL_H :
+                return self::$QR_RS_BLOCK_TABLE[($typeNumber - 1) * 4 + 3];
+            default :
+                trigger_error("tn:$typeNumber/ecl:$errorCorrectLevel", E_USER_ERROR);
+        }
+    }
+}
+
+//---------------------------------------------------------------
+// QRNumber
+//---------------------------------------------------------------
+
+class QRNumber extends QRData
+{
+
+    function __construct($data)
+    {
+        parent::__construct(QR_MODE_NUMBER, $data);
+    }
+
+    function write(&$buffer)
+    {
+
+        $data = $this->getData();
+
+        $i = 0;
+
+        while ($i + 2 < strlen($data)) {
+            $num = QRNumber::parseInt(substr($data, $i, 3));
+            $buffer->put($num, 10);
+            $i += 3;
+        }
+
+        if ($i < strlen($data)) {
+
+            if (strlen($data) - $i == 1) {
+                $num = QRNumber::parseInt(substr($data, $i, $i + 1));
+                $buffer->put($num, 4);
+            } else if (strlen($data) - $i == 2) {
+                $num = QRNumber::parseInt(substr($data, $i, $i + 2));
+                $buffer->put($num, 7);
+            }
+        }
+    }
+
+    static function parseInt($s)
+    {
+
+        $num = 0;
+        for ($i = 0; $i < strlen($s); $i++) {
+            $num = $num * 10 + QRNumber::parseIntAt(ord($s[$i]));
+        }
+        return $num;
+    }
+
+    static function parseIntAt($c)
+    {
+
+        if (QRUtil::toCharCode('0') <= $c && $c <= QRUtil::toCharCode('9')) {
+            return $c - QRUtil::toCharCode('0');
+        }
+
+        trigger_error("illegal char : $c", E_USER_ERROR);
+    }
+}
+
+//---------------------------------------------------------------
+// QRKanji
+//---------------------------------------------------------------
+
+class QRKanji extends QRData
+{
+
+    function __construct($data)
+    {
+        parent::__construct(QR_MODE_KANJI, $data);
+    }
+
+    function write(&$buffer)
+    {
+
+        $data = $this->getData();
+
+        $i = 0;
+
+        while ($i + 1 < strlen($data)) {
+
+            $c = ((0xff & ord($data[$i])) << 8) | (0xff & ord($data[$i + 1]));
+
+            if (0x8140 <= $c && $c <= 0x9FFC) {
+                $c -= 0x8140;
+            } else if (0xE040 <= $c && $c <= 0xEBBF) {
+                $c -= 0xC140;
+            } else {
+                trigger_error("illegal char at " . ($i + 1) . "/$c", E_USER_ERROR);
+            }
+
+            $c = (($c >> 8) & 0xff) * 0xC0 + ($c & 0xff);
+
+            $buffer->put($c, 13);
+
+            $i += 2;
+        }
+
+        if ($i < strlen($data)) {
+            trigger_error("illegal char at " . ($i + 1), E_USER_ERROR);
+        }
+    }
+
+    function getLength()
+    {
+        return floor(strlen($this->getData()) / 2);
+    }
+}
+
+//---------------------------------------------------------------
+// QRAlphaNum
+//---------------------------------------------------------------
+
+class QRAlphaNum extends QRData
+{
+
+    function __construct($data)
+    {
+        parent::__construct(QR_MODE_ALPHA_NUM, $data);
+    }
+
+    function write(&$buffer)
+    {
+
+        $i = 0;
+        $c = $this->getData();
+
+        while ($i + 1 < strlen($c)) {
+            $buffer->put(QRAlphaNum::getCode(ord($c[$i])) * 45
+                + QRAlphaNum::getCode(ord($c[$i + 1])), 11);
+            $i += 2;
+        }
+
+        if ($i < strlen($c)) {
+            $buffer->put(QRAlphaNum::getCode(ord($c[$i])), 6);
+        }
+    }
+
+    static function getCode($c)
+    {
+
+        if (QRUtil::toCharCode('0') <= $c
+            && $c <= QRUtil::toCharCode('9')) {
+            return $c - QRUtil::toCharCode('0');
+        } else if (QRUtil::toCharCode('A') <= $c
+            && $c <= QRUtil::toCharCode('Z')) {
+            return $c - QRUtil::toCharCode('A') + 10;
+        } else {
+            switch ($c) {
+                case QRUtil::toCharCode(' ') :
+                    return 36;
+                case QRUtil::toCharCode('$') :
+                    return 37;
+                case QRUtil::toCharCode('%') :
+                    return 38;
+                case QRUtil::toCharCode('*') :
+                    return 39;
+                case QRUtil::toCharCode('+') :
+                    return 40;
+                case QRUtil::toCharCode('-') :
+                    return 41;
+                case QRUtil::toCharCode('.') :
+                    return 42;
+                case QRUtil::toCharCode('/') :
+                    return 43;
+                case QRUtil::toCharCode(':') :
+                    return 44;
+                default :
+                    trigger_error("illegal char : $c", E_USER_ERROR);
+            }
+        }
+
+    }
+}
+
+//---------------------------------------------------------------
+// QR8BitByte
+//---------------------------------------------------------------
+
+class QR8BitByte extends QRData
+{
+
+    function __construct($data)
+    {
+        parent::__construct(QR_MODE_8BIT_BYTE, $data);
+    }
+
+    function write(&$buffer)
+    {
+
+        $data = $this->getData();
+        for ($i = 0; $i < strlen($data); $i++) {
+            $buffer->put(ord($data[$i]), 8);
+        }
+    }
+
+}
+
+//---------------------------------------------------------------
+// QRData
+//---------------------------------------------------------------
+
+abstract class QRData
+{
+
+    var $mode;
+
+    var $data;
+
+    function __construct($mode, $data)
+    {
+        $this->mode = $mode;
+        $this->data = $data;
+    }
+
+    function getMode()
+    {
+        return $this->mode;
+    }
+
+    function getData()
+    {
+        return $this->data;
+    }
+
+    /**
+     * @return int
+     */
+    function getLength()
+    {
+        return strlen($this->getData());
+    }
+
+    /**
+     * @param \QRBitBuffer $buffer
+     */
+    abstract function write(&$buffer);
+
+    function getLengthInBits($type)
+    {
+
+        if (1 <= $type && $type < 10) {
+
+            // 1 - 9
+
+            switch ($this->mode) {
+                case QR_MODE_NUMBER     :
+                    return 10;
+                case QR_MODE_ALPHA_NUM     :
+                    return 9;
+                case QR_MODE_8BIT_BYTE    :
+                    return 8;
+                case QR_MODE_KANJI      :
+                    return 8;
+                default :
+                    trigger_error("mode:$this->mode", E_USER_ERROR);
+            }
+
+        } else if ($type < 27) {
+
+            // 10 - 26
+
+            switch ($this->mode) {
+                case QR_MODE_NUMBER     :
+                    return 12;
+                case QR_MODE_ALPHA_NUM     :
+                    return 11;
+                case QR_MODE_8BIT_BYTE    :
+                    return 16;
+                case QR_MODE_KANJI      :
+                    return 10;
+                default :
+                    trigger_error("mode:$this->mode", E_USER_ERROR);
+            }
+
+        } else if ($type < 41) {
+
+            // 27 - 40
+
+            switch ($this->mode) {
+                case QR_MODE_NUMBER     :
+                    return 14;
+                case QR_MODE_ALPHA_NUM    :
+                    return 13;
+                case QR_MODE_8BIT_BYTE    :
+                    return 16;
+                case QR_MODE_KANJI      :
+                    return 12;
+                default :
+                    trigger_error("mode:$this->mode", E_USER_ERROR);
+            }
+
+        } else {
+            trigger_error("mode:$this->mode", E_USER_ERROR);
+        }
+    }
+
+}
+
+//---------------------------------------------------------------
+// QRMath
+//---------------------------------------------------------------
+
+class QRMath
+{
+
+    static $QR_MATH_EXP_TABLE = null;
+    static $QR_MATH_LOG_TABLE = null;
+
+    static function init()
+    {
+
+        self::$QR_MATH_EXP_TABLE = QRMath::createNumArray(256);
+
+        for ($i = 0; $i < 8; $i++) {
+            self::$QR_MATH_EXP_TABLE[$i] = 1 << $i;
+        }
+
+        for ($i = 8; $i < 256; $i++) {
+            self::$QR_MATH_EXP_TABLE[$i] = self::$QR_MATH_EXP_TABLE[$i - 4]
+                ^ self::$QR_MATH_EXP_TABLE[$i - 5]
+                ^ self::$QR_MATH_EXP_TABLE[$i - 6]
+                ^ self::$QR_MATH_EXP_TABLE[$i - 8];
+        }
+
+        self::$QR_MATH_LOG_TABLE = QRMath::createNumArray(256);
+
+        for ($i = 0; $i < 255; $i++) {
+            self::$QR_MATH_LOG_TABLE[self::$QR_MATH_EXP_TABLE[$i]] = $i;
+        }
+    }
+
+    static function createNumArray($length)
+    {
+        $num_array = array();
+        for ($i = 0; $i < $length; $i++) {
+            $num_array[] = 0;
+        }
+        return $num_array;
+    }
+
+    static function glog($n)
+    {
+
+        if ($n < 1) {
+            trigger_error("log($n)", E_USER_ERROR);
+        }
+
+        return self::$QR_MATH_LOG_TABLE[$n];
+    }
+
+    static function gexp($n)
+    {
+
+        while ($n < 0) {
+            $n += 255;
+        }
+
+        while ($n >= 256) {
+            $n -= 255;
+        }
+
+        return self::$QR_MATH_EXP_TABLE[$n];
+    }
+}
+
+// init static table
+QRMath::init();
+
+//---------------------------------------------------------------
+// QRPolynomial
+//---------------------------------------------------------------
+
+class QRPolynomial
+{
+
+    var $num;
+
+    function __construct($num, $shift = 0)
+    {
+
+        $offset = 0;
+
+        while ($offset < count($num) && $num[$offset] == 0) {
+            $offset++;
+        }
+
+        $this->num = QRMath::createNumArray(count($num) - $offset + $shift);
+        for ($i = 0; $i < count($num) - $offset; $i++) {
+            $this->num[$i] = $num[$i + $offset];
+        }
+    }
+
+    function get($index)
+    {
+        return $this->num[$index];
+    }
+
+    function getLength()
+    {
+        return count($this->num);
+    }
+
+    // PHP5
+    function __toString()
+    {
+        return $this->toString();
+    }
+
+    function toString()
+    {
+
+        $buffer = "";
+
+        for ($i = 0; $i < $this->getLength(); $i++) {
+            if ($i > 0) {
+                $buffer .= ",";
+            }
+            $buffer .= $this->get($i);
+        }
+
+        return $buffer;
+    }
+
+    function toLogString()
+    {
+
+        $buffer = "";
+
+        for ($i = 0; $i < $this->getLength(); $i++) {
+            if ($i > 0) {
+                $buffer .= ",";
+            }
+            $buffer .= QRMath::glog($this->get($i));
+        }
+
+        return $buffer;
+    }
+
+    /**
+     * @param \QRPolynomial $e
+     *
+     * @return \QRPolynomial
+     */
+    function multiply($e)
+    {
+
+        $num = QRMath::createNumArray($this->getLength() + $e->getLength() - 1);
+
+        for ($i = 0; $i < $this->getLength(); $i++) {
+            $vi = QRMath::glog($this->get($i));
+
+            for ($j = 0; $j < $e->getLength(); $j++) {
+                $num[$i + $j] ^= QRMath::gexp($vi + QRMath::glog($e->get($j)));
+            }
+        }
+
+        return new QRPolynomial($num);
+    }
+
+    /**
+     * @param \QRPolynomial $e
+     *
+     * @return $this|\QRPolynomial
+     */
+    function mod($e)
+    {
+
+        if ($this->getLength() - $e->getLength() < 0) {
+            return $this;
+        }
+
+        $ratio = QRMath::glog($this->get(0)) - QRMath::glog($e->get(0));
+
+        $num = QRMath::createNumArray($this->getLength());
+        for ($i = 0; $i < $this->getLength(); $i++) {
+            $num[$i] = $this->get($i);
+        }
+
+        for ($i = 0; $i < $e->getLength(); $i++) {
+            $num[$i] ^= QRMath::gexp(QRMath::glog($e->get($i)) + $ratio);
+        }
+
+        $newPolynomial = new QRPolynomial($num);
+        return $newPolynomial->mod($e);
+    }
+}
+
+//---------------------------------------------------------------
+// Mode
+//---------------------------------------------------------------
+
+define("QR_MODE_NUMBER", 1 << 0);
+define("QR_MODE_ALPHA_NUM", 1 << 1);
+define("QR_MODE_8BIT_BYTE", 1 << 2);
+define("QR_MODE_KANJI", 1 << 3);
+
+//---------------------------------------------------------------
+// MaskPattern
+//---------------------------------------------------------------
+
+define("QR_MASK_PATTERN000", 0);
+define("QR_MASK_PATTERN001", 1);
+define("QR_MASK_PATTERN010", 2);
+define("QR_MASK_PATTERN011", 3);
+define("QR_MASK_PATTERN100", 4);
+define("QR_MASK_PATTERN101", 5);
+define("QR_MASK_PATTERN110", 6);
+define("QR_MASK_PATTERN111", 7);
+
+//---------------------------------------------------------------
+// ErrorCorrectLevel
+
+// 7%.
+define("QR_ERROR_CORRECT_LEVEL_L", 1);
+// 15%.
+define("QR_ERROR_CORRECT_LEVEL_M", 0);
+// 25%.
+define("QR_ERROR_CORRECT_LEVEL_Q", 3);
+// 30%.
+define("QR_ERROR_CORRECT_LEVEL_H", 2);
+
+
+//---------------------------------------------------------------
+// QRBitBuffer
+//---------------------------------------------------------------
+
+class QRBitBuffer
+{
+
+    var $buffer;
+    var $length;
+
+    function __construct()
+    {
+        $this->buffer = array();
+        $this->length = 0;
+    }
+
+    function getBuffer()
+    {
+        return $this->buffer;
+    }
+
+    function getLengthInBits()
+    {
+        return $this->length;
+    }
+
+    function __toString()
+    {
+        $buffer = "";
+        for ($i = 0; $i < $this->getLengthInBits(); $i++) {
+            $buffer .= $this->get($i) ? '1' : '0';
+        }
+        return $buffer;
+    }
+
+    function get($index)
+    {
+        $bufIndex = (int)floor($index / 8);
+        return (($this->buffer[$bufIndex] >> (7 - $index % 8)) & 1) == 1;
+    }
+
+    function put($num, $length)
+    {
+
+        for ($i = 0; $i < $length; $i++) {
+            $this->putBit((($num >> ($length - $i - 1)) & 1) == 1);
+        }
+    }
+
+    function putBit($bit)
+    {
+
+        $bufIndex = (int)floor($this->length / 8);
+        if (count($this->buffer) <= $bufIndex) {
+            $this->buffer[] = 0;
+        }
+
+        if ($bit) {
+            $this->buffer[$bufIndex] |= (0x80 >> ($this->length % 8));
+        }
+
+        $this->length++;
+    }
+}
+

+ 58 - 0
addons/epay/library/RedirectResponse.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace addons\epay\library;
+
+class RedirectResponse extends \Symfony\Component\HttpFoundation\RedirectResponse implements \JsonSerializable, \Serializable
+{
+    public function __toString()
+    {
+        return $this->getContent();
+    }
+
+    public function setTargetUrl($url)
+    {
+        if ('' === ($url ?? '')) {
+            throw new \InvalidArgumentException('无法跳转到空页面');
+        }
+
+        $this->targetUrl = $url;
+
+        $this->setContent(
+            sprintf('<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="UTF-8" />
+        <meta http-equiv="refresh" content="0;url=\'%1$s\'" />
+
+        <title>正在跳转支付 %1$s</title>
+    </head>
+    <body>
+        <div id="redirect" style="display:none;">正在跳转支付 <a href="%1$s">%1$s</a></div>
+        <script type="text/javascript">
+            setTimeout(function(){
+                document.getElementById("redirect").style.display = "block";
+            }, 1000);
+        </script>
+    </body>
+</html>', htmlspecialchars($url, \ENT_QUOTES, 'UTF-8')));
+
+        $this->headers->set('Location', $url);
+
+        return $this;
+    }
+
+    public function jsonSerialize()
+    {
+        return $this->getContent();
+    }
+
+    public function serialize()
+    {
+        return serialize($this->content);
+    }
+
+    public function unserialize($serialized)
+    {
+        return $this->content = unserialize($serialized);
+    }
+}

+ 26 - 0
addons/epay/library/Response.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace addons\epay\library;
+
+class Response extends \Symfony\Component\HttpFoundation\Response implements \JsonSerializable, \Serializable
+{
+    public function __toString()
+    {
+        return $this->getContent();
+    }
+
+    public function jsonSerialize()
+    {
+        return $this->getContent();
+    }
+
+    public function serialize()
+    {
+        return serialize($this->content);
+    }
+
+    public function unserialize($serialized)
+    {
+        return $this->content = unserialize($serialized);
+    }
+}

+ 310 - 0
addons/epay/library/Service.php

@@ -0,0 +1,310 @@
+<?php
+
+namespace addons\epay\library;
+
+use addons\third\model\Third;
+use app\common\library\Auth;
+use Exception;
+use think\Session;
+use Yansongda\Pay\Pay;
+use Yansongda\Supports\Str;
+
+/**
+ * 订单服务类
+ *
+ * @package addons\epay\library
+ */
+class Service
+{
+
+    /**
+     * 提交订单
+     * @param array|float $amount    订单金额
+     * @param string      $orderid   订单号
+     * @param string      $type      支付类型,可选alipay或wechat
+     * @param string      $title     订单标题
+     * @param string      $notifyurl 通知回调URL
+     * @param string      $returnurl 跳转返回URL
+     * @param string      $method    支付方法
+     * @return Response|RedirectResponse|Collection
+     * @throws Exception
+     */
+    public static function submitOrder($amount, $orderid = null, $type = null, $title = null, $notifyurl = null, $returnurl = null, $method = null, $openid = '')
+    {
+        if (!is_array($amount)) {
+            $params = [
+                'amount'    => $amount,
+                'orderid'   => $orderid,
+                'type'      => $type,
+                'title'     => $title,
+                'notifyurl' => $notifyurl,
+                'returnurl' => $returnurl,
+                'method'    => $method,
+                'openid'    => $openid,
+            ];
+        } else {
+            $params = $amount;
+        }
+        $type = isset($params['type']) && in_array($params['type'], ['alipay', 'wechat']) ? $params['type'] : 'wechat';
+        $method = isset($params['method']) ? $params['method'] : 'web';
+        $orderid = isset($params['orderid']) ? $params['orderid'] : date("YmdHis") . mt_rand(100000, 999999);
+        $amount = isset($params['amount']) ? $params['amount'] : 1;
+        $title = isset($params['title']) ? $params['title'] : "支付";
+        $auth_code = isset($params['auth_code']) ? $params['auth_code'] : '';
+        $openid = isset($params['openid']) ? $params['openid'] : '';
+
+        $request = request();
+        $notifyurl = isset($params['notifyurl']) ? $params['notifyurl'] : $request->root(true) . '/addons/epay/index/' . $type . 'notify';
+        $returnurl = isset($params['returnurl']) ? $params['returnurl'] : $request->root(true) . '/addons/epay/index/' . $type . 'return/out_trade_no/' . $orderid;
+        $html = '';
+        $config = Service::getConfig($type);
+        $config['notify_url'] = $notifyurl;
+        $config['return_url'] = $returnurl;
+        $isWechat = strpos($request->server('HTTP_USER_AGENT'), 'MicroMessenger') !== false;
+
+        $result = null;
+        if ($type == 'alipay') {
+            //如果是PC支付,判断当前环境,进行跳转
+            if ($method == 'web') {
+                //如果是微信环境或后台配置PC使用扫码支付
+                if ($isWechat || $config['scanpay']) {
+                    Session::set("alipayorderdata", $params);
+                    $url = addon_url('epay/api/alipay', [], true, true);
+                    return RedirectResponse::create($url);
+                } elseif ($request->isMobile()) {
+                    $method = 'wap';
+                }
+            }
+            //创建支付对象
+            $pay = Pay::alipay($config);
+            $params = [
+                'out_trade_no' => $orderid,//你的订单号
+                'total_amount' => $amount,//单位元
+                'subject'      => $title,
+            ];
+
+            switch ($method) {
+                case 'web':
+                    //电脑支付
+                    $result = $pay->web($params);
+                    break;
+                case 'wap':
+                    //手机网页支付
+                    $result = $pay->wap($params);
+                    break;
+                case 'app':
+                    //APP支付
+                    $result = $pay->app($params);
+                    break;
+                case 'scan':
+                    //扫码支付
+                    $result = $pay->scan($params);
+                    break;
+                case 'pos':
+                    //刷卡支付必须要有auth_code
+                    $params['auth_code'] = $auth_code;
+                    $result = $pay->pos($params);
+                    break;
+                case 'mini':
+                case 'miniapp':
+                    //小程序支付
+                    //小程序支付,直接返回字符串
+                    //小程序支付必须要有buyer_id,这里使用openid
+                    $params['buyer_id'] = $openid;
+                    $result = $pay->mini($params);
+                    break;
+                default:
+            }
+        } else {
+            //如果是PC支付,判断当前环境,进行跳转
+            if ($method == 'web') {
+                //如果是移动端,但不是微信环境
+                if ($request->isMobile() && !$isWechat) {
+                    $method = 'wap';
+                } else {
+                    Session::set("wechatorderdata", $params);
+                    $url = addon_url('epay/api/wechat', [], true, true);
+                    return RedirectResponse::create($url);
+                }
+            }
+
+            //创建支付对象
+            $pay = Pay::wechat($config);
+            $params = [
+                'out_trade_no' => $orderid,//你的订单号
+                'body'         => $title,
+                'total_fee'    => $amount * 100, //单位分
+            ];
+            switch ($method) {
+                //case 'web':
+                //    //电脑支付,跳转到自定义展示页面
+                //    $result = $pay->web($params);
+                //    break;
+                case 'mp':
+                    //公众号支付
+                    //公众号支付必须有openid
+                    $params['openid'] = $openid;
+                    $result = $pay->mp($params);
+                    break;
+                case 'wap':
+                    //手机网页支付,跳转
+                    $params['spbill_create_ip'] = $request->ip(0, false);
+                    $result = $pay->wap($params);
+                    break;
+                case 'app':
+                    //APP支付,直接返回字符串
+                    $result = $pay->app($params);
+                    break;
+                case 'scan':
+                    //扫码支付,直接返回字符串
+                    $result = $pay->scan($params);
+                    break;
+                case 'pos':
+                    //刷卡支付,直接返回字符串
+                    //刷卡支付必须要有auth_code
+                    $params['auth_code'] = $auth_code;
+                    $result = $pay->pos($params);
+                    break;
+                case 'mini':
+                case 'miniapp':
+                    //小程序支付,直接返回字符串
+                    //小程序支付必须要有openid
+                    $params['openid'] = $openid;
+                    $result = $pay->miniapp($params);
+                    break;
+                default:
+            }
+        }
+
+        //使用重写的Response类、RedirectResponse、Collection类
+        if ($result instanceof \Symfony\Component\HttpFoundation\RedirectResponse) {
+            $result = RedirectResponse::create($result->getTargetUrl());
+        } elseif ($result instanceof \Symfony\Component\HttpFoundation\Response) {
+            $result = Response::create($result->getContent());
+        } elseif ($result instanceof \Yansongda\Supports\Collection) {
+            $result = Collection::make($result->all());
+        }
+
+        return $result;
+    }
+
+    /**
+     * 验证回调是否成功
+     * @param string $type   支付类型
+     * @param array  $config 配置信息
+     * @return bool|\Yansongda\Pay\Gateways\Alipay|\Yansongda\Pay\Gateways\Wechat
+     */
+    public static function checkNotify($type, $config = [])
+    {
+        $type = strtolower($type);
+        if (!in_array($type, ['wechat', 'alipay'])) {
+            return false;
+        }
+        try {
+            $config = self::getConfig($type);
+            $pay = $type == 'wechat' ? Pay::wechat($config) : Pay::alipay($config);
+            $data = $pay->verify();
+
+            if ($type == 'alipay') {
+                if (in_array($data['trade_status'], ['TRADE_SUCCESS', 'TRADE_FINISHED'])) {
+                    return $pay;
+                }
+            } else {
+                return $pay;
+            }
+        } catch (Exception $e) {
+            return false;
+        }
+
+        return false;
+    }
+
+    /**
+     * 验证返回是否成功,请勿用于判断是否支付成功的逻辑验证
+     * 已弃用
+     *
+     * @param string $type   支付类型
+     * @param array  $config 配置信息
+     * @return bool
+     * @deprecated  已弃用,请勿用于逻辑验证
+     */
+    public static function checkReturn($type, $config = [])
+    {
+        //由于PC及移动端无法获取请求的参数信息,取消return验证,均返回true
+        return true;
+    }
+
+    /**
+     * 获取配置
+     * @param string $type 支付类型
+     * @return array|mixed
+     */
+    public static function getConfig($type = 'wechat')
+    {
+        $config = get_addon_config('epay');
+        $config = isset($config[$type]) ? $config[$type] : $config['wechat'];
+        if ($config['log']) {
+            $config['log'] = [
+                'file'  => LOG_PATH . 'epaylogs' . DS . $type . '-' . date("Y-m-d") . '.log',
+                'level' => 'debug'
+            ];
+        }
+        if (isset($config['cert_client']) && substr($config['cert_client'], 0, 8) == '/addons/') {
+            $config['cert_client'] = ROOT_PATH . str_replace('/', DS, substr($config['cert_client'], 1));
+        }
+        if (isset($config['cert_key']) && substr($config['cert_key'], 0, 8) == '/addons/') {
+            $config['cert_key'] = ROOT_PATH . str_replace('/', DS, substr($config['cert_key'], 1));
+        }
+        if (isset($config['app_cert_public_key']) && substr($config['app_cert_public_key'], 0, 8) == '/addons/') {
+            $config['app_cert_public_key'] = ROOT_PATH . str_replace('/', DS, substr($config['app_cert_public_key'], 1));
+        }
+        if (isset($config['alipay_root_cert']) && substr($config['alipay_root_cert'], 0, 8) == '/addons/') {
+            $config['alipay_root_cert'] = ROOT_PATH . str_replace('/', DS, substr($config['alipay_root_cert'], 1));
+        }
+        if (isset($config['ali_public_key']) && (Str::endsWith($config['ali_public_key'], '.crt') || Str::endsWith($config['ali_public_key'], '.pem'))) {
+            $config['ali_public_key'] = ROOT_PATH . str_replace('/', DS, substr($config['ali_public_key'], 1));
+        }
+        // 可选
+        $config['http'] = [
+            'timeout'         => 10,
+            'connect_timeout' => 10,
+            // 更多配置项请参考 [Guzzle](https://guzzle-cn.readthedocs.io/zh_CN/latest/request-options.html)
+        ];
+
+        $config['notify_url'] = empty($config['notify_url']) ? addon_url('epay/api/notifyx', [], false) . '/type/' . $type : $config['notify_url'];
+        $config['notify_url'] = !preg_match("/^(http:\/\/|https:\/\/)/i", $config['notify_url']) ? request()->root(true) . $config['notify_url'] : $config['notify_url'];
+        $config['return_url'] = empty($config['return_url']) ? addon_url('epay/api/returnx', [], false) . '/type/' . $type : $config['return_url'];
+        $config['return_url'] = !preg_match("/^(http:\/\/|https:\/\/)/i", $config['return_url']) ? request()->root(true) . $config['return_url'] : $config['return_url'];
+        return $config;
+    }
+
+    /**
+     * 获取微信Openid
+     *
+     * @return mixed|string
+     */
+    public static function getOpenid()
+    {
+        $config = self::getConfig('wechat');
+        $openid = '';
+        $auth = Auth::instance();
+        if ($auth->isLogin()) {
+            $third = get_addon_info('third');
+            if ($third && $third['state']) {
+                $thirdInfo = Third::where('user_id', $auth->id)->where('platform', 'wechat')->where('apptype', 'mp')->find();
+                $openid = $thirdInfo ? $thirdInfo['openid'] : '';
+            }
+        }
+        if (!$openid) {
+            $openid = Session::get("openid");
+
+            //如果未传openid,则去读取openid
+            if (!$openid) {
+                $wechat = new Wechat($config['app_id'], $config['app_secret']);
+                $openid = $wechat->getOpenid();
+            }
+        }
+        return $openid;
+    }
+
+}

+ 110 - 0
addons/epay/library/Wechat.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace addons\epay\library;
+
+use fast\Http;
+use think\Cache;
+use think\Session;
+
+/**
+ * 微信授权
+ *
+ */
+class Wechat
+{
+    private $app_id = '';
+    private $app_secret = '';
+    private $scope = 'snsapi_userinfo';
+
+    public function __construct($app_id, $app_secret)
+    {
+        $this->app_id = $app_id;
+        $this->app_secret = $app_secret;
+    }
+
+    /**
+     * 获取微信授权链接
+     *
+     * @return string
+     */
+    public function getAuthorizeUrl()
+    {
+        $redirect_uri = addon_url('epay/api/wechat', [], true, true);
+        $redirect_uri = urlencode($redirect_uri);
+        $state = \fast\Random::alnum();
+        Session::set('state', $state);
+        return "https://open.weixin.qq.com/connect/oauth2/authorize?appid={$this->app_id}&redirect_uri={$redirect_uri}&response_type=code&scope={$this->scope}&state={$state}#wechat_redirect";
+    }
+
+    /**
+     * 获取微信openid
+     *
+     * @return mixed|string
+     */
+    public function getOpenid()
+    {
+        $openid = Session::get('openid');
+        if (!$openid) {
+            if (!isset($_GET['code'])) {
+                $url = $this->getAuthorizeUrl();
+
+                Header("Location: $url");
+                exit();
+            } else {
+                $state = Session::get('state');
+                if ($state == $_GET['state']) {
+                    $code = $_GET['code'];
+                    $token = $this->getAccessToken($code);
+                    if (!isset($token['openid']) && isset($token['errmsg'])) {
+                        exception($token['errmsg']);
+                    }
+                    $openid = isset($token['openid']) ? $token['openid'] : '';
+                    if ($openid) {
+                        Session::set("openid", $openid);
+                    }
+                }
+            }
+        }
+        return $openid;
+    }
+
+    /**
+     * 获取授权token网页授权
+     *
+     * @param string $code
+     * @return mixed|string
+     */
+    public function getAccessToken($code = '')
+    {
+        $params = [
+            'appid'      => $this->app_id,
+            'secret'     => $this->app_secret,
+            'code'       => $code,
+            'grant_type' => 'authorization_code'
+        ];
+        $ret = Http::sendRequest('https://api.weixin.qq.com/sns/oauth2/access_token', $params, 'GET');
+        if ($ret['ret']) {
+            $ar = json_decode($ret['msg'], true);
+            return $ar;
+        }
+        return [];
+    }
+
+    public function getJsticket($code = '')
+    {
+        $jsticket = Session::get('jsticket');
+        if (!$jsticket) {
+            $token = $this->getAccessToken($code);
+            $params = [
+                'access_token' => 'token',
+                'type'         => 'jsapi',
+            ];
+            $ret = Http::sendRequest('https://api.weixin.qq.com/cgi-bin/ticket/getticket', $params, 'GET');
+            if ($ret['ret']) {
+                $ar = json_decode($ret['msg'], true);
+                return $ar;
+            }
+        }
+        return $jsticket;
+    }
+}

+ 83 - 0
addons/epay/library/Yansongda/Pay/Contracts/GatewayApplicationInterface.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Yansongda\Pay\Contracts;
+
+use Symfony\Component\HttpFoundation\Response;
+use Yansongda\Supports\Collection;
+
+interface GatewayApplicationInterface
+{
+    /**
+     * To pay.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string $gateway
+     * @param array  $params
+     *
+     * @return Collection|Response
+     */
+    public function pay($gateway, $params);
+
+    /**
+     * Query an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $order
+     *
+     * @return Collection
+     */
+    public function find($order, string $type);
+
+    /**
+     * Refund an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return Collection
+     */
+    public function refund(array $order);
+
+    /**
+     * Cancel an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $order
+     *
+     * @return Collection
+     */
+    public function cancel($order);
+
+    /**
+     * Close an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $order
+     *
+     * @return Collection
+     */
+    public function close($order);
+
+    /**
+     * Verify a request.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array|null $content
+     *
+     * @return Collection
+     */
+    public function verify($content, bool $refund);
+
+    /**
+     * Echo success to server.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return Response
+     */
+    public function success();
+}

+ 20 - 0
addons/epay/library/Yansongda/Pay/Contracts/GatewayInterface.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Yansongda\Pay\Contracts;
+
+use Symfony\Component\HttpFoundation\Response;
+use Yansongda\Supports\Collection;
+
+interface GatewayInterface
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @return Collection|Response
+     */
+    public function pay($endpoint, array $payload);
+}

+ 98 - 0
addons/epay/library/Yansongda/Pay/Events.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Yansongda\Pay;
+
+use Exception;
+use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * @author yansongda <me@yansongda.cn>
+ *
+ * @method static Event dispatch(Event $event)                                Dispatches an event to all registered listeners
+ * @method static array getListeners($eventName = null)                       Gets the listeners of a specific event or all listeners sorted by descending priority.
+ * @method static int|void getListenerPriority($eventName, $listener)         Gets the listener priority for a specific event.
+ * @method static bool hasListeners($eventName = null)                        Checks whether an event has any registered listeners.
+ * @method static void addListener($eventName, $listener, $priority = 0)      Adds an event listener that listens on the specified events.
+ * @method static removeListener($eventName, $listener)                       Removes an event listener from the specified events.
+ * @method static void addSubscriber(EventSubscriberInterface $subscriber)    Adds an event subscriber.
+ * @method static void removeSubscriber(EventSubscriberInterface $subscriber)
+ */
+class Events
+{
+    /**
+     * dispatcher.
+     *
+     * @var EventDispatcher
+     */
+    protected static $dispatcher;
+
+    /**
+     * Forward call.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $args
+     *
+     * @throws Exception
+     *
+     * @return mixed
+     */
+    public static function __callStatic($method, $args)
+    {
+        return call_user_func_array([self::getDispatcher(), $method], $args);
+    }
+
+    /**
+     * Forward call.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $args
+     *
+     * @throws Exception
+     *
+     * @return mixed
+     */
+    public function __call($method, $args)
+    {
+        return call_user_func_array([self::getDispatcher(), $method], $args);
+    }
+
+    /**
+     * setDispatcher.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public static function setDispatcher(EventDispatcher $dispatcher)
+    {
+        self::$dispatcher = $dispatcher;
+    }
+
+    /**
+     * getDispatcher.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public static function getDispatcher(): EventDispatcher
+    {
+        if (self::$dispatcher) {
+            return self::$dispatcher;
+        }
+
+        return self::$dispatcher = self::createDispatcher();
+    }
+
+    /**
+     * createDispatcher.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public static function createDispatcher(): EventDispatcher
+    {
+        return new EventDispatcher();
+    }
+}

+ 31 - 0
addons/epay/library/Yansongda/Pay/Events/ApiRequested.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+class ApiRequested extends Event
+{
+    /**
+     * Endpoint.
+     *
+     * @var string
+     */
+    public $endpoint;
+
+    /**
+     * Result.
+     *
+     * @var array
+     */
+    public $result;
+
+    /**
+     * Bootstrap.
+     */
+    public function __construct(string $driver, string $gateway, string $endpoint, array $result)
+    {
+        $this->endpoint = $endpoint;
+        $this->result = $result;
+
+        parent::__construct($driver, $gateway);
+    }
+}

+ 31 - 0
addons/epay/library/Yansongda/Pay/Events/ApiRequesting.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+class ApiRequesting extends Event
+{
+    /**
+     * Endpoint.
+     *
+     * @var string
+     */
+    public $endpoint;
+
+    /**
+     * Payload.
+     *
+     * @var array
+     */
+    public $payload;
+
+    /**
+     * Bootstrap.
+     */
+    public function __construct(string $driver, string $gateway, string $endpoint, array $payload)
+    {
+        $this->endpoint = $endpoint;
+        $this->payload = $payload;
+
+        parent::__construct($driver, $gateway);
+    }
+}

+ 40 - 0
addons/epay/library/Yansongda/Pay/Events/Event.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+use Symfony\Contracts\EventDispatcher\Event as SymfonyEvent;
+
+class Event extends SymfonyEvent
+{
+    /**
+     * Driver.
+     *
+     * @var string
+     */
+    public $driver;
+
+    /**
+     * Method.
+     *
+     * @var string
+     */
+    public $gateway;
+
+    /**
+     * Extra attributes.
+     *
+     * @var mixed
+     */
+    public $attributes;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function __construct(string $driver, string $gateway)
+    {
+        $this->driver = $driver;
+        $this->gateway = $gateway;
+    }
+}

+ 33 - 0
addons/epay/library/Yansongda/Pay/Events/MethodCalled.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+class MethodCalled extends Event
+{
+    /**
+     * endpoint.
+     *
+     * @var string
+     */
+    public $endpoint;
+
+    /**
+     * payload.
+     *
+     * @var array
+     */
+    public $payload;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function __construct(string $driver, string $gateway, string $endpoint, array $payload = [])
+    {
+        $this->endpoint = $endpoint;
+        $this->payload = $payload;
+
+        parent::__construct($driver, $gateway);
+    }
+}

+ 31 - 0
addons/epay/library/Yansongda/Pay/Events/PayStarted.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+class PayStarted extends Event
+{
+    /**
+     * Endpoint.
+     *
+     * @var string
+     */
+    public $endpoint;
+
+    /**
+     * Payload.
+     *
+     * @var array
+     */
+    public $payload;
+
+    /**
+     * Bootstrap.
+     */
+    public function __construct(string $driver, string $gateway, string $endpoint, array $payload)
+    {
+        $this->endpoint = $endpoint;
+        $this->payload = $payload;
+
+        parent::__construct($driver, $gateway);
+    }
+}

+ 23 - 0
addons/epay/library/Yansongda/Pay/Events/PayStarting.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+class PayStarting extends Event
+{
+    /**
+     * Params.
+     *
+     * @var array
+     */
+    public $params;
+
+    /**
+     * Bootstrap.
+     */
+    public function __construct(string $driver, string $gateway, array $params)
+    {
+        $this->params = $params;
+
+        parent::__construct($driver, $gateway);
+    }
+}

+ 25 - 0
addons/epay/library/Yansongda/Pay/Events/RequestReceived.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+class RequestReceived extends Event
+{
+    /**
+     * Received data.
+     *
+     * @var array
+     */
+    public $data;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function __construct(string $driver, string $gateway, array $data)
+    {
+        $this->data = $data;
+
+        parent::__construct($driver, $gateway);
+    }
+}

+ 25 - 0
addons/epay/library/Yansongda/Pay/Events/SignFailed.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Yansongda\Pay\Events;
+
+class SignFailed extends Event
+{
+    /**
+     * Received data.
+     *
+     * @var array
+     */
+    public $data;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function __construct(string $driver, string $gateway, array $data)
+    {
+        $this->data = $data;
+
+        parent::__construct($driver, $gateway);
+    }
+}

+ 19 - 0
addons/epay/library/Yansongda/Pay/Exceptions/BusinessException.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Yansongda\Pay\Exceptions;
+
+class BusinessException extends GatewayException
+{
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string       $message
+     * @param array|string $raw
+     */
+    public function __construct($message, $raw = [])
+    {
+        parent::__construct('ERROR_BUSINESS: '.$message, $raw, self::ERROR_BUSINESS);
+    }
+}

+ 44 - 0
addons/epay/library/Yansongda/Pay/Exceptions/Exception.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Yansongda\Pay\Exceptions;
+
+class Exception extends \Exception
+{
+    const UNKNOWN_ERROR = 9999;
+
+    const INVALID_GATEWAY = 1;
+
+    const INVALID_CONFIG = 2;
+
+    const INVALID_ARGUMENT = 3;
+
+    const ERROR_GATEWAY = 4;
+
+    const INVALID_SIGN = 5;
+
+    const ERROR_BUSINESS = 6;
+
+    /**
+     * Raw error info.
+     *
+     * @var array
+     */
+    public $raw;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string       $message
+     * @param array|string $raw
+     * @param int|string   $code
+     */
+    public function __construct($message = '', $raw = [], $code = self::UNKNOWN_ERROR)
+    {
+        $message = '' === $message ? 'Unknown Error' : $message;
+        $this->raw = is_array($raw) ? $raw : [$raw];
+
+        parent::__construct($message, intval($code));
+    }
+}

+ 20 - 0
addons/epay/library/Yansongda/Pay/Exceptions/GatewayException.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Yansongda\Pay\Exceptions;
+
+class GatewayException extends Exception
+{
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string       $message
+     * @param array|string $raw
+     * @param int          $code
+     */
+    public function __construct($message, $raw = [], $code = self::ERROR_GATEWAY)
+    {
+        parent::__construct('ERROR_GATEWAY: '.$message, $raw, $code);
+    }
+}

+ 19 - 0
addons/epay/library/Yansongda/Pay/Exceptions/InvalidArgumentException.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Yansongda\Pay\Exceptions;
+
+class InvalidArgumentException extends Exception
+{
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string       $message
+     * @param array|string $raw
+     */
+    public function __construct($message, $raw = [])
+    {
+        parent::__construct('INVALID_ARGUMENT: '.$message, $raw, self::INVALID_ARGUMENT);
+    }
+}

+ 19 - 0
addons/epay/library/Yansongda/Pay/Exceptions/InvalidConfigException.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Yansongda\Pay\Exceptions;
+
+class InvalidConfigException extends Exception
+{
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string       $message
+     * @param array|string $raw
+     */
+    public function __construct($message, $raw = [])
+    {
+        parent::__construct('INVALID_CONFIG: '.$message, $raw, self::INVALID_CONFIG);
+    }
+}

+ 19 - 0
addons/epay/library/Yansongda/Pay/Exceptions/InvalidGatewayException.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Yansongda\Pay\Exceptions;
+
+class InvalidGatewayException extends Exception
+{
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string       $message
+     * @param array|string $raw
+     */
+    public function __construct($message, $raw = [])
+    {
+        parent::__construct('INVALID_GATEWAY: '.$message, $raw, self::INVALID_GATEWAY);
+    }
+}

+ 19 - 0
addons/epay/library/Yansongda/Pay/Exceptions/InvalidSignException.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Yansongda\Pay\Exceptions;
+
+class InvalidSignException extends Exception
+{
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string       $message
+     * @param array|string $raw
+     */
+    public function __construct($message, $raw = [])
+    {
+        parent::__construct('INVALID_SIGN: '.$message, $raw, self::INVALID_SIGN);
+    }
+}

+ 422 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay.php

@@ -0,0 +1,422 @@
+<?php
+
+namespace Yansongda\Pay\Gateways;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Yansongda\Pay\Contracts\GatewayApplicationInterface;
+use Yansongda\Pay\Contracts\GatewayInterface;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Exceptions\InvalidGatewayException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Alipay\Support;
+use Yansongda\Supports\Collection;
+use Yansongda\Supports\Config;
+use Yansongda\Supports\Str;
+
+/**
+ * @method Response   app(array $config)      APP 支付
+ * @method Collection pos(array $config)      刷卡支付
+ * @method Collection scan(array $config)     扫码支付
+ * @method Collection transfer(array $config) 帐户转账
+ * @method Response   wap(array $config)      手机网站支付
+ * @method Response   web(array $config)      电脑支付
+ * @method Collection mini(array $config)     小程序支付
+ */
+class Alipay implements GatewayApplicationInterface
+{
+    /**
+     * Const mode_normal.
+     */
+    const MODE_NORMAL = 'normal';
+
+    /**
+     * Const mode_dev.
+     */
+    const MODE_DEV = 'dev';
+
+    /**
+     * Const mode_service.
+     */
+    const MODE_SERVICE = 'service';
+
+    /**
+     * Const url.
+     */
+    const URL = [
+        self::MODE_NORMAL => 'https://openapi.alipay.com/gateway.do?charset=utf-8',
+        self::MODE_DEV => 'https://openapi.alipaydev.com/gateway.do?charset=utf-8',
+    ];
+
+    /**
+     * Alipay payload.
+     *
+     * @var array
+     */
+    protected $payload;
+
+    /**
+     * Alipay gateway.
+     *
+     * @var string
+     */
+    protected $gateway;
+
+    /**
+     * extends.
+     *
+     * @var array
+     */
+    protected $extends;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws \Exception
+     */
+    public function __construct(Config $config)
+    {
+        $this->gateway = Support::create($config)->getBaseUri();
+        $this->payload = [
+            'app_id' => $config->get('app_id'),
+            'method' => '',
+            'format' => 'JSON',
+            'charset' => 'utf-8',
+            'sign_type' => 'RSA2',
+            'version' => '1.0',
+            'return_url' => $config->get('return_url'),
+            'notify_url' => $config->get('notify_url'),
+            'timestamp' => date('Y-m-d H:i:s'),
+            'sign' => '',
+            'biz_content' => '',
+            'app_auth_token' => $config->get('app_auth_token'),
+        ];
+
+        if ($config->get('app_cert_public_key') && $config->get('alipay_root_cert')) {
+            $this->payload['app_cert_sn'] = Support::getCertSN($config->get('app_cert_public_key'));
+            $this->payload['alipay_root_cert_sn'] = Support::getRootCertSN($config->get('alipay_root_cert'));
+        }
+    }
+
+    /**
+     * Magic pay.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $params
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidConfigException
+     * @throws InvalidGatewayException
+     * @throws InvalidSignException
+     *
+     * @return Response|Collection
+     */
+    public function __call($method, $params)
+    {
+        if (isset($this->extends[$method])) {
+            return $this->makeExtend($method, ...$params);
+        }
+
+        return $this->pay($method, ...$params);
+    }
+
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $gateway
+     * @param array  $params
+     *
+     * @throws InvalidGatewayException
+     *
+     * @return Response|Collection
+     */
+    public function pay($gateway, $params = [])
+    {
+        Events::dispatch(new Events\PayStarting('Alipay', $gateway, $params));
+
+        $this->payload['return_url'] = $params['return_url'] ?? $this->payload['return_url'];
+        $this->payload['notify_url'] = $params['notify_url'] ?? $this->payload['notify_url'];
+
+        unset($params['return_url'], $params['notify_url']);
+
+        $this->payload['biz_content'] = json_encode($params);
+
+        $gateway = get_class($this).'\\'.Str::studly($gateway).'Gateway';
+
+        if (class_exists($gateway)) {
+            return $this->makePay($gateway);
+        }
+
+        throw new InvalidGatewayException("Pay Gateway [{$gateway}] not exists");
+    }
+
+    /**
+     * Verify sign.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array|null $data
+     *
+     * @throws InvalidSignException
+     * @throws InvalidConfigException
+     */
+    public function verify($data = null, bool $refund = false): Collection
+    {
+        if (is_null($data)) {
+            $request = Request::createFromGlobals();
+
+            $data = $request->request->count() > 0 ? $request->request->all() : $request->query->all();
+        }
+
+        if (isset($data['fund_bill_list'])) {
+            $data['fund_bill_list'] = htmlspecialchars_decode($data['fund_bill_list']);
+        }
+
+        Events::dispatch(new Events\RequestReceived('Alipay', '', $data));
+
+        if (Support::verifySign($data)) {
+            return new Collection($data);
+        }
+
+        Events::dispatch(new Events\SignFailed('Alipay', '', $data));
+
+        throw new InvalidSignException('Alipay Sign Verify FAILED', $data);
+    }
+
+    /**
+     * Query an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $order
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function find($order, string $type = 'wap'): Collection
+    {
+        $gateway = get_class($this).'\\'.Str::studly($type).'Gateway';
+
+        if (!class_exists($gateway) || !is_callable([new $gateway(), 'find'])) {
+            throw new GatewayException("{$gateway} Done Not Exist Or Done Not Has FIND Method");
+        }
+
+        $config = call_user_func([new $gateway(), 'find'], $order);
+
+        $this->payload['method'] = $config['method'];
+        $this->payload['biz_content'] = $config['biz_content'];
+        $this->payload['sign'] = Support::generateSign($this->payload);
+
+        Events::dispatch(new Events\MethodCalled('Alipay', 'Find', $this->gateway, $this->payload));
+
+        return Support::requestApi($this->payload);
+    }
+
+    /**
+     * Refund an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function refund(array $order): Collection
+    {
+        $this->payload['method'] = 'alipay.trade.refund';
+        $this->payload['biz_content'] = json_encode($order);
+        $this->payload['sign'] = Support::generateSign($this->payload);
+
+        Events::dispatch(new Events\MethodCalled('Alipay', 'Refund', $this->gateway, $this->payload));
+
+        return Support::requestApi($this->payload);
+    }
+
+    /**
+     * Cancel an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array|string $order
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function cancel($order): Collection
+    {
+        $this->payload['method'] = 'alipay.trade.cancel';
+        $this->payload['biz_content'] = json_encode(is_array($order) ? $order : ['out_trade_no' => $order]);
+        $this->payload['sign'] = Support::generateSign($this->payload);
+
+        Events::dispatch(new Events\MethodCalled('Alipay', 'Cancel', $this->gateway, $this->payload));
+
+        return Support::requestApi($this->payload);
+    }
+
+    /**
+     * Close an order.
+     *
+     * @param string|array $order
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function close($order): Collection
+    {
+        $this->payload['method'] = 'alipay.trade.close';
+        $this->payload['biz_content'] = json_encode(is_array($order) ? $order : ['out_trade_no' => $order]);
+        $this->payload['sign'] = Support::generateSign($this->payload);
+
+        Events::dispatch(new Events\MethodCalled('Alipay', 'Close', $this->gateway, $this->payload));
+
+        return Support::requestApi($this->payload);
+    }
+
+    /**
+     * Download bill.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $bill
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function download($bill): string
+    {
+        $this->payload['method'] = 'alipay.data.dataservice.bill.downloadurl.query';
+        $this->payload['biz_content'] = json_encode(is_array($bill) ? $bill : ['bill_type' => 'trade', 'bill_date' => $bill]);
+        $this->payload['sign'] = Support::generateSign($this->payload);
+
+        Events::dispatch(new Events\MethodCalled('Alipay', 'Download', $this->gateway, $this->payload));
+
+        $result = Support::requestApi($this->payload);
+
+        return ($result instanceof Collection) ? $result->get('bill_download_url') : '';
+    }
+
+    /**
+     * Reply success to alipay.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function success(): Response
+    {
+        Events::dispatch(new Events\MethodCalled('Alipay', 'Success', $this->gateway));
+
+        return new Response('success');
+    }
+
+    /**
+     * extend.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     * @throws InvalidArgumentException
+     */
+    public function extend(string $method, callable $function, bool $now = true): ?Collection
+    {
+        if (!$now && !method_exists($this, $method)) {
+            $this->extends[$method] = $function;
+
+            return null;
+        }
+
+        $customize = $function($this->payload);
+
+        if (!is_array($customize) && !($customize instanceof Collection)) {
+            throw new InvalidArgumentException('Return Type Must Be Array Or Collection');
+        }
+
+        Events::dispatch(new Events\MethodCalled('Alipay', 'extend', $this->gateway, $customize));
+
+        if (is_array($customize)) {
+            $this->payload = $customize;
+            $this->payload['sign'] = Support::generateSign($this->payload);
+
+            return Support::requestApi($this->payload);
+        }
+
+        return $customize;
+    }
+
+    /**
+     * Make pay gateway.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidGatewayException
+     *
+     * @return Response|Collection
+     */
+    protected function makePay(string $gateway)
+    {
+        $app = new $gateway();
+
+        if ($app instanceof GatewayInterface) {
+            return $app->pay($this->gateway, array_filter($this->payload, function ($value) {
+                return '' !== $value && !is_null($value);
+            }));
+        }
+
+        throw new InvalidGatewayException("Pay Gateway [{$gateway}] Must Be An Instance Of GatewayInterface");
+    }
+
+    /**
+     * makeExtend.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    protected function makeExtend(string $method, array ...$params): Collection
+    {
+        $params = count($params) >= 1 ? $params[0] : $params;
+
+        $function = $this->extends[$method];
+
+        $customize = $function($this->payload, $params);
+
+        if (!is_array($customize) && !($customize instanceof Collection)) {
+            throw new InvalidArgumentException('Return Type Must Be Array Or Collection');
+        }
+
+        Events::dispatch(new Events\MethodCalled(
+            'Alipay',
+            'extend - '.$method,
+            $this->gateway,
+            is_array($customize) ? $customize : $customize->toArray()
+        ));
+
+        if (is_array($customize)) {
+            $this->payload = $customize;
+            $this->payload['sign'] = Support::generateSign($this->payload);
+
+            return Support::requestApi($this->payload);
+        }
+
+        return $customize;
+    }
+}

+ 38 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/AppGateway.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Symfony\Component\HttpFoundation\Response;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Gateways\Alipay;
+
+class AppGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws InvalidConfigException
+     * @throws InvalidArgumentException
+     */
+    public function pay($endpoint, array $payload): Response
+    {
+        $payload['method'] = 'alipay.trade.app.pay';
+
+        $biz_array = json_decode($payload['biz_content'], true);
+        if ((Alipay::MODE_SERVICE === $this->mode) && (!empty(Support::getInstance()->pid))) {
+            $biz_array['extend_params'] = is_array($biz_array['extend_params']) ? array_merge(['sys_service_provider_id' => Support::getInstance()->pid], $biz_array['extend_params']) : ['sys_service_provider_id' => Support::getInstance()->pid];
+        }
+        $payload['biz_content'] = json_encode(array_merge($biz_array, ['product_code' => 'QUICK_MSECURITY_PAY']));
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Alipay', 'App', $endpoint, $payload));
+
+        return new Response(http_build_query($payload));
+    }
+}

+ 40 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/Gateway.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Yansongda\Pay\Contracts\GatewayInterface;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Supports\Collection;
+
+abstract class Gateway implements GatewayInterface
+{
+    /**
+     * Mode.
+     *
+     * @var string
+     */
+    protected $mode;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidArgumentException
+     */
+    public function __construct()
+    {
+        $this->mode = Support::getInstance()->mode;
+    }
+
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @return Collection
+     */
+    abstract public function pay($endpoint, array $payload);
+}

+ 46 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/MiniGateway.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Alipay;
+use Yansongda\Supports\Collection;
+
+class MiniGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author xiaozan <i@xiaozan.me>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     *
+     * @see https://docs.alipay.com/mini/introduce/pay
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $biz_array = json_decode($payload['biz_content'], true);
+        if (empty($biz_array['buyer_id'])) {
+            throw new InvalidArgumentException('buyer_id required');
+        }
+        if ((Alipay::MODE_SERVICE === $this->mode) && (!empty(Support::getInstance()->pid))) {
+            $biz_array['extend_params'] = is_array($biz_array['extend_params']) ? array_merge(['sys_service_provider_id' => Support::getInstance()->pid], $biz_array['extend_params']) : ['sys_service_provider_id' => Support::getInstance()->pid];
+        }
+        $payload['biz_content'] = json_encode($biz_array);
+        $payload['method'] = 'alipay.trade.create';
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Alipay', 'Mini', $endpoint, $payload));
+
+        return Support::requestApi($payload);
+    }
+}

+ 47 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/PosGateway.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Alipay;
+use Yansongda\Supports\Collection;
+
+class PosGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws InvalidArgumentException
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['method'] = 'alipay.trade.pay';
+        $biz_array = json_decode($payload['biz_content'], true);
+        if ((Alipay::MODE_SERVICE === $this->mode) && (!empty(Support::getInstance()->pid))) {
+            $biz_array['extend_params'] = is_array($biz_array['extend_params']) ? array_merge(['sys_service_provider_id' => Support::getInstance()->pid], $biz_array['extend_params']) : ['sys_service_provider_id' => Support::getInstance()->pid];
+        }
+        $payload['biz_content'] = json_encode(array_merge(
+            $biz_array,
+            [
+                'product_code' => 'FACE_TO_FACE_PAYMENT',
+                'scene' => 'bar_code',
+            ]
+        ));
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Alipay', 'Pos', $endpoint, $payload));
+
+        return Support::requestApi($payload);
+    }
+}

+ 21 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/RefundGateway.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+class RefundGateway
+{
+    /**
+     * Find.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $order
+     */
+    public function find($order): array
+    {
+        return [
+            'method' => 'alipay.trade.fastpay.refund.query',
+            'biz_content' => json_encode(is_array($order) ? $order : ['out_trade_no' => $order]),
+        ];
+    }
+}

+ 41 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/ScanGateway.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Alipay;
+use Yansongda\Supports\Collection;
+
+class ScanGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['method'] = 'alipay.trade.precreate';
+        $biz_array = json_decode($payload['biz_content'], true);
+        if ((Alipay::MODE_SERVICE === $this->mode) && (!empty(Support::getInstance()->pid))) {
+            $biz_array['extend_params'] = is_array($biz_array['extend_params']) ? array_merge(['sys_service_provider_id' => Support::getInstance()->pid], $biz_array['extend_params']) : ['sys_service_provider_id' => Support::getInstance()->pid];
+        }
+        $payload['biz_content'] = json_encode(array_merge($biz_array, ['product_code' => '']));
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Alipay', 'Scan', $endpoint, $payload));
+
+        return Support::requestApi($payload);
+    }
+}

+ 452 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/Support.php

@@ -0,0 +1,452 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Exception;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Alipay;
+use Yansongda\Pay\Log;
+use Yansongda\Supports\Arr;
+use Yansongda\Supports\Collection;
+use Yansongda\Supports\Config;
+use Yansongda\Supports\Str;
+use Yansongda\Supports\Traits\HasHttpRequest;
+
+/**
+ * @author yansongda <me@yansongda.cn>
+ *
+ * @property string app_id alipay app_id
+ * @property string ali_public_key
+ * @property string private_key
+ * @property array http http options
+ * @property string mode current mode
+ * @property array log log options
+ * @property string pid ali pid
+ */
+class Support
+{
+    use HasHttpRequest;
+
+    /**
+     * Alipay gateway.
+     *
+     * @var string
+     */
+    protected $baseUri;
+
+    /**
+     * Config.
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * Instance.
+     *
+     * @var Support
+     */
+    private static $instance;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    private function __construct(Config $config)
+    {
+        $this->baseUri = Alipay::URL[$config->get('mode', Alipay::MODE_NORMAL)];
+        $this->config = $config;
+
+        $this->setHttpOptions();
+    }
+
+    /**
+     * __get.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $key
+     *
+     * @return mixed|Config|null
+     */
+    public function __get($key)
+    {
+        return $this->getConfig($key);
+    }
+
+    /**
+     * create.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return Support
+     */
+    public static function create(Config $config)
+    {
+        if ('cli' === php_sapi_name() || !(self::$instance instanceof self)) {
+            self::$instance = new self($config);
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * getInstance.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidArgumentException
+     *
+     * @return Support
+     */
+    public static function getInstance()
+    {
+        if (is_null(self::$instance)) {
+            throw new InvalidArgumentException('You Should [Create] First Before Using');
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * clear.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function clear()
+    {
+        self::$instance = null;
+    }
+
+    /**
+     * Get Alipay API result.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public static function requestApi(array $data): Collection
+    {
+        Events::dispatch(new Events\ApiRequesting('Alipay', '', self::$instance->getBaseUri(), $data));
+
+        $data = array_filter($data, function ($value) {
+            return ('' == $value || is_null($value)) ? false : true;
+        });
+
+        $result = json_decode(self::$instance->post('', $data), true);
+
+        Events::dispatch(new Events\ApiRequested('Alipay', '', self::$instance->getBaseUri(), $result));
+
+        return self::processingApiResult($data, $result);
+    }
+
+    /**
+     * Generate sign.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidConfigException
+     */
+    public static function generateSign(array $params): string
+    {
+        $privateKey = self::$instance->private_key;
+
+        if (is_null($privateKey)) {
+            throw new InvalidConfigException('Missing Alipay Config -- [private_key]');
+        }
+
+        if (Str::endsWith($privateKey, '.pem')) {
+            $privateKey = openssl_pkey_get_private(
+                Str::startsWith($privateKey, 'file://') ? $privateKey : 'file://'.$privateKey
+            );
+        } else {
+            $privateKey = "-----BEGIN RSA PRIVATE KEY-----\n".
+                wordwrap($privateKey, 64, "\n", true).
+                "\n-----END RSA PRIVATE KEY-----";
+        }
+
+        openssl_sign(self::getSignContent($params), $sign, $privateKey, OPENSSL_ALGO_SHA256);
+
+        $sign = base64_encode($sign);
+
+        Log::debug('Alipay Generate Sign', [$params, $sign]);
+
+        if (is_resource($privateKey)) {
+            openssl_free_key($privateKey);
+        }
+
+        return $sign;
+    }
+
+    /**
+     * Verify sign.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param bool        $sync
+     * @param string|null $sign
+     *
+     * @throws InvalidConfigException
+     */
+    public static function verifySign(array $data, $sync = false, $sign = null): bool
+    {
+        $publicKey = self::$instance->ali_public_key;
+
+        if (is_null($publicKey)) {
+            throw new InvalidConfigException('Missing Alipay Config -- [ali_public_key]');
+        }
+
+        if (Str::endsWith($publicKey, '.crt')) {
+            $publicKey = file_get_contents($publicKey);
+        } elseif (Str::endsWith($publicKey, '.pem')) {
+            $publicKey = openssl_pkey_get_public(
+                Str::startsWith($publicKey, 'file://') ? $publicKey : 'file://'.$publicKey
+            );
+        } else {
+            $publicKey = "-----BEGIN PUBLIC KEY-----\n".
+                wordwrap($publicKey, 64, "\n", true).
+                "\n-----END PUBLIC KEY-----";
+        }
+
+        $sign = $sign ?? $data['sign'];
+
+        $toVerify = $sync ? json_encode($data, JSON_UNESCAPED_UNICODE) : self::getSignContent($data, true);
+
+        $isVerify = 1 === openssl_verify($toVerify, base64_decode($sign), $publicKey, OPENSSL_ALGO_SHA256);
+
+        if (is_resource($publicKey)) {
+            openssl_free_key($publicKey);
+        }
+
+        return $isVerify;
+    }
+
+    /**
+     * Get signContent that is to be signed.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param bool $verify
+     */
+    public static function getSignContent(array $data, $verify = false): string
+    {
+        ksort($data);
+
+        $stringToBeSigned = '';
+        foreach ($data as $k => $v) {
+            if ($verify && 'sign' != $k && 'sign_type' != $k) {
+                $stringToBeSigned .= $k.'='.$v.'&';
+            }
+            if (!$verify && '' !== $v && !is_null($v) && 'sign' != $k && '@' != substr($v, 0, 1)) {
+                $stringToBeSigned .= $k.'='.$v.'&';
+            }
+        }
+
+        Log::debug('Alipay Generate Sign Content Before Trim', [$data, $stringToBeSigned]);
+
+        return trim($stringToBeSigned, '&');
+    }
+
+    /**
+     * Convert encoding.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string|array $data
+     * @param string       $to
+     * @param string       $from
+     */
+    public static function encoding($data, $to = 'utf-8', $from = 'gb2312'): array
+    {
+        return Arr::encoding((array) $data, $to, $from);
+    }
+
+    /**
+     * Get service config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|null $key
+     * @param mixed|null  $default
+     *
+     * @return mixed|null
+     */
+    public function getConfig($key = null, $default = null)
+    {
+        if (is_null($key)) {
+            return $this->config->all();
+        }
+
+        if ($this->config->has($key)) {
+            return $this->config[$key];
+        }
+
+        return $default;
+    }
+
+    /**
+     * Get Base Uri.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return string
+     */
+    public function getBaseUri()
+    {
+        return $this->baseUri;
+    }
+
+    /**
+     * 生成应用证书SN.
+     *
+     * @author 大冰 https://sbing.vip/archives/2019-new-alipay-php-docking.html
+     *
+     * @param $certPath
+     *
+     * @throws /Exception
+     */
+    public static function getCertSN($certPath): string
+    {
+        if (!is_file($certPath)) {
+            throw new Exception('unknown certPath -- [getCertSN]');
+        }
+        $x509data = file_get_contents($certPath);
+        if (false === $x509data) {
+            throw new Exception('Alipay CertSN Error -- [getCertSN]');
+        }
+        openssl_x509_read($x509data);
+        $certdata = openssl_x509_parse($x509data);
+        if (empty($certdata)) {
+            throw new Exception('Alipay openssl_x509_parse Error -- [getCertSN]');
+        }
+        $issuer_arr = [];
+        foreach ($certdata['issuer'] as $key => $val) {
+            $issuer_arr[] = $key.'='.$val;
+        }
+        $issuer = implode(',', array_reverse($issuer_arr));
+        Log::debug('getCertSN:', [$certPath, $issuer, $certdata['serialNumber']]);
+
+        return md5($issuer.$certdata['serialNumber']);
+    }
+
+    /**
+     * 生成支付宝根证书SN.
+     *
+     * @author 大冰 https://sbing.vip/archives/2019-new-alipay-php-docking.html
+     *
+     * @param $certPath
+     *
+     * @return string
+     *
+     * @throws /Exception
+     */
+    public static function getRootCertSN($certPath)
+    {
+        if (!is_file($certPath)) {
+            throw new Exception('unknown certPath -- [getRootCertSN]');
+        }
+        $x509data = file_get_contents($certPath);
+        if (false === $x509data) {
+            throw new Exception('Alipay CertSN Error -- [getRootCertSN]');
+        }
+        $kCertificateEnd = '-----END CERTIFICATE-----';
+        $certStrList = explode($kCertificateEnd, $x509data);
+        $md5_arr = [];
+        foreach ($certStrList as $one) {
+            if (!empty(trim($one))) {
+                $_x509data = $one.$kCertificateEnd;
+                openssl_x509_read($_x509data);
+                $_certdata = openssl_x509_parse($_x509data);
+                if (in_array($_certdata['signatureTypeSN'], ['RSA-SHA256', 'RSA-SHA1'])) {
+                    $issuer_arr = [];
+                    foreach ($_certdata['issuer'] as $key => $val) {
+                        $issuer_arr[] = $key.'='.$val;
+                    }
+                    $_issuer = implode(',', array_reverse($issuer_arr));
+                    if (0 === strpos($_certdata['serialNumber'], '0x')) {
+                        $serialNumber = self::bchexdec($_certdata['serialNumber']);
+                    } else {
+                        $serialNumber = $_certdata['serialNumber'];
+                    }
+                    $md5_arr[] = md5($_issuer.$serialNumber);
+                    Log::debug('getRootCertSN Sub:', [$certPath, $_issuer, $serialNumber]);
+                }
+            }
+        }
+
+        return implode('_', $md5_arr);
+    }
+
+    /**
+     * processingApiResult.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $data
+     * @param $result
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    protected static function processingApiResult($data, $result): Collection
+    {
+        $method = str_replace('.', '_', $data['method']).'_response';
+
+        if (!isset($result['sign']) || '10000' != $result[$method]['code']) {
+            throw new GatewayException('Get Alipay API Error:'.$result[$method]['msg'].(isset($result[$method]['sub_code']) ? (' - '.$result[$method]['sub_code']) : ''), $result);
+        }
+
+        if (self::verifySign($result[$method], true, $result['sign'])) {
+            return new Collection($result[$method]);
+        }
+
+        Events::dispatch(new Events\SignFailed('Alipay', '', $result));
+
+        throw new InvalidSignException('Alipay Sign Verify FAILED', $result);
+    }
+
+    /**
+     * Set Http options.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function setHttpOptions(): self
+    {
+        if ($this->config->has('http') && is_array($this->config->get('http'))) {
+            $this->config->forget('http.base_uri');
+            $this->httpOptions = $this->config->get('http');
+        }
+
+        return $this;
+    }
+
+    /**
+     * 0x转高精度数字.
+     *
+     * @author 大冰 https://sbing.vip/archives/2019-new-alipay-php-docking.html
+     *
+     * @param $hex
+     *
+     * @return int|string
+     */
+    private static function bchexdec($hex)
+    {
+        $dec = 0;
+        $len = strlen($hex);
+        for ($i = 1; $i <= $len; ++$i) {
+            if (ctype_xdigit($hex[$i - 1])) {
+                $dec = bcadd($dec, bcmul(strval(hexdec($hex[$i - 1])), bcpow('16', strval($len - $i))));
+            }
+        }
+
+        return str_replace('.00', '', $dec);
+    }
+}

+ 49 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/TransferGateway.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Yansongda\Pay\Contracts\GatewayInterface;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Supports\Collection;
+
+class TransferGateway implements GatewayInterface
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidConfigException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['method'] = 'alipay.fund.trans.uni.transfer';
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Alipay', 'Transfer', $endpoint, $payload));
+
+        return Support::requestApi($payload);
+    }
+
+    /**
+     * Find.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $order
+     */
+    public function find($order): array
+    {
+        return [
+            'method' => 'alipay.fund.trans.order.query',
+            'biz_content' => json_encode(is_array($order) ? $order : ['out_biz_no' => $order]),
+        ];
+    }
+}

+ 26 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/WapGateway.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+class WapGateway extends WebGateway
+{
+    /**
+     * Get method config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getMethod(): string
+    {
+        return 'alipay.trade.wap.pay';
+    }
+
+    /**
+     * Get productCode config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getProductCode(): string
+    {
+        return 'QUICK_WAP_WAY';
+    }
+}

+ 104 - 0
addons/epay/library/Yansongda/Pay/Gateways/Alipay/WebGateway.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Alipay;
+
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidConfigException;
+use Yansongda\Pay\Gateways\Alipay;
+
+class WebGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws InvalidConfigException
+     * @throws InvalidArgumentException
+     */
+    public function pay($endpoint, array $payload): Response
+    {
+        $biz_array = json_decode($payload['biz_content'], true);
+        $biz_array['product_code'] = $this->getProductCode();
+
+        $method = $biz_array['http_method'] ?? 'POST';
+
+        unset($biz_array['http_method']);
+        if ((Alipay::MODE_SERVICE === $this->mode) && (!empty(Support::getInstance()->pid))) {
+            $biz_array['extend_params'] = is_array($biz_array['extend_params']) ? array_merge(['sys_service_provider_id' => Support::getInstance()->pid], $biz_array['extend_params']) : ['sys_service_provider_id' => Support::getInstance()->pid];
+        }
+        $payload['method'] = $this->getMethod();
+        $payload['biz_content'] = json_encode($biz_array);
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Alipay', 'Web/Wap', $endpoint, $payload));
+
+        return $this->buildPayHtml($endpoint, $payload, $method);
+    }
+
+    /**
+     * Find.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $order
+     */
+    public function find($order): array
+    {
+        return [
+            'method' => 'alipay.trade.query',
+            'biz_content' => json_encode(is_array($order) ? $order : ['out_trade_no' => $order]),
+        ];
+    }
+
+    /**
+     * Build Html response.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     * @param array  $payload
+     * @param string $method
+     */
+    protected function buildPayHtml($endpoint, $payload, $method = 'POST'): Response
+    {
+        if ('GET' === strtoupper($method)) {
+            return new RedirectResponse($endpoint.'&'.http_build_query($payload));
+        }
+
+        $sHtml = "<form id='alipay_submit' name='alipay_submit' action='".$endpoint."' method='".$method."'>";
+        foreach ($payload as $key => $val) {
+            $val = str_replace("'", '&apos;', $val);
+            $sHtml .= "<input type='hidden' name='".$key."' value='".$val."'/>";
+        }
+        $sHtml .= "<input type='submit' value='ok' style='display:none;'></form>";
+        $sHtml .= "<script>document.forms['alipay_submit'].submit();</script>";
+
+        return new Response($sHtml);
+    }
+
+    /**
+     * Get method config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getMethod(): string
+    {
+        return 'alipay.trade.page.pay';
+    }
+
+    /**
+     * Get productCode config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getProductCode(): string
+    {
+        return 'FAST_INSTANT_TRADE_PAY';
+    }
+}

+ 366 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat.php

@@ -0,0 +1,366 @@
+<?php
+
+namespace Yansongda\Pay\Gateways;
+
+use Exception;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Yansongda\Pay\Contracts\GatewayApplicationInterface;
+use Yansongda\Pay\Contracts\GatewayInterface;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidGatewayException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Wechat\Support;
+use Yansongda\Pay\Log;
+use Yansongda\Supports\Collection;
+use Yansongda\Supports\Config;
+use Yansongda\Supports\Str;
+
+/**
+ * @method Response         app(array $config)          APP 支付
+ * @method Collection       groupRedpack(array $config) 分裂红包
+ * @method Collection       miniapp(array $config)      小程序支付
+ * @method Collection       mp(array $config)           公众号支付
+ * @method Collection       pos(array $config)          刷卡支付
+ * @method Collection       redpack(array $config)      普通红包
+ * @method Collection       scan(array $config)         扫码支付
+ * @method Collection       transfer(array $config)     企业付款
+ * @method RedirectResponse web(array $config)          Web 扫码支付
+ * @method RedirectResponse wap(array $config)          H5 支付
+ */
+class Wechat implements GatewayApplicationInterface
+{
+    /**
+     * 普通模式.
+     */
+    const MODE_NORMAL = 'normal';
+
+    /**
+     * 沙箱模式.
+     */
+    const MODE_DEV = 'dev';
+
+    /**
+     * 香港钱包 API.
+     */
+    const MODE_HK = 'hk';
+
+    /**
+     * 境外 API.
+     */
+    const MODE_US = 'us';
+
+    /**
+     * 服务商模式.
+     */
+    const MODE_SERVICE = 'service';
+
+    /**
+     * Const url.
+     */
+    const URL = [
+        self::MODE_NORMAL => 'https://api.mch.weixin.qq.com/',
+        self::MODE_DEV => 'https://api.mch.weixin.qq.com/sandboxnew/',
+        self::MODE_HK => 'https://apihk.mch.weixin.qq.com/',
+        self::MODE_SERVICE => 'https://api.mch.weixin.qq.com/',
+        self::MODE_US => 'https://apius.mch.weixin.qq.com/',
+    ];
+
+    /**
+     * Wechat payload.
+     *
+     * @var array
+     */
+    protected $payload;
+
+    /**
+     * Wechat gateway.
+     *
+     * @var string
+     */
+    protected $gateway;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws Exception
+     */
+    public function __construct(Config $config)
+    {
+        $this->gateway = Support::create($config)->getBaseUri();
+        $this->payload = [
+            'appid' => $config->get('app_id', ''),
+            'mch_id' => $config->get('mch_id', ''),
+            'nonce_str' => Str::random(),
+            'notify_url' => $config->get('notify_url', ''),
+            'sign' => '',
+            'trade_type' => '',
+            'spbill_create_ip' => Request::createFromGlobals()->getClientIp(),
+        ];
+
+        if ($config->get('mode', self::MODE_NORMAL) === static::MODE_SERVICE) {
+            $this->payload = array_merge($this->payload, [
+                'sub_mch_id' => $config->get('sub_mch_id'),
+                'sub_appid' => $config->get('sub_app_id', ''),
+            ]);
+        }
+    }
+
+    /**
+     * Magic pay.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param string $params
+     *
+     * @throws InvalidGatewayException
+     *
+     * @return Response|Collection
+     */
+    public function __call($method, $params)
+    {
+        return self::pay($method, ...$params);
+    }
+
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $gateway
+     * @param array  $params
+     *
+     * @throws InvalidGatewayException
+     *
+     * @return Response|Collection
+     */
+    public function pay($gateway, $params = [])
+    {
+        Events::dispatch(new Events\PayStarting('Wechat', $gateway, $params));
+
+        $this->payload = array_merge($this->payload, $params);
+
+        $gateway = get_class($this).'\\'.Str::studly($gateway).'Gateway';
+
+        if (class_exists($gateway)) {
+            return $this->makePay($gateway);
+        }
+
+        throw new InvalidGatewayException("Pay Gateway [{$gateway}] Not Exists");
+    }
+
+    /**
+     * Verify data.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|null $content
+     *
+     * @throws InvalidSignException
+     * @throws InvalidArgumentException
+     */
+    public function verify($content = null, bool $refund = false): Collection
+    {
+        $content = $content ?? Request::createFromGlobals()->getContent();
+
+        Events::dispatch(new Events\RequestReceived('Wechat', '', [$content]));
+
+        $data = Support::fromXml($content);
+        if ($refund) {
+            $decrypt_data = Support::decryptRefundContents($data['req_info']);
+            $data = array_merge(Support::fromXml($decrypt_data), $data);
+        }
+
+        Log::debug('Resolved The Received Wechat Request Data', $data);
+
+        if ($refund || Support::generateSign($data) === $data['sign']) {
+            return new Collection($data);
+        }
+
+        Events::dispatch(new Events\SignFailed('Wechat', '', $data));
+
+        throw new InvalidSignException('Wechat Sign Verify FAILED', $data);
+    }
+
+    /**
+     * Query an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $order
+     *
+     * @throws GatewayException
+     * @throws InvalidSignException
+     * @throws InvalidArgumentException
+     */
+    public function find($order, string $type = 'wap'): Collection
+    {
+        if ('wap' != $type) {
+            unset($this->payload['spbill_create_ip']);
+        }
+
+        $gateway = get_class($this).'\\'.Str::studly($type).'Gateway';
+
+        if (!class_exists($gateway) || !is_callable([new $gateway(), 'find'])) {
+            throw new GatewayException("{$gateway} Done Not Exist Or Done Not Has FIND Method");
+        }
+
+        $config = call_user_func([new $gateway(), 'find'], $order);
+
+        $this->payload = Support::filterPayload($this->payload, $config['order']);
+
+        Events::dispatch(new Events\MethodCalled('Wechat', 'Find', $this->gateway, $this->payload));
+
+        return Support::requestApi(
+            $config['endpoint'],
+            $this->payload,
+            $config['cert']
+        );
+    }
+
+    /**
+     * Refund an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidSignException
+     * @throws InvalidArgumentException
+     */
+    public function refund(array $order): Collection
+    {
+        $this->payload = Support::filterPayload($this->payload, $order, true);
+
+        Events::dispatch(new Events\MethodCalled('Wechat', 'Refund', $this->gateway, $this->payload));
+
+        return Support::requestApi(
+            'secapi/pay/refund',
+            $this->payload,
+            true
+        );
+    }
+
+    /**
+     * Cancel an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array $order
+     *
+     * @throws GatewayException
+     * @throws InvalidSignException
+     * @throws InvalidArgumentException
+     */
+    public function cancel($order): Collection
+    {
+        unset($this->payload['spbill_create_ip']);
+
+        $this->payload = Support::filterPayload($this->payload, $order);
+
+        Events::dispatch(new Events\MethodCalled('Wechat', 'Cancel', $this->gateway, $this->payload));
+
+        return Support::requestApi(
+            'secapi/pay/reverse',
+            $this->payload,
+            true
+        );
+    }
+
+    /**
+     * Close an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $order
+     *
+     * @throws GatewayException
+     * @throws InvalidSignException
+     * @throws InvalidArgumentException
+     */
+    public function close($order): Collection
+    {
+        unset($this->payload['spbill_create_ip']);
+
+        $this->payload = Support::filterPayload($this->payload, $order);
+
+        Events::dispatch(new Events\MethodCalled('Wechat', 'Close', $this->gateway, $this->payload));
+
+        return Support::requestApi('pay/closeorder', $this->payload);
+    }
+
+    /**
+     * Echo success to server.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidArgumentException
+     */
+    public function success(): Response
+    {
+        Events::dispatch(new Events\MethodCalled('Wechat', 'Success', $this->gateway));
+
+        return new Response(
+            Support::toXml(['return_code' => 'SUCCESS', 'return_msg' => 'OK']),
+            200,
+            ['Content-Type' => 'application/xml']
+        );
+    }
+
+    /**
+     * Download the bill.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     */
+    public function download(array $params): string
+    {
+        unset($this->payload['spbill_create_ip']);
+
+        $this->payload = Support::filterPayload($this->payload, $params, true);
+
+        Events::dispatch(new Events\MethodCalled('Wechat', 'Download', $this->gateway, $this->payload));
+
+        $result = Support::getInstance()->post(
+            'pay/downloadbill',
+            Support::getInstance()->toXml($this->payload)
+        );
+
+        if (is_array($result)) {
+            throw new GatewayException('Get Wechat API Error: '.$result['return_msg'], $result);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Make pay gateway.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $gateway
+     *
+     * @throws InvalidGatewayException
+     *
+     * @return Response|Collection
+     */
+    protected function makePay($gateway)
+    {
+        $app = new $gateway();
+
+        if ($app instanceof GatewayInterface) {
+            return $app->pay($this->gateway, array_filter($this->payload, function ($value) {
+                return '' !== $value && !is_null($value);
+            }));
+        }
+
+        throw new InvalidGatewayException("Pay Gateway [{$gateway}] Must Be An Instance Of GatewayInterface");
+    }
+}

+ 62 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/AppGateway.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Exception;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Wechat;
+use Yansongda\Supports\Str;
+
+class AppGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     * @throws Exception
+     */
+    public function pay($endpoint, array $payload): Response
+    {
+        $payload['appid'] = Support::getInstance()->appid;
+        $payload['trade_type'] = $this->getTradeType();
+
+        if (Wechat::MODE_SERVICE === $this->mode) {
+            $payload['sub_appid'] = Support::getInstance()->sub_appid;
+        }
+
+        $pay_request = [
+            'appid' => Wechat::MODE_SERVICE === $this->mode ? $payload['sub_appid'] : $payload['appid'],
+            'partnerid' => Wechat::MODE_SERVICE === $this->mode ? $payload['sub_mch_id'] : $payload['mch_id'],
+            'prepayid' => $this->preOrder($payload)->get('prepay_id'),
+            'timestamp' => strval(time()),
+            'noncestr' => Str::random(),
+            'package' => 'Sign=WXPay',
+        ];
+        $pay_request['sign'] = Support::generateSign($pay_request);
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'App', $endpoint, $pay_request));
+
+        return new JsonResponse($pay_request);
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return 'APP';
+    }
+}

+ 88 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/Gateway.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Yansongda\Pay\Contracts\GatewayInterface;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Supports\Collection;
+
+abstract class Gateway implements GatewayInterface
+{
+    /**
+     * Mode.
+     *
+     * @var string
+     */
+    protected $mode;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidArgumentException
+     */
+    public function __construct()
+    {
+        $this->mode = Support::getInstance()->mode;
+    }
+
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @return Collection
+     */
+    abstract public function pay($endpoint, array $payload);
+
+    /**
+     * Find.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $order
+     */
+    public function find($order): array
+    {
+        return [
+            'endpoint' => 'pay/orderquery',
+            'order' => is_array($order) ? $order : ['out_trade_no' => $order],
+            'cert' => false,
+        ];
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return string
+     */
+    abstract protected function getTradeType();
+
+    /**
+     * Schedule an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array $payload
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    protected function preOrder($payload): Collection
+    {
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\MethodCalled('Wechat', 'PreOrder', '', $payload));
+
+        return Support::requestApi('pay/unifiedorder', $payload);
+    }
+}

+ 57 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/GroupRedpackGateway.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Wechat;
+use Yansongda\Supports\Collection;
+
+class GroupRedpackGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['wxappid'] = $payload['appid'];
+        $payload['amt_type'] = 'ALL_RAND';
+
+        if (Wechat::MODE_SERVICE === $this->mode) {
+            $payload['msgappid'] = $payload['appid'];
+        }
+
+        unset($payload['appid'], $payload['trade_type'],
+              $payload['notify_url'], $payload['spbill_create_ip']);
+
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'Group Redpack', $endpoint, $payload));
+
+        return Support::requestApi(
+            'mmpaymkttransfers/sendgroupredpack',
+            $payload,
+            true
+        );
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return '';
+    }
+}

+ 35 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/MiniappGateway.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Wechat;
+use Yansongda\Supports\Collection;
+
+class MiniappGateway extends MpGateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['appid'] = Support::getInstance()->miniapp_id;
+
+        if (Wechat::MODE_SERVICE === $this->mode) {
+            $payload['sub_appid'] = Support::getInstance()->sub_miniapp_id;
+            $this->payRequestUseSubAppId = true;
+        }
+
+        return parent::pay($endpoint, $payload);
+    }
+}

+ 59 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/MpGateway.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Exception;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Supports\Collection;
+use Yansongda\Supports\Str;
+
+class MpGateway extends Gateway
+{
+    /**
+     * @var bool
+     */
+    protected $payRequestUseSubAppId = false;
+
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     * @throws Exception
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['trade_type'] = $this->getTradeType();
+
+        $pay_request = [
+            'appId' => !$this->payRequestUseSubAppId ? $payload['appid'] : $payload['sub_appid'],
+            'timeStamp' => strval(time()),
+            'nonceStr' => Str::random(),
+            'package' => 'prepay_id='.$this->preOrder($payload)->get('prepay_id'),
+            'signType' => 'MD5',
+        ];
+        $pay_request['paySign'] = Support::generateSign($pay_request);
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'JSAPI', $endpoint, $pay_request));
+
+        return new Collection($pay_request);
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return 'JSAPI';
+    }
+}

+ 44 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/PosGateway.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Supports\Collection;
+
+class PosGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        unset($payload['trade_type'], $payload['notify_url']);
+
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'Pos', $endpoint, $payload));
+
+        return Support::requestApi('pay/micropay', $payload);
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return 'MICROPAY';
+    }
+}

+ 61 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/RedpackGateway.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Symfony\Component\HttpFoundation\Request;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Wechat;
+use Yansongda\Supports\Collection;
+
+class RedpackGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['wxappid'] = $payload['appid'];
+
+        if ('cli' !== php_sapi_name()) {
+            $payload['client_ip'] = Request::createFromGlobals()->server->get('SERVER_ADDR');
+        }
+
+        if (Wechat::MODE_SERVICE === $this->mode) {
+            $payload['msgappid'] = $payload['appid'];
+        }
+
+        unset($payload['appid'], $payload['trade_type'],
+              $payload['notify_url'], $payload['spbill_create_ip']);
+
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'Redpack', $endpoint, $payload));
+
+        return Support::requestApi(
+            'mmpaymkttransfers/sendredpack',
+            $payload,
+            true
+        );
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return '';
+    }
+}

+ 50 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/RefundGateway.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+
+class RefundGateway extends Gateway
+{
+    /**
+     * Find.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $order
+     */
+    public function find($order): array
+    {
+        return [
+            'endpoint' => 'pay/refundquery',
+            'order' => is_array($order) ? $order : ['out_trade_no' => $order],
+            'cert' => false,
+        ];
+    }
+
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws InvalidArgumentException
+     */
+    public function pay($endpoint, array $payload)
+    {
+        throw new InvalidArgumentException('Not Support Refund In Pay');
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidArgumentException
+     */
+    protected function getTradeType()
+    {
+        throw new InvalidArgumentException('Not Support Refund In Pay');
+    }
+}

+ 44 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/ScanGateway.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Symfony\Component\HttpFoundation\Request;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Supports\Collection;
+
+class ScanGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        $payload['spbill_create_ip'] = Request::createFromGlobals()->server->get('SERVER_ADDR');
+        $payload['trade_type'] = $this->getTradeType();
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'Scan', $endpoint, $payload));
+
+        return $this->preOrder($payload);
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return 'NATIVE';
+    }
+}

+ 449 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/Support.php

@@ -0,0 +1,449 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Exception;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\BusinessException;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Wechat;
+use Yansongda\Pay\Log;
+use Yansongda\Supports\Collection;
+use Yansongda\Supports\Config;
+use Yansongda\Supports\Str;
+use Yansongda\Supports\Traits\HasHttpRequest;
+
+/**
+ * @author yansongda <me@yansongda.cn>
+ *
+ * @property string appid
+ * @property string app_id
+ * @property string miniapp_id
+ * @property string sub_appid
+ * @property string sub_app_id
+ * @property string sub_miniapp_id
+ * @property string mch_id
+ * @property string sub_mch_id
+ * @property string key
+ * @property string return_url
+ * @property string cert_client
+ * @property string cert_key
+ * @property array log
+ * @property array http
+ * @property string mode
+ */
+class Support
+{
+    use HasHttpRequest;
+
+    /**
+     * Wechat gateway.
+     *
+     * @var string
+     */
+    protected $baseUri;
+
+    /**
+     * Config.
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * Instance.
+     *
+     * @var Support
+     */
+    private static $instance;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    private function __construct(Config $config)
+    {
+        $this->baseUri = Wechat::URL[$config->get('mode', Wechat::MODE_NORMAL)];
+        $this->config = $config;
+
+        $this->setHttpOptions();
+    }
+
+    /**
+     * __get.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $key
+     *
+     * @return mixed|Config|null
+     */
+    public function __get($key)
+    {
+        return $this->getConfig($key);
+    }
+
+    /**
+     * create.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     *
+     * @return Support
+     */
+    public static function create(Config $config)
+    {
+        if ('cli' === php_sapi_name() || !(self::$instance instanceof self)) {
+            self::$instance = new self($config);
+
+            self::setDevKey();
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * getInstance.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws InvalidArgumentException
+     *
+     * @return Support
+     */
+    public static function getInstance()
+    {
+        if (is_null(self::$instance)) {
+            throw new InvalidArgumentException('You Should [Create] First Before Using');
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * clear.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public static function clear()
+    {
+        self::$instance = null;
+    }
+
+    /**
+     * Request wechat api.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     * @param array  $data
+     * @param bool   $cert
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public static function requestApi($endpoint, $data, $cert = false): Collection
+    {
+        Events::dispatch(new Events\ApiRequesting('Wechat', '', self::$instance->getBaseUri().$endpoint, $data));
+
+        $result = self::$instance->post(
+            $endpoint,
+            self::toXml($data),
+            $cert ? [
+                'cert' => self::$instance->cert_client,
+                'ssl_key' => self::$instance->cert_key,
+            ] : []
+        );
+        $result = is_array($result) ? $result : self::fromXml($result);
+
+        Events::dispatch(new Events\ApiRequested('Wechat', '', self::$instance->getBaseUri().$endpoint, $result));
+
+        return self::processingApiResult($endpoint, $result);
+    }
+
+    /**
+     * Filter payload.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array        $payload
+     * @param array|string $params
+     * @param bool         $preserve_notify_url
+     *
+     * @throws InvalidArgumentException
+     */
+    public static function filterPayload($payload, $params, $preserve_notify_url = false): array
+    {
+        $type = self::getTypeName($params['type'] ?? '');
+
+        $payload = array_merge(
+            $payload,
+            is_array($params) ? $params : ['out_trade_no' => $params]
+        );
+        $payload['appid'] = self::$instance->getConfig($type, '');
+
+        if (Wechat::MODE_SERVICE === self::$instance->getConfig('mode', Wechat::MODE_NORMAL)) {
+            $payload['sub_appid'] = self::$instance->getConfig('sub_'.$type, '');
+        }
+
+        unset($payload['trade_type'], $payload['type']);
+        if (!$preserve_notify_url) {
+            unset($payload['notify_url']);
+        }
+
+        $payload['sign'] = self::generateSign($payload);
+
+        return $payload;
+    }
+
+    /**
+     * Generate wechat sign.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array $data
+     *
+     * @throws InvalidArgumentException
+     */
+    public static function generateSign($data): string
+    {
+        $key = self::$instance->key;
+
+        if (is_null($key)) {
+            throw new InvalidArgumentException('Missing Wechat Config -- [key]');
+        }
+
+        ksort($data);
+
+        $string = md5(self::getSignContent($data).'&key='.$key);
+
+        Log::debug('Wechat Generate Sign Before UPPER', [$data, $string]);
+
+        return strtoupper($string);
+    }
+
+    /**
+     * Generate sign content.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array $data
+     */
+    public static function getSignContent($data): string
+    {
+        $buff = '';
+
+        foreach ($data as $k => $v) {
+            $buff .= ('sign' != $k && '' != $v && !is_array($v)) ? $k.'='.$v.'&' : '';
+        }
+
+        Log::debug('Wechat Generate Sign Content Before Trim', [$data, $buff]);
+
+        return trim($buff, '&');
+    }
+
+    /**
+     * Decrypt refund contents.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $contents
+     */
+    public static function decryptRefundContents($contents): string
+    {
+        return openssl_decrypt(
+            base64_decode($contents),
+            'AES-256-ECB',
+            md5(self::$instance->key),
+            OPENSSL_RAW_DATA
+        );
+    }
+
+    /**
+     * Convert array to xml.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param array $data
+     *
+     * @throws InvalidArgumentException
+     */
+    public static function toXml($data): string
+    {
+        if (!is_array($data) || count($data) <= 0) {
+            throw new InvalidArgumentException('Convert To Xml Error! Invalid Array!');
+        }
+
+        $xml = '<xml>';
+        foreach ($data as $key => $val) {
+            $xml .= is_numeric($val) ? '<'.$key.'>'.$val.'</'.$key.'>' :
+                                       '<'.$key.'><![CDATA['.$val.']]></'.$key.'>';
+        }
+        $xml .= '</xml>';
+
+        return $xml;
+    }
+
+    /**
+     * Convert xml to array.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $xml
+     *
+     * @throws InvalidArgumentException
+     */
+    public static function fromXml($xml): array
+    {
+        if (!$xml) {
+            throw new InvalidArgumentException('Convert To Array Error! Invalid Xml!');
+        }
+
+        libxml_disable_entity_loader(true);
+
+        return json_decode(json_encode(simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA), JSON_UNESCAPED_UNICODE), true);
+    }
+
+    /**
+     * Get service config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|null $key
+     * @param mixed|null  $default
+     *
+     * @return mixed|null
+     */
+    public function getConfig($key = null, $default = null)
+    {
+        if (is_null($key)) {
+            return $this->config->all();
+        }
+
+        if ($this->config->has($key)) {
+            return $this->config[$key];
+        }
+
+        return $default;
+    }
+
+    /**
+     * Get app id according to param type.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $type
+     */
+    public static function getTypeName($type = ''): string
+    {
+        switch ($type) {
+            case '':
+                $type = 'app_id';
+                break;
+            case 'app':
+                $type = 'appid';
+                break;
+            default:
+                $type = $type.'_id';
+        }
+
+        return $type;
+    }
+
+    /**
+     * Get Base Uri.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return string
+     */
+    public function getBaseUri()
+    {
+        return $this->baseUri;
+    }
+
+    /**
+     * processingApiResult.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     *
+     * @return Collection
+     */
+    protected static function processingApiResult($endpoint, array $result)
+    {
+        if (!isset($result['return_code']) || 'SUCCESS' != $result['return_code']) {
+            throw new GatewayException('Get Wechat API Error:'.($result['return_msg'] ?? $result['retmsg'] ?? ''), $result);
+        }
+
+        if (isset($result['result_code']) && 'SUCCESS' != $result['result_code']) {
+            throw new BusinessException('Wechat Business Error: '.$result['err_code'].' - '.$result['err_code_des'], $result);
+        }
+
+        if ('pay/getsignkey' === $endpoint ||
+            false !== strpos($endpoint, 'mmpaymkttransfers') ||
+            self::generateSign($result) === $result['sign']) {
+            return new Collection($result);
+        }
+
+        Events::dispatch(new Events\SignFailed('Wechat', '', $result));
+
+        throw new InvalidSignException('Wechat Sign Verify FAILED', $result);
+    }
+
+    /**
+     * setDevKey.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     * @throws Exception
+     *
+     * @return Support
+     */
+    private static function setDevKey()
+    {
+        if (Wechat::MODE_DEV == self::$instance->mode) {
+            $data = [
+                'mch_id' => self::$instance->mch_id,
+                'nonce_str' => Str::random(),
+            ];
+            $data['sign'] = self::generateSign($data);
+
+            $result = self::requestApi('pay/getsignkey', $data);
+
+            self::$instance->config->set('key', $result['sandbox_signkey']);
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * Set Http options.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    private function setHttpOptions(): self
+    {
+        if ($this->config->has('http') && is_array($this->config->get('http'))) {
+            $this->config->forget('http.base_uri');
+            $this->httpOptions = $this->config->get('http');
+        }
+
+        return $this;
+    }
+}

+ 80 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/TransferGateway.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Symfony\Component\HttpFoundation\Request;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Pay\Gateways\Wechat;
+use Yansongda\Supports\Collection;
+
+class TransferGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): Collection
+    {
+        if (Wechat::MODE_SERVICE === $this->mode) {
+            unset($payload['sub_mch_id'], $payload['sub_appid']);
+        }
+
+        $type = Support::getTypeName($payload['type'] ?? '');
+
+        $payload['mch_appid'] = Support::getInstance()->getConfig($type, '');
+        $payload['mchid'] = $payload['mch_id'];
+
+        if ('cli' !== php_sapi_name() && !isset($payload['spbill_create_ip'])) {
+            $payload['spbill_create_ip'] = Request::createFromGlobals()->server->get('SERVER_ADDR');
+        }
+
+        unset($payload['appid'], $payload['mch_id'], $payload['trade_type'],
+            $payload['notify_url'], $payload['type']);
+
+        $payload['sign'] = Support::generateSign($payload);
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'Transfer', $endpoint, $payload));
+
+        return Support::requestApi(
+            'mmpaymkttransfers/promotion/transfers',
+            $payload,
+            true
+        );
+    }
+
+    /**
+     * Find.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $order
+     */
+    public function find($order): array
+    {
+        return [
+            'endpoint' => 'mmpaymkttransfers/gettransferinfo',
+            'order' => is_array($order) ? $order : ['partner_trade_no' => $order],
+            'cert' => true,
+        ];
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return '';
+    }
+}

+ 47 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/WapGateway.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+
+class WapGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $endpoint
+     *
+     * @throws GatewayException
+     * @throws InvalidArgumentException
+     * @throws InvalidSignException
+     */
+    public function pay($endpoint, array $payload): RedirectResponse
+    {
+        $payload['trade_type'] = $this->getTradeType();
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'Wap', $endpoint, $payload));
+
+        $mweb_url = $this->preOrder($payload)->get('mweb_url');
+
+        $url = is_null(Support::getInstance()->return_url) ? $mweb_url : $mweb_url.
+                        '&redirect_url='.urlencode(Support::getInstance()->return_url);
+
+        return new RedirectResponse($url);
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function getTradeType(): string
+    {
+        return 'MWEB';
+    }
+}

+ 86 - 0
addons/epay/library/Yansongda/Pay/Gateways/Wechat/WebGateway.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace Yansongda\Pay\Gateways\Wechat;
+
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\Request;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Exceptions\GatewayException;
+use Yansongda\Pay\Exceptions\InvalidArgumentException;
+use Yansongda\Pay\Exceptions\InvalidSignException;
+use Yansongda\Supports\Collection;
+
+class WebGateway extends Gateway
+{
+    /**
+     * Pay an order.
+     *
+     * @param string $endpoint
+     * @param array  $payload
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     */
+    public function pay($endpoint, array $payload): Response
+    {
+        $payload['spbill_create_ip'] = Request::createFromGlobals()->server->get('SERVER_ADDR');
+        $payload['trade_type'] = $this->getTradeType();
+
+        $code_url = $this->preOrder($payload)['code_url'];
+        $params = [
+            'body'         => $payload['body'],
+            'code_url'     => $code_url,
+            'out_trade_no' => $payload['out_trade_no'],
+            'return_url'   => Support::getInstance()->return_url,
+            'total_fee'    => $payload['total_fee'],
+        ];
+
+        $params['sign'] = md5(implode('', $params) . Support::getInstance()->app_id);
+        $endpoint = addon_url("epay/api/wechat");
+
+        Events::dispatch(new Events\PayStarted('Wechat', 'Web/Wap', $endpoint, $payload));
+
+        return $this->buildPayHtml($endpoint, $params);
+    }
+
+    /**
+     * Build Html response.
+     *
+     * @param string $endpoint
+     * @param array  $payload
+     * @param string $method
+     *
+     * @return Response
+     * @author yansongda <me@yansongda.cn>
+     *
+     */
+    protected function buildPayHtml($endpoint, $payload, $method = 'POST'): Response
+    {
+        if (strtoupper($method) === 'GET') {
+            return RedirectResponse::create($endpoint . '?' . http_build_query($payload));
+        }
+
+        $sHtml = "<form id='wechat_submit' name='wechat_submit' action='" . $endpoint . "' method='" . $method . "'>";
+        foreach ($payload as $key => $val) {
+            $val = str_replace("'", '&apos;', $val);
+            $sHtml .= "<input type='hidden' name='" . $key . "' value='" . $val . "'/>";
+        }
+        $sHtml .= "<input type='submit' value='ok' style='display:none;'></form>";
+        $sHtml .= "<script>document.forms['wechat_submit'].submit();</script>";
+
+        return Response::create($sHtml);
+    }
+
+    /**
+     * Get trade type config.
+     *
+     * @return string
+     * @author yansongda <me@yansongda.cn>
+     *
+     */
+    protected function getTradeType(): string
+    {
+        return 'NATIVE';
+    }
+}

+ 20 - 0
addons/epay/library/Yansongda/Pay/LICENSE

@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2017 yansongda <me@yansongda.cn>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 114 - 0
addons/epay/library/Yansongda/Pay/Listeners/KernelLogSubscriber.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace Yansongda\Pay\Listeners;
+
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Yansongda\Pay\Events;
+use Yansongda\Pay\Log;
+
+class KernelLogSubscriber implements EventSubscriberInterface
+{
+    /**
+     * Returns an array of event names this subscriber wants to listen to.
+     *
+     * The array keys are event names and the value can be:
+     *
+     *  * The method name to call (priority defaults to 0)
+     *  * An array composed of the method name to call and the priority
+     *  * An array of arrays composed of the method names to call and respective
+     *    priorities, or 0 if unset
+     *
+     * For instance:
+     *
+     *  * array('eventName' => 'methodName')
+     *  * array('eventName' => array('methodName', $priority))
+     *  * array('eventName' => array(array('methodName1', $priority), array('methodName2')))
+     *
+     * @return array The event names to listen to
+     */
+    public static function getSubscribedEvents()
+    {
+        return [
+            Events\PayStarting::class => ['writePayStartingLog', 256],
+            Events\PayStarted::class => ['writePayStartedLog', 256],
+            Events\ApiRequesting::class => ['writeApiRequestingLog', 256],
+            Events\ApiRequested::class => ['writeApiRequestedLog', 256],
+            Events\SignFailed::class => ['writeSignFailedLog', 256],
+            Events\RequestReceived::class => ['writeRequestReceivedLog', 256],
+            Events\MethodCalled::class => ['writeMethodCalledLog', 256],
+        ];
+    }
+
+    /**
+     * writePayStartingLog.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function writePayStartingLog(Events\PayStarting $event)
+    {
+        Log::debug("Starting To {$event->driver}", [$event->gateway, $event->params]);
+    }
+
+    /**
+     * writePayStartedLog.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function writePayStartedLog(Events\PayStarted $event)
+    {
+        Log::info(
+            "{$event->driver} {$event->gateway} Has Started",
+            [$event->endpoint, $event->payload]
+        );
+    }
+
+    /**
+     * writeApiRequestingLog.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function writeApiRequestingLog(Events\ApiRequesting $event)
+    {
+        Log::debug("Requesting To {$event->driver} Api", [$event->endpoint, $event->payload]);
+    }
+
+    /**
+     * writeApiRequestedLog.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function writeApiRequestedLog(Events\ApiRequested $event)
+    {
+        Log::debug("Result Of {$event->driver} Api", $event->result);
+    }
+
+    /**
+     * writeSignFailedLog.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function writeSignFailedLog(Events\SignFailed $event)
+    {
+        Log::warning("{$event->driver} Sign Verify FAILED", $event->data);
+    }
+
+    /**
+     * writeRequestReceivedLog.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function writeRequestReceivedLog(Events\RequestReceived $event)
+    {
+        Log::info("Received {$event->driver} Request", $event->data);
+    }
+
+    /**
+     * writeMethodCalledLog.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function writeMethodCalledLog(Events\MethodCalled $event)
+    {
+        Log::info("{$event->driver} {$event->gateway} Method Has Called", [$event->endpoint, $event->payload]);
+    }
+}

+ 49 - 0
addons/epay/library/Yansongda/Pay/Log.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace Yansongda\Pay;
+
+use Yansongda\Supports\Log as BaseLog;
+
+/**
+ * @method static void emergency($message, array $context = array())
+ * @method static void alert($message, array $context = array())
+ * @method static void critical($message, array $context = array())
+ * @method static void error($message, array $context = array())
+ * @method static void warning($message, array $context = array())
+ * @method static void notice($message, array $context = array())
+ * @method static void info($message, array $context = array())
+ * @method static void debug($message, array $context = array())
+ * @method static void log($message, array $context = array())
+ */
+class Log
+{
+    /**
+     * Forward call.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $args
+     *
+     * @return mixed
+     */
+    public static function __callStatic($method, $args)
+    {
+        return forward_static_call_array([BaseLog::class, $method], $args);
+    }
+
+    /**
+     * Forward call.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $args
+     *
+     * @return mixed
+     */
+    public function __call($method, $args)
+    {
+        return call_user_func_array([BaseLog::class, $method], $args);
+    }
+}

+ 131 - 0
addons/epay/library/Yansongda/Pay/Pay.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace Yansongda\Pay;
+
+use Exception;
+use Yansongda\Pay\Contracts\GatewayApplicationInterface;
+use Yansongda\Pay\Exceptions\InvalidGatewayException;
+use Yansongda\Pay\Gateways\Alipay;
+use Yansongda\Pay\Gateways\Wechat;
+use Yansongda\Pay\Listeners\KernelLogSubscriber;
+use Yansongda\Supports\Config;
+use Yansongda\Supports\Log;
+use Yansongda\Supports\Logger;
+use Yansongda\Supports\Str;
+
+/**
+ * @method static Alipay alipay(array $config) 支付宝
+ * @method static Wechat wechat(array $config) 微信
+ */
+class Pay
+{
+    /**
+     * Config.
+     *
+     * @var Config
+     */
+    protected $config;
+
+    /**
+     * Bootstrap.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws Exception
+     */
+    public function __construct(array $config)
+    {
+        $this->config = new Config($config);
+
+        $this->registerLogService();
+        $this->registerEventService();
+    }
+
+    /**
+     * Magic static call.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $params
+     *
+     * @throws InvalidGatewayException
+     * @throws Exception
+     */
+    public static function __callStatic($method, $params): GatewayApplicationInterface
+    {
+        $app = new self(...$params);
+
+        return $app->create($method);
+    }
+
+    /**
+     * Create a instance.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     *
+     * @throws InvalidGatewayException
+     */
+    protected function create($method): GatewayApplicationInterface
+    {
+        $gateway = __NAMESPACE__.'\\Gateways\\'.Str::studly($method);
+
+        if (class_exists($gateway)) {
+            return self::make($gateway);
+        }
+
+        throw new InvalidGatewayException("Gateway [{$method}] Not Exists");
+    }
+
+    /**
+     * Make a gateway.
+     *
+     * @author yansongda <me@yansonga.cn>
+     *
+     * @param string $gateway
+     *
+     * @throws InvalidGatewayException
+     */
+    protected function make($gateway): GatewayApplicationInterface
+    {
+        $app = new $gateway($this->config);
+
+        if ($app instanceof GatewayApplicationInterface) {
+            return $app;
+        }
+
+        throw new InvalidGatewayException("Gateway [{$gateway}] Must Be An Instance Of GatewayApplicationInterface");
+    }
+
+    /**
+     * Register log service.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws Exception
+     */
+    protected function registerLogService()
+    {
+        $config = $this->config->get('log');
+        $config['identify'] = 'yansongda.pay';
+
+        $logger = new Logger();
+        $logger->setConfig($config);
+
+        Log::setInstance($logger);
+    }
+
+    /**
+     * Register event service.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    protected function registerEventService()
+    {
+        Events::setDispatcher(Events::createDispatcher());
+
+        Events::addSubscriber(new KernelLogSubscriber());
+    }
+}

+ 605 - 0
addons/epay/library/Yansongda/Supports/Arr.php

@@ -0,0 +1,605 @@
+<?php
+
+namespace Yansongda\Supports;
+
+use ArrayAccess;
+
+/**
+ * Array helper from Illuminate\Support\Arr.
+ */
+class Arr
+{
+    /**
+     * Determine whether the given value is array accessible.
+     *
+     * @param mixed $value
+     */
+    public static function accessible($value): bool
+    {
+        return is_array($value) || $value instanceof ArrayAccess;
+    }
+
+    /**
+     * Add an element to an array using "dot" notation if it doesn't exist.
+     *
+     * @param mixed $value
+     */
+    public static function add(array $array, string $key, $value): array
+    {
+        if (is_null(static::get($array, $key))) {
+            static::set($array, $key, $value);
+        }
+
+        return $array;
+    }
+
+    /**
+     * Build a new array using a callback.
+     */
+    public static function build(array $array, callable $callback): array
+    {
+        $results = [];
+
+        foreach ($array as $key => $value) {
+            [$innerKey, $innerValue] = call_user_func($callback, $key, $value);
+            $results[$innerKey] = $innerValue;
+        }
+
+        return $results;
+    }
+
+    /**
+     * Divide an array into two arrays. One with keys and the other with values.
+     */
+    public static function divide(array $array): array
+    {
+        return [
+                array_keys($array),
+                array_values($array),
+               ];
+    }
+
+    /**
+     * Flatten a multi-dimensional associative array with dots.
+     */
+    public static function dot(array $array, string $prepend = ''): array
+    {
+        $results = [];
+
+        foreach ($array as $key => $value) {
+            if (is_array($value)) {
+                $results = array_merge($results, static::dot($value, $prepend.$key.'.'));
+            } else {
+                $results[$prepend.$key] = $value;
+            }
+        }
+
+        return $results;
+    }
+
+    /**
+     * Get all of the given array except for a specified array of items.
+     *
+     * @param array|string $keys
+     */
+    public static function except(array $array, $keys): array
+    {
+        return array_diff_key($array, array_flip((array) $keys));
+    }
+
+    /**
+     * access array.
+     *
+     * if not array access, return original.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param mixed $data
+     *
+     * @return mixed
+     */
+    public static function access($data)
+    {
+        if (!self::accessible($data) &&
+            !(is_object($data) && method_exists($data, 'toArray'))) {
+            return $data;
+        }
+
+        return is_object($data) ? $data->toArray() : $data;
+    }
+
+    /**
+     * Determine if the given key exists in the provided array.
+     *
+     * @param \ArrayAccess|array $array
+     * @param string|int         $key
+     *
+     * @return bool
+     */
+    public static function exists($array, $key)
+    {
+        $array = self::access($array);
+
+        if ($array instanceof ArrayAccess) {
+            return $array->offsetExists($key);
+        }
+
+        return array_key_exists($key, $array);
+    }
+
+    /**
+     * Check if an item or items exist in an array using "dot" notation.
+     *
+     * @param \ArrayAccess|array $array
+     * @param string|array       $keys
+     *
+     * @return bool
+     */
+    public static function has($array, $keys)
+    {
+        $array = self::access($array);
+
+        $keys = (array) $keys;
+
+        if (!$array || $keys === []) {
+            return false;
+        }
+
+        foreach ($keys as $key) {
+            $subKeyArray = $array;
+
+            if (static::exists($array, $key)) {
+                continue;
+            }
+
+            foreach (explode('.', $key) as $segment) {
+                if (static::accessible($subKeyArray) && static::exists($subKeyArray, $segment)) {
+                    $subKeyArray = $subKeyArray[$segment];
+                } else {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Determine if any of the keys exist in an array using "dot" notation.
+     *
+     * @param \ArrayAccess|array $array
+     * @param string|array       $keys
+     *
+     * @return bool
+     */
+    public static function hasAny($array, $keys)
+    {
+        $array = self::access($array);
+
+        if (is_null($keys)) {
+            return false;
+        }
+
+        $keys = (array) $keys;
+
+        if (!$array) {
+            return false;
+        }
+
+        if ($keys === []) {
+            return false;
+        }
+
+        foreach ($keys as $key) {
+            if (static::has($array, $key)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Fetch a flattened array of a nested array element.
+     */
+    public static function fetch(array $array, string $key): array
+    {
+        $results = [];
+
+        foreach (explode('.', $key) as $segment) {
+            $results = [];
+            foreach ($array as $value) {
+                $value = (array) $value;
+                $results[] = $value[$segment];
+            }
+            $array = array_values($results);
+        }
+
+        return array_values($results);
+    }
+
+    /**
+     * Return the first element in an array passing a given truth test.
+     *
+     * @param mixed $default
+     *
+     * @return mixed
+     */
+    public static function first(array $array, callable $callback, $default = null)
+    {
+        foreach ($array as $key => $value) {
+            if (call_user_func($callback, $key, $value)) {
+                return $value;
+            }
+        }
+
+        return $default;
+    }
+
+    /**
+     * Return the last element in an array passing a given truth test.
+     *
+     * @param mixed $default
+     *
+     * @return mixed
+     */
+    public static function last(array $array, callable $callback, $default = null)
+    {
+        return static::first(array_reverse($array), $callback, $default);
+    }
+
+    /**
+     * Flatten a multi-dimensional array into a single level.
+     */
+    public static function flatten(array $array): array
+    {
+        $return = [];
+        array_walk_recursive(
+            $array,
+            function ($x) use (&$return) {
+                $return[] = $x;
+            }
+        );
+
+        return $return;
+    }
+
+    /**
+     * Remove one or many array items from a given array using "dot" notation.
+     *
+     * @param array        $array
+     * @param array|string $keys
+     */
+    public static function forget(&$array, $keys)
+    {
+        $original = &$array;
+
+        $keys = (array) $keys;
+
+        if (0 === count($keys)) {
+            return;
+        }
+
+        foreach ($keys as $key) {
+            // if the exact key exists in the top-level, remove it
+            if (static::exists($array, $key)) {
+                unset($array[$key]);
+
+                continue;
+            }
+
+            $parts = explode('.', $key);
+
+            // clean up before each pass
+            $array = &$original;
+
+            while (count($parts) > 1) {
+                $part = array_shift($parts);
+
+                if (isset($array[$part]) && is_array($array[$part])) {
+                    $array = &$array[$part];
+                } else {
+                    continue 2;
+                }
+            }
+
+            unset($array[array_shift($parts)]);
+        }
+    }
+
+    /**
+     * Get an item from an array using "dot" notation.
+     *
+     * @param mixed $default
+     *
+     * @return mixed
+     */
+    public static function get(array $array, string $key, $default = null)
+    {
+        if (is_null($key)) {
+            return $array;
+        }
+
+        if (isset($array[$key])) {
+            return $array[$key];
+        }
+
+        foreach (explode('.', $key) as $segment) {
+            if (!is_array($array) || !array_key_exists($segment, $array)) {
+                return $default;
+            }
+            $array = $array[$segment];
+        }
+
+        return $array;
+    }
+
+    /**
+     * Get a subset of the items from the given array.
+     *
+     * @param array|string $keys
+     */
+    public static function only(array $array, $keys): array
+    {
+        return array_intersect_key($array, array_flip((array) $keys));
+    }
+
+    /**
+     * Pluck an array of values from an array.
+     *
+     * @param string $key
+     */
+    public static function pluck(array $array, string $value, string $key = null): array
+    {
+        $results = [];
+
+        foreach ($array as $item) {
+            $itemValue = is_object($item) ? $item->{$value} : $item[$value];
+            // If the key is "null", we will just append the value to the array and keep
+            // looping. Otherwise we will key the array using the value of the key we
+            // received from the developer. Then we'll return the final array form.
+            if (is_null($key)) {
+                $results[] = $itemValue;
+            } else {
+                $itemKey = is_object($item) ? $item->{$key} : $item[$key];
+                $results[$itemKey] = $itemValue;
+            }
+        }
+
+        return $results;
+    }
+
+    /**
+     * Push an item onto the beginning of an array.
+     *
+     * @param mixed $value
+     * @param mixed $key
+     *
+     * @return array
+     */
+    public static function prepend(array $array, $value, $key = null)
+    {
+        if (is_null($key)) {
+            array_unshift($array, $value);
+        } else {
+            $array = [$key => $value] + $array;
+        }
+
+        return $array;
+    }
+
+    /**
+     * Get a value from the array, and remove it.
+     *
+     * @param mixed $default
+     *
+     * @return mixed
+     */
+    public static function pull(array &$array, string $key, $default = null)
+    {
+        $value = static::get($array, $key, $default);
+
+        static::forget($array, $key);
+
+        return $value;
+    }
+
+    /**
+     * Get one or a specified number of random values from an array.
+     *
+     * @param array    $array
+     * @param int|null $number
+     *
+     * @return mixed
+     *
+     * @throws \InvalidArgumentException
+     */
+    public static function random(array $array, $number = null)
+    {
+        $requested = is_null($number) ? 1 : $number;
+
+        $count = count($array);
+
+        $number = $requested > $count ? $count : $requested;
+
+        if (is_null($number)) {
+            return $array[array_rand($array)];
+        }
+
+        if (0 === (int) $number) {
+            return [];
+        }
+
+        $keys = array_rand($array, $number);
+
+        $results = [];
+
+        foreach ((array) $keys as $key) {
+            $results[] = $array[$key];
+        }
+
+        return $results;
+    }
+
+    /**
+     * Set an array item to a given value using "dot" notation.
+     *
+     * If no key is given to the method, the entire array will be replaced.
+     *
+     * @param mixed $value
+     */
+    public static function set(array &$array, string $key, $value): array
+    {
+        if (is_null($key)) {
+            return $array = $value;
+        }
+
+        $keys = explode('.', $key);
+
+        while (count($keys) > 1) {
+            $key = array_shift($keys);
+            // If the key doesn't exist at this depth, we will just create an empty array
+            // to hold the next value, allowing us to create the arrays to hold final
+            // values at the correct depth. Then we'll keep digging into the array.
+            if (!isset($array[$key]) || !is_array($array[$key])) {
+                $array[$key] = [];
+            }
+            $array = &$array[$key];
+        }
+        $array[array_shift($keys)] = $value;
+
+        return $array;
+    }
+
+    /**
+     * Sort the array using the given Closure.
+     */
+    public static function sort(array $array, callable $callback): array
+    {
+        $results = [];
+
+        foreach ($array as $key => $value) {
+            $results[$key] = $callback($value);
+        }
+
+        return $results;
+    }
+
+    /**
+     * Shuffle the given array and return the result.
+     *
+     * @param array    $array
+     * @param int|null $seed
+     *
+     * @return array
+     */
+    public static function shuffle(array $array, $seed = null): array
+    {
+        if (is_null($seed)) {
+            shuffle($array);
+        } else {
+            mt_srand($seed);
+            shuffle($array);
+            mt_srand();
+        }
+
+        return $array;
+    }
+
+    /**
+     * Convert the array into a query string.
+     */
+    public static function query(array $array): string
+    {
+        return http_build_query($array, null, '&', PHP_QUERY_RFC3986);
+    }
+
+    /**
+     * Filter the array using the given callback.
+     */
+    public static function where(array $array, ?callable $callback = null): array
+    {
+        return array_filter($array, $callback ?? function ($value) use ($callback) {
+            if (static::accessible($value)) {
+                $value = static::where($value, $callback);
+            }
+
+            if (is_array($value) && 0 === count($value)) {
+                $value = null;
+            }
+
+            return '' !== $value && !is_null($value);
+        }, ARRAY_FILTER_USE_BOTH);
+    }
+
+    /**
+     * Convert encoding.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $from_encoding
+     */
+    public static function encoding(array $array, string $to_encoding, $from_encoding = 'gb2312'): array
+    {
+        $encoded = [];
+
+        foreach ($array as $key => $value) {
+            $encoded[$key] = is_array($value) ? self::encoding($value, $to_encoding, $from_encoding) :
+                                                mb_convert_encoding($value, $to_encoding, $from_encoding);
+        }
+
+        return $encoded;
+    }
+
+    /**
+     * camelCaseKey.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param mixed $data
+     *
+     * @return mixed
+     */
+    public static function camelCaseKey($data)
+    {
+        if (!self::accessible($data) &&
+            !(is_object($data) && method_exists($data, 'toArray'))) {
+            return $data;
+        }
+
+        $result = [];
+        $data = self::access($data);
+
+        foreach ($data as $key => $value) {
+            $result[is_string($key) ? Str::camel($key) : $key] = self::camelCaseKey($value);
+        }
+
+        return $result;
+    }
+
+    /**
+     * snakeCaseKey.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param mixed $data
+     *
+     * @return mixed
+     */
+    public static function snakeCaseKey($data)
+    {
+        if (!self::accessible($data) &&
+            !(is_object($data) && method_exists($data, 'toArray'))) {
+            return $data;
+        }
+
+        $data = self::access($data);
+        $result = [];
+
+        foreach ($data as $key => $value) {
+            $result[is_string($key) ? Str::snake($key) : $key] = self::snakeCaseKey($value);
+        }
+
+        return $result;
+    }
+}

+ 363 - 0
addons/epay/library/Yansongda/Supports/Collection.php

@@ -0,0 +1,363 @@
+<?php
+
+namespace Yansongda\Supports;
+
+use ArrayAccess;
+use ArrayIterator;
+use Countable;
+use IteratorAggregate;
+use JsonSerializable;
+use Serializable;
+
+class Collection implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable, Serializable
+{
+    /**
+     * The collection data.
+     *
+     * @var array
+     */
+    protected $items = [];
+
+    /**
+     * set data.
+     *
+     * @param mixed $items
+     */
+    public function __construct(array $items = [])
+    {
+        foreach ($items as $key => $value) {
+            $this->set($key, $value);
+        }
+    }
+
+    /**
+     * To string.
+     */
+    public function __toString(): string
+    {
+        return $this->toJson();
+    }
+
+    /**
+     * Get a data by key.
+     *
+     * @return mixed
+     */
+    public function __get(string $key)
+    {
+        return $this->get($key);
+    }
+
+    /**
+     * Assigns a value to the specified data.
+     *
+     * @param mixed $value
+     */
+    public function __set(string $key, $value)
+    {
+        $this->set($key, $value);
+    }
+
+    /**
+     * Whether or not an data exists by key.
+     */
+    public function __isset(string $key): bool
+    {
+        return $this->has($key);
+    }
+
+    /**
+     * Unsets an data by key.
+     */
+    public function __unset(string $key)
+    {
+        $this->forget($key);
+    }
+
+    /**
+     * Return all items.
+     */
+    public function all(): array
+    {
+        return $this->items;
+    }
+
+    /**
+     * Return specific items.
+     */
+    public function only(array $keys): array
+    {
+        $return = [];
+
+        foreach ($keys as $key) {
+            $value = $this->get($key);
+
+            if (!is_null($value)) {
+                $return[$key] = $value;
+            }
+        }
+
+        return $return;
+    }
+
+    /**
+     * Get all items except for those with the specified keys.
+     *
+     * @param mixed $keys
+     *
+     * @return static
+     */
+    public function except($keys)
+    {
+        $keys = is_array($keys) ? $keys : func_get_args();
+
+        return new static(Arr::except($this->items, $keys));
+    }
+
+    /**
+     * Merge data.
+     *
+     * @param Collection|array $items
+     */
+    public function merge($items): array
+    {
+        foreach ($items as $key => $value) {
+            $this->set($key, $value);
+        }
+
+        return $this->all();
+    }
+
+    /**
+     * To determine Whether the specified element exists.
+     */
+    public function has(string $key): bool
+    {
+        return !is_null(Arr::get($this->items, $key));
+    }
+
+    /**
+     * Retrieve the first item.
+     *
+     * @return mixed
+     */
+    public function first()
+    {
+        return reset($this->items);
+    }
+
+    /**
+     * Retrieve the last item.
+     *
+     * @return mixed
+     */
+    public function last()
+    {
+        $end = end($this->items);
+
+        reset($this->items);
+
+        return $end;
+    }
+
+    /**
+     * add the item value.
+     *
+     * @param mixed $value
+     */
+    public function add(string $key, $value)
+    {
+        Arr::set($this->items, $key, $value);
+    }
+
+    /**
+     * Set the item value.
+     *
+     * @param mixed $value
+     */
+    public function set(string $key, $value)
+    {
+        Arr::set($this->items, $key, $value);
+    }
+
+    /**
+     * Retrieve item from Collection.
+     *
+     * @param string $key
+     * @param mixed  $default
+     *
+     * @return mixed
+     */
+    public function get(?string $key = null, $default = null)
+    {
+        return Arr::get($this->items, $key, $default);
+    }
+
+    /**
+     * Remove item form Collection.
+     */
+    public function forget(string $key)
+    {
+        Arr::forget($this->items, $key);
+    }
+
+    /**
+     * Build to array.
+     */
+    public function toArray(): array
+    {
+        return $this->all();
+    }
+
+    /**
+     * Build to json.
+     */
+    public function toJson(int $option = JSON_UNESCAPED_UNICODE): string
+    {
+        return json_encode($this->all(), $option);
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.4.0)<br/>
+     * Specify data which should be serialized to JSON.
+     *
+     * @see http://php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed data which can be serialized by <b>json_encode</b>,
+     *               which is a value of any type other than a resource
+     */
+    public function jsonSerialize()
+    {
+        return $this->items;
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.1.0)<br/>
+     * String representation of object.
+     *
+     * @see http://php.net/manual/en/serializable.serialize.php
+     *
+     * @return string the string representation of the object or null
+     */
+    public function serialize()
+    {
+        return serialize($this->items);
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.0.0)<br/>
+     * Retrieve an external iterator.
+     *
+     * @see http://php.net/manual/en/iteratoraggregate.getiterator.php
+     *
+     * @return ArrayIterator An instance of an object implementing <b>Iterator</b> or
+     *                       <b>ArrayIterator</b>
+     */
+    public function getIterator()
+    {
+        return new ArrayIterator($this->items);
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.1.0)<br/>
+     * Count elements of an object.
+     *
+     * @see http://php.net/manual/en/countable.count.php
+     *
+     * @return int The custom count as an integer.
+     *             </p>
+     *             <p>
+     *             The return value is cast to an integer
+     */
+    public function count()
+    {
+        return count($this->items);
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.1.0)<br/>
+     * Constructs the object.
+     *
+     * @see  http://php.net/manual/en/serializable.unserialize.php
+     *
+     * @param string $serialized <p>
+     *                           The string representation of the object.
+     *                           </p>
+     *
+     * @return mixed|void
+     */
+    public function unserialize($serialized)
+    {
+        return $this->items = unserialize($serialized);
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.0.0)<br/>
+     * Whether a offset exists.
+     *
+     * @see http://php.net/manual/en/arrayaccess.offsetexists.php
+     *
+     * @param mixed $offset <p>
+     *                      An offset to check for.
+     *                      </p>
+     *
+     * @return bool true on success or false on failure.
+     *              The return value will be casted to boolean if non-boolean was returned
+     */
+    public function offsetExists($offset)
+    {
+        return $this->has($offset);
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.0.0)<br/>
+     * Offset to unset.
+     *
+     * @see http://php.net/manual/en/arrayaccess.offsetunset.php
+     *
+     * @param mixed $offset <p>
+     *                      The offset to unset.
+     *                      </p>
+     */
+    public function offsetUnset($offset)
+    {
+        if ($this->offsetExists($offset)) {
+            $this->forget($offset);
+        }
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.0.0)<br/>
+     * Offset to retrieve.
+     *
+     * @see http://php.net/manual/en/arrayaccess.offsetget.php
+     *
+     * @param mixed $offset <p>
+     *                      The offset to retrieve.
+     *                      </p>
+     *
+     * @return mixed Can return all value types
+     */
+    public function offsetGet($offset)
+    {
+        return $this->offsetExists($offset) ? $this->get($offset) : null;
+    }
+
+    /**
+     * (PHP 5 &gt;= 5.0.0)<br/>
+     * Offset to set.
+     *
+     * @see http://php.net/manual/en/arrayaccess.offsetset.php
+     *
+     * @param mixed $offset <p>
+     *                      The offset to assign the value to.
+     *                      </p>
+     * @param mixed $value  <p>
+     *                      The value to set.
+     *                      </p>
+     */
+    public function offsetSet($offset, $value)
+    {
+        $this->set($offset, $value);
+    }
+}

+ 7 - 0
addons/epay/library/Yansongda/Supports/Config.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Yansongda\Supports;
+
+class Config extends Collection
+{
+}

+ 20 - 0
addons/epay/library/Yansongda/Supports/LICENSE

@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2017 yansongda <me@yansongda.cn>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 91 - 0
addons/epay/library/Yansongda/Supports/Log.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace Yansongda\Supports;
+
+/**
+ * @method static void emergency($message, array $context = array())
+ * @method static void alert($message, array $context = array())
+ * @method static void critical($message, array $context = array())
+ * @method static void error($message, array $context = array())
+ * @method static void warning($message, array $context = array())
+ * @method static void notice($message, array $context = array())
+ * @method static void info($message, array $context = array())
+ * @method static void debug($message, array $context = array())
+ * @method static void log($message, array $context = array())
+ */
+class Log extends Logger
+{
+    /**
+     * instance.
+     *
+     * @var \Psr\Log\LoggerInterface
+     */
+    private static $instance;
+
+    /**
+     * Bootstrap.
+     */
+    private function __construct()
+    {
+    }
+
+    /**
+     * __call.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $args
+     *
+     * @throws \Exception
+     */
+    public function __call($method, $args): void
+    {
+        call_user_func_array([self::getInstance(), $method], $args);
+    }
+
+    /**
+     * __callStatic.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $args
+     *
+     * @throws \Exception
+     */
+    public static function __callStatic($method, $args): void
+    {
+        forward_static_call_array([self::getInstance(), $method], $args);
+    }
+
+    /**
+     * getInstance.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return \Yansongda\Supports\Logger
+     */
+    public static function getInstance(): Logger
+    {
+        if (is_null(self::$instance)) {
+            self::$instance = new Logger();
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * setInstance.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param \Yansongda\Supports\Logger $logger
+     *
+     * @throws \Exception
+     */
+    public static function setInstance(Logger $logger): void
+    {
+        self::$instance = $logger;
+    }
+}

+ 240 - 0
addons/epay/library/Yansongda/Supports/Logger.php

@@ -0,0 +1,240 @@
+<?php
+
+namespace Yansongda\Supports;
+
+use Exception;
+use Monolog\Formatter\FormatterInterface;
+use Monolog\Formatter\LineFormatter;
+use Monolog\Handler\AbstractHandler;
+use Monolog\Handler\RotatingFileHandler;
+use Monolog\Handler\StreamHandler;
+use Monolog\Logger as BaseLogger;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @method void emergency($message, array $context = array())
+ * @method void alert($message, array $context = array())
+ * @method void critical($message, array $context = array())
+ * @method void error($message, array $context = array())
+ * @method void warning($message, array $context = array())
+ * @method void notice($message, array $context = array())
+ * @method void info($message, array $context = array())
+ * @method void debug($message, array $context = array())
+ * @method void log($message, array $context = array())
+ */
+class Logger
+{
+    /**
+     * Logger instance.
+     *
+     * @var LoggerInterface
+     */
+    protected $logger;
+
+    /**
+     * formatter.
+     *
+     * @var \Monolog\Formatter\FormatterInterface
+     */
+    protected $formatter;
+
+    /**
+     * handler.
+     *
+     * @var AbstractHandler
+     */
+    protected $handler;
+
+    /**
+     * config.
+     *
+     * @var array
+     */
+    protected $config = [
+        'file' => null,
+        'identify' => 'yansongda.supports',
+        'level' => BaseLogger::DEBUG,
+        'type' => 'daily',
+        'max_files' => 30,
+    ];
+
+    /**
+     * Forward call.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $method
+     * @param array  $args
+     *
+     * @throws Exception
+     */
+    public function __call($method, $args): void
+    {
+        call_user_func_array([$this->getLogger(), $method], $args);
+    }
+
+    /**
+     * Set logger.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function setLogger(LoggerInterface $logger): Logger
+    {
+        $this->logger = $logger;
+
+        return $this;
+    }
+
+    /**
+     * Return the logger instance.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws Exception
+     */
+    public function getLogger(): LoggerInterface
+    {
+        if (is_null($this->logger)) {
+            $this->logger = $this->createLogger();
+        }
+
+        return $this->logger;
+    }
+
+    /**
+     * Make a default log instance.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws Exception
+     */
+    public function createLogger(): BaseLogger
+    {
+        $handler = $this->getHandler();
+
+        $handler->setFormatter($this->getFormatter());
+
+        $logger = new BaseLogger($this->config['identify']);
+
+        $logger->pushHandler($handler);
+
+        return $logger;
+    }
+
+    /**
+     * setFormatter.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return $this
+     */
+    public function setFormatter(FormatterInterface $formatter): self
+    {
+        $this->formatter = $formatter;
+
+        return $this;
+    }
+
+    /**
+     * getFormatter.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function getFormatter(): FormatterInterface
+    {
+        if (is_null($this->formatter)) {
+            $this->formatter = $this->createFormatter();
+        }
+
+        return $this->formatter;
+    }
+
+    /**
+     * createFormatter.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function createFormatter(): LineFormatter
+    {
+        return new LineFormatter(
+            "%datetime% > %channel%.%level_name% > %message% %context% %extra%\n\n",
+            null,
+            false,
+            true
+        );
+    }
+
+    /**
+     * setHandler.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return $this
+     */
+    public function setHandler(AbstractHandler $handler): self
+    {
+        $this->handler = $handler;
+
+        return $this;
+    }
+
+    /**
+     * getHandler.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws \Exception
+     */
+    public function getHandler(): AbstractHandler
+    {
+        if (is_null($this->handler)) {
+            $this->handler = $this->createHandler();
+        }
+
+        return $this->handler;
+    }
+
+    /**
+     * createHandler.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws \Exception
+     *
+     * @return \Monolog\Handler\RotatingFileHandler|\Monolog\Handler\StreamHandler
+     */
+    public function createHandler(): AbstractHandler
+    {
+        $file = $this->config['file'] ?? sys_get_temp_dir().'/logs/'.$this->config['identify'].'.log';
+
+        if ('single' === $this->config['type']) {
+            return new StreamHandler($file, $this->config['level']);
+        }
+
+        return new RotatingFileHandler($file, $this->config['max_files'], $this->config['level']);
+    }
+
+    /**
+     * setConfig.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return $this
+     */
+    public function setConfig(array $config): self
+    {
+        $this->config = array_merge($this->config, $config);
+
+        return $this;
+    }
+
+    /**
+     * getConfig.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function getConfig(): array
+    {
+        return $this->config;
+    }
+}

+ 36 - 0
addons/epay/library/Yansongda/Supports/Logger/StdoutHandler.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace Yansongda\Supports\Logger;
+
+use Monolog\Handler\AbstractProcessingHandler;
+use Monolog\Logger;
+use Symfony\Component\Console\Output\ConsoleOutput;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class StdoutHandler extends AbstractProcessingHandler
+{
+    /**
+     * @var OutputInterface
+     */
+    private $output;
+
+    /**
+     * Bootstrap.
+     *
+     * @param int  $level
+     * @param bool $bubble
+     */
+    public function __construct($level = Logger::DEBUG, $bubble = true, ?OutputInterface $output = null)
+    {
+        $this->output = $output ?? new ConsoleOutput();
+        parent::__construct($level, $bubble);
+    }
+
+    /**
+     * Writes the record down to the log of the implementing handler.
+     */
+    protected function write(array $record): void
+    {
+        $this->output->writeln($record['formatted']);
+    }
+}

+ 570 - 0
addons/epay/library/Yansongda/Supports/Str.php

@@ -0,0 +1,570 @@
+<?php
+
+namespace Yansongda\Supports;
+
+use Exception;
+
+/**
+ * modify from Illuminate\Support.
+ */
+class Str
+{
+    /**
+     * The cache of snake-cased words.
+     *
+     * @var array
+     */
+    protected static $snakeCache = [];
+
+    /**
+     * The cache of camel-cased words.
+     *
+     * @var array
+     */
+    protected static $camelCache = [];
+
+    /**
+     * The cache of studly-cased words.
+     *
+     * @var array
+     */
+    protected static $studlyCache = [];
+
+    /**
+     * Return the remainder of a string after a given value.
+     */
+    public static function after(string $subject, string $search): string
+    {
+        return '' === $search ? $subject : array_reverse(explode($search, $subject, 2))[0];
+    }
+
+    /**
+     * Transliterate a UTF-8 value to ASCII.
+     */
+    public static function ascii(string $value, string $language = 'en'): string
+    {
+        $languageSpecific = static::languageSpecificCharsArray($language);
+
+        if (!is_null($languageSpecific)) {
+            $value = str_replace($languageSpecific[0], $languageSpecific[1], $value);
+        }
+
+        foreach (static::charsArray() as $key => $val) {
+            $value = str_replace($val, $key, $value);
+        }
+
+        return preg_replace('/[^\x20-\x7E]/u', '', $value);
+    }
+
+    /**
+     * Get the portion of a string before a given value.
+     */
+    public static function before(string $subject, string $search): string
+    {
+        return '' === $search ? $subject : explode($search, $subject)[0];
+    }
+
+    /**
+     * Convert a value to camel case.
+     */
+    public static function camel(string $value): string
+    {
+        if (isset(static::$camelCache[$value])) {
+            return static::$camelCache[$value];
+        }
+
+        return static::$camelCache[$value] = lcfirst(static::studly($value));
+    }
+
+    /**
+     * Determine if a given string contains a given substring.
+     *
+     * @param string|array $needles
+     */
+    public static function contains(string $haystack, $needles): bool
+    {
+        foreach ((array) $needles as $needle) {
+            if ('' !== $needle && false !== mb_strpos($haystack, $needle)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Determine if a given string ends with a given substring.
+     *
+     * @param string|array $needles
+     */
+    public static function endsWith(string $haystack, $needles): bool
+    {
+        foreach ((array) $needles as $needle) {
+            if (substr($haystack, -strlen($needle)) === (string) $needle) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Cap a string with a single instance of a given value.
+     */
+    public static function finish(string $value, string $cap): string
+    {
+        $quoted = preg_quote($cap, '/');
+
+        return preg_replace('/(?:'.$quoted.')+$/u', '', $value).$cap;
+    }
+
+    /**
+     * Determine if a given string matches a given pattern.
+     *
+     * @param string|array $pattern
+     */
+    public static function is($pattern, string $value): bool
+    {
+        $patterns = is_array($pattern) ? $pattern : (array) $pattern;
+
+        if (empty($patterns)) {
+            return false;
+        }
+
+        foreach ($patterns as $pattern) {
+            // If the given value is an exact match we can of course return true right
+            // from the beginning. Otherwise, we will translate asterisks and do an
+            // actual pattern match against the two strings to see if they match.
+            if ($pattern == $value) {
+                return true;
+            }
+
+            $pattern = preg_quote($pattern, '#');
+
+            // Asterisks are translated into zero-or-more regular expression wildcards
+            // to make it convenient to check if the strings starts with the given
+            // pattern such as "library/*", making any string check convenient.
+            $pattern = str_replace('\*', '.*', $pattern);
+
+            if (1 === preg_match('#^'.$pattern.'\z#u', $value)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Convert a string to kebab case.
+     */
+    public static function kebab(string $value): string
+    {
+        return static::snake($value, '-');
+    }
+
+    /**
+     * Return the length of the given string.
+     *
+     * @param string $encoding
+     */
+    public static function length(string $value, ?string $encoding = null): int
+    {
+        if (null !== $encoding) {
+            return mb_strlen($value, $encoding);
+        }
+
+        return mb_strlen($value);
+    }
+
+    /**
+     * Limit the number of characters in a string.
+     */
+    public static function limit(string $value, int $limit = 100, string $end = '...'): string
+    {
+        if (mb_strwidth($value, 'UTF-8') <= $limit) {
+            return $value;
+        }
+
+        return rtrim(mb_strimwidth($value, 0, $limit, '', 'UTF-8')).$end;
+    }
+
+    /**
+     * Convert the given string to lower-case.
+     */
+    public static function lower(string $value): string
+    {
+        return mb_strtolower($value, 'UTF-8');
+    }
+
+    /**
+     * Limit the number of words in a string.
+     */
+    public static function words(string $value, int $words = 100, string $end = '...'): string
+    {
+        preg_match('/^\s*+(?:\S++\s*+){1,'.$words.'}/u', $value, $matches);
+
+        if (!isset($matches[0]) || static::length($value) === static::length($matches[0])) {
+            return $value;
+        }
+
+        return rtrim($matches[0]).$end;
+    }
+
+    /**
+     * Parse a Class.
+     */
+    public static function parseCallback(string $callback, ?string $default = null): array
+    {
+        return static::contains($callback, '@') ? explode('@', $callback, 2) : [$callback, $default];
+    }
+
+    /**
+     * Generate a more truly "random" alpha-numeric string.
+     *
+     * @throws Exception
+     */
+    public static function random(int $length = 16): string
+    {
+        $string = '';
+
+        while (($len = strlen($string)) < $length) {
+            $size = $length - $len;
+
+            $bytes = function_exists('random_bytes') ? random_bytes($size) : mt_rand();
+
+            $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
+        }
+
+        return $string;
+    }
+
+    /**
+     * Replace a given value in the string sequentially with an array.
+     */
+    public static function replaceArray(string $search, array $replace, string $subject): string
+    {
+        foreach ($replace as $value) {
+            $subject = static::replaceFirst($search, $value, $subject);
+        }
+
+        return $subject;
+    }
+
+    /**
+     * Replace the first occurrence of a given value in the string.
+     */
+    public static function replaceFirst(string $search, string $replace, string $subject): string
+    {
+        if ('' == $search) {
+            return $subject;
+        }
+
+        $position = strpos($subject, $search);
+
+        if (false !== $position) {
+            return substr_replace($subject, $replace, $position, strlen($search));
+        }
+
+        return $subject;
+    }
+
+    /**
+     * Replace the last occurrence of a given value in the string.
+     */
+    public static function replaceLast(string $search, string $replace, string $subject): string
+    {
+        $position = strrpos($subject, $search);
+
+        if (false !== $position) {
+            return substr_replace($subject, $replace, $position, strlen($search));
+        }
+
+        return $subject;
+    }
+
+    /**
+     * Begin a string with a single instance of a given value.
+     */
+    public static function start(string $value, string $prefix): string
+    {
+        $quoted = preg_quote($prefix, '/');
+
+        return $prefix.preg_replace('/^(?:'.$quoted.')+/u', '', $value);
+    }
+
+    /**
+     * Convert the given string to upper-case.
+     */
+    public static function upper(string $value): string
+    {
+        return mb_strtoupper($value, 'UTF-8');
+    }
+
+    /**
+     * Convert the given string to title case.
+     */
+    public static function title(string $value): string
+    {
+        return mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
+    }
+
+    /**
+     * Generate a URL friendly "slug" from a given string.
+     */
+    public static function slug(string $title, string $separator = '-', string $language = 'en'): string
+    {
+        $title = static::ascii($title, $language);
+
+        // Convert all dashes/underscores into separator
+        $flip = '-' == $separator ? '_' : '-';
+
+        $title = preg_replace('!['.preg_quote($flip).']+!u', $separator, $title);
+
+        // Replace @ with the word 'at'
+        $title = str_replace('@', $separator.'at'.$separator, $title);
+
+        // Remove all characters that are not the separator, letters, numbers, or whitespace.
+        $title = preg_replace('![^'.preg_quote($separator).'\pL\pN\s]+!u', '', mb_strtolower($title));
+
+        // Replace all separator characters and whitespace by a single separator
+        $title = preg_replace('!['.preg_quote($separator).'\s]+!u', $separator, $title);
+
+        return trim($title, $separator);
+    }
+
+    /**
+     * Convert a string to snake case.
+     */
+    public static function snake(string $value, string $delimiter = '_'): string
+    {
+        $key = $value;
+
+        if (isset(static::$snakeCache[$key][$delimiter])) {
+            return static::$snakeCache[$key][$delimiter];
+        }
+
+        if (!ctype_lower($value)) {
+            $value = preg_replace('/\s+/u', '', ucwords($value));
+
+            $value = static::lower(preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $value));
+        }
+
+        return static::$snakeCache[$key][$delimiter] = $value;
+    }
+
+    /**
+     * Determine if a given string starts with a given substring.
+     *
+     * @param string|array $needles
+     */
+    public static function startsWith(string $haystack, $needles): bool
+    {
+        foreach ((array) $needles as $needle) {
+            if ('' !== $needle && substr($haystack, 0, strlen($needle)) === (string) $needle) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Convert a value to studly caps case.
+     */
+    public static function studly(string $value): string
+    {
+        $key = $value;
+
+        if (isset(static::$studlyCache[$key])) {
+            return static::$studlyCache[$key];
+        }
+
+        $value = ucwords(str_replace(['-', '_'], ' ', $value));
+
+        return static::$studlyCache[$key] = str_replace(' ', '', $value);
+    }
+
+    /**
+     * Returns the portion of string specified by the start and length parameters.
+     */
+    public static function substr(string $string, int $start, ?int $length = null): string
+    {
+        return mb_substr($string, $start, $length, 'UTF-8');
+    }
+
+    /**
+     * Make a string's first character uppercase.
+     */
+    public static function ucfirst(string $string): string
+    {
+        return static::upper(static::substr($string, 0, 1)).static::substr($string, 1);
+    }
+
+    /**
+     * Convert string's encoding.
+     *
+     * @author yansongda <me@yansonga.cn>
+     */
+    public static function encoding(string $string, string $to = 'utf-8', string $from = 'gb2312'): string
+    {
+        return mb_convert_encoding($string, $to, $from);
+    }
+
+    /**
+     * Returns the replacements for the ascii method.
+     *
+     * Note: Adapted from Stringy\Stringy.
+     *
+     * @see https://github.com/danielstjules/Stringy/blob/3.1.0/LICENSE.txt
+     */
+    protected static function charsArray(): array
+    {
+        static $charsArray;
+
+        if (isset($charsArray)) {
+            return $charsArray;
+        }
+
+        return $charsArray = [
+            '0' => ['°', '₀', '۰', '0'],
+            '1' => ['¹', '₁', '۱', '1'],
+            '2' => ['²', '₂', '۲', '2'],
+            '3' => ['³', '₃', '۳', '3'],
+            '4' => ['⁴', '₄', '۴', '٤', '4'],
+            '5' => ['⁵', '₅', '۵', '٥', '5'],
+            '6' => ['⁶', '₆', '۶', '٦', '6'],
+            '7' => ['⁷', '₇', '۷', '7'],
+            '8' => ['⁸', '₈', '۸', '8'],
+            '9' => ['⁹', '₉', '۹', '9'],
+            'a' => ['à', 'á', 'ả', 'ã', 'ạ', 'ă', 'ắ', 'ằ', 'ẳ', 'ẵ', 'ặ', 'â', 'ấ', 'ầ', 'ẩ', 'ẫ', 'ậ', 'ā', 'ą', 'å', 'α', 'ά', 'ἀ', 'ἁ', 'ἂ', 'ἃ', 'ἄ', 'ἅ', 'ἆ', 'ἇ', 'ᾀ', 'ᾁ', 'ᾂ', 'ᾃ', 'ᾄ', 'ᾅ', 'ᾆ', 'ᾇ', 'ὰ', 'ά', 'ᾰ', 'ᾱ', 'ᾲ', 'ᾳ', 'ᾴ', 'ᾶ', 'ᾷ', 'а', 'أ', 'အ', 'ာ', 'ါ', 'ǻ', 'ǎ', 'ª', 'ა', 'अ', 'ا', 'a', 'ä'],
+            'b' => ['б', 'β', 'ب', 'ဗ', 'ბ', 'b'],
+            'c' => ['ç', 'ć', 'č', 'ĉ', 'ċ', 'c'],
+            'd' => ['ď', 'ð', 'đ', 'ƌ', 'ȡ', 'ɖ', 'ɗ', 'ᵭ', 'ᶁ', 'ᶑ', 'д', 'δ', 'د', 'ض', 'ဍ', 'ဒ', 'დ', 'd'],
+            'e' => ['é', 'è', 'ẻ', 'ẽ', 'ẹ', 'ê', 'ế', 'ề', 'ể', 'ễ', 'ệ', 'ë', 'ē', 'ę', 'ě', 'ĕ', 'ė', 'ε', 'έ', 'ἐ', 'ἑ', 'ἒ', 'ἓ', 'ἔ', 'ἕ', 'ὲ', 'έ', 'е', 'ё', 'э', 'є', 'ə', 'ဧ', 'ေ', 'ဲ', 'ე', 'ए', 'إ', 'ئ', 'e'],
+            'f' => ['ф', 'φ', 'ف', 'ƒ', 'ფ', 'f'],
+            'g' => ['ĝ', 'ğ', 'ġ', 'ģ', 'г', 'ґ', 'γ', 'ဂ', 'გ', 'گ', 'g'],
+            'h' => ['ĥ', 'ħ', 'η', 'ή', 'ح', 'ه', 'ဟ', 'ှ', 'ჰ', 'h'],
+            'i' => ['í', 'ì', 'ỉ', 'ĩ', 'ị', 'î', 'ï', 'ī', 'ĭ', 'į', 'ı', 'ι', 'ί', 'ϊ', 'ΐ', 'ἰ', 'ἱ', 'ἲ', 'ἳ', 'ἴ', 'ἵ', 'ἶ', 'ἷ', 'ὶ', 'ί', 'ῐ', 'ῑ', 'ῒ', 'ΐ', 'ῖ', 'ῗ', 'і', 'ї', 'и', 'ဣ', 'ိ', 'ီ', 'ည်', 'ǐ', 'ი', 'इ', 'ی', 'i'],
+            'j' => ['ĵ', 'ј', 'Ј', 'ჯ', 'ج', 'j'],
+            'k' => ['ķ', 'ĸ', 'к', 'κ', 'Ķ', 'ق', 'ك', 'က', 'კ', 'ქ', 'ک', 'k'],
+            'l' => ['ł', 'ľ', 'ĺ', 'ļ', 'ŀ', 'л', 'λ', 'ل', 'လ', 'ლ', 'l'],
+            'm' => ['м', 'μ', 'م', 'မ', 'მ', 'm'],
+            'n' => ['ñ', 'ń', 'ň', 'ņ', 'ʼn', 'ŋ', 'ν', 'н', 'ن', 'န', 'ნ', 'n'],
+            'o' => ['ó', 'ò', 'ỏ', 'õ', 'ọ', 'ô', 'ố', 'ồ', 'ổ', 'ỗ', 'ộ', 'ơ', 'ớ', 'ờ', 'ở', 'ỡ', 'ợ', 'ø', 'ō', 'ő', 'ŏ', 'ο', 'ὀ', 'ὁ', 'ὂ', 'ὃ', 'ὄ', 'ὅ', 'ὸ', 'ό', 'о', 'و', 'θ', 'ို', 'ǒ', 'ǿ', 'º', 'ო', 'ओ', 'o', 'ö'],
+            'p' => ['п', 'π', 'ပ', 'პ', 'پ', 'p'],
+            'q' => ['ყ', 'q'],
+            'r' => ['ŕ', 'ř', 'ŗ', 'р', 'ρ', 'ر', 'რ', 'r'],
+            's' => ['ś', 'š', 'ş', 'с', 'σ', 'ș', 'ς', 'س', 'ص', 'စ', 'ſ', 'ს', 's'],
+            't' => ['ť', 'ţ', 'т', 'τ', 'ț', 'ت', 'ط', 'ဋ', 'တ', 'ŧ', 'თ', 'ტ', 't'],
+            'u' => ['ú', 'ù', 'ủ', 'ũ', 'ụ', 'ư', 'ứ', 'ừ', 'ử', 'ữ', 'ự', 'û', 'ū', 'ů', 'ű', 'ŭ', 'ų', 'µ', 'у', 'ဉ', 'ု', 'ူ', 'ǔ', 'ǖ', 'ǘ', 'ǚ', 'ǜ', 'უ', 'उ', 'u', 'ў', 'ü'],
+            'v' => ['в', 'ვ', 'ϐ', 'v'],
+            'w' => ['ŵ', 'ω', 'ώ', 'ဝ', 'ွ', 'w'],
+            'x' => ['χ', 'ξ', 'x'],
+            'y' => ['ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ', 'ÿ', 'ŷ', 'й', 'ы', 'υ', 'ϋ', 'ύ', 'ΰ', 'ي', 'ယ', 'y'],
+            'z' => ['ź', 'ž', 'ż', 'з', 'ζ', 'ز', 'ဇ', 'ზ', 'z'],
+            'aa' => ['ع', 'आ', 'آ'],
+            'ae' => ['æ', 'ǽ'],
+            'ai' => ['ऐ'],
+            'ch' => ['ч', 'ჩ', 'ჭ', 'چ'],
+            'dj' => ['ђ', 'đ'],
+            'dz' => ['џ', 'ძ'],
+            'ei' => ['ऍ'],
+            'gh' => ['غ', 'ღ'],
+            'ii' => ['ई'],
+            'ij' => ['ij'],
+            'kh' => ['х', 'خ', 'ხ'],
+            'lj' => ['љ'],
+            'nj' => ['њ'],
+            'oe' => ['ö', 'œ', 'ؤ'],
+            'oi' => ['ऑ'],
+            'oii' => ['ऒ'],
+            'ps' => ['ψ'],
+            'sh' => ['ш', 'შ', 'ش'],
+            'shch' => ['щ'],
+            'ss' => ['ß'],
+            'sx' => ['ŝ'],
+            'th' => ['þ', 'ϑ', 'ث', 'ذ', 'ظ'],
+            'ts' => ['ц', 'ც', 'წ'],
+            'ue' => ['ü'],
+            'uu' => ['ऊ'],
+            'ya' => ['я'],
+            'yu' => ['ю'],
+            'zh' => ['ж', 'ჟ', 'ژ'],
+            '(c)' => ['©'],
+            'A' => ['Á', 'À', 'Ả', 'Ã', 'Ạ', 'Ă', 'Ắ', 'Ằ', 'Ẳ', 'Ẵ', 'Ặ', 'Â', 'Ấ', 'Ầ', 'Ẩ', 'Ẫ', 'Ậ', 'Å', 'Ā', 'Ą', 'Α', 'Ά', 'Ἀ', 'Ἁ', 'Ἂ', 'Ἃ', 'Ἄ', 'Ἅ', 'Ἆ', 'Ἇ', 'ᾈ', 'ᾉ', 'ᾊ', 'ᾋ', 'ᾌ', 'ᾍ', 'ᾎ', 'ᾏ', 'Ᾰ', 'Ᾱ', 'Ὰ', 'Ά', 'ᾼ', 'А', 'Ǻ', 'Ǎ', 'A', 'Ä'],
+            'B' => ['Б', 'Β', 'ब', 'B'],
+            'C' => ['Ç', 'Ć', 'Č', 'Ĉ', 'Ċ', 'C'],
+            'D' => ['Ď', 'Ð', 'Đ', 'Ɖ', 'Ɗ', 'Ƌ', 'ᴅ', 'ᴆ', 'Д', 'Δ', 'D'],
+            'E' => ['É', 'È', 'Ẻ', 'Ẽ', 'Ẹ', 'Ê', 'Ế', 'Ề', 'Ể', 'Ễ', 'Ệ', 'Ë', 'Ē', 'Ę', 'Ě', 'Ĕ', 'Ė', 'Ε', 'Έ', 'Ἐ', 'Ἑ', 'Ἒ', 'Ἓ', 'Ἔ', 'Ἕ', 'Έ', 'Ὲ', 'Е', 'Ё', 'Э', 'Є', 'Ə', 'E'],
+            'F' => ['Ф', 'Φ', 'F'],
+            'G' => ['Ğ', 'Ġ', 'Ģ', 'Г', 'Ґ', 'Γ', 'G'],
+            'H' => ['Η', 'Ή', 'Ħ', 'H'],
+            'I' => ['Í', 'Ì', 'Ỉ', 'Ĩ', 'Ị', 'Î', 'Ï', 'Ī', 'Ĭ', 'Į', 'İ', 'Ι', 'Ί', 'Ϊ', 'Ἰ', 'Ἱ', 'Ἳ', 'Ἴ', 'Ἵ', 'Ἶ', 'Ἷ', 'Ῐ', 'Ῑ', 'Ὶ', 'Ί', 'И', 'І', 'Ї', 'Ǐ', 'ϒ', 'I'],
+            'J' => ['J'],
+            'K' => ['К', 'Κ', 'K'],
+            'L' => ['Ĺ', 'Ł', 'Л', 'Λ', 'Ļ', 'Ľ', 'Ŀ', 'ल', 'L'],
+            'M' => ['М', 'Μ', 'M'],
+            'N' => ['Ń', 'Ñ', 'Ň', 'Ņ', 'Ŋ', 'Н', 'Ν', 'N'],
+            'O' => ['Ó', 'Ò', 'Ỏ', 'Õ', 'Ọ', 'Ô', 'Ố', 'Ồ', 'Ổ', 'Ỗ', 'Ộ', 'Ơ', 'Ớ', 'Ờ', 'Ở', 'Ỡ', 'Ợ', 'Ø', 'Ō', 'Ő', 'Ŏ', 'Ο', 'Ό', 'Ὀ', 'Ὁ', 'Ὂ', 'Ὃ', 'Ὄ', 'Ὅ', 'Ὸ', 'Ό', 'О', 'Θ', 'Ө', 'Ǒ', 'Ǿ', 'O', 'Ö'],
+            'P' => ['П', 'Π', 'P'],
+            'Q' => ['Q'],
+            'R' => ['Ř', 'Ŕ', 'Р', 'Ρ', 'Ŗ', 'R'],
+            'S' => ['Ş', 'Ŝ', 'Ș', 'Š', 'Ś', 'С', 'Σ', 'S'],
+            'T' => ['Ť', 'Ţ', 'Ŧ', 'Ț', 'Т', 'Τ', 'T'],
+            'U' => ['Ú', 'Ù', 'Ủ', 'Ũ', 'Ụ', 'Ư', 'Ứ', 'Ừ', 'Ử', 'Ữ', 'Ự', 'Û', 'Ū', 'Ů', 'Ű', 'Ŭ', 'Ų', 'У', 'Ǔ', 'Ǖ', 'Ǘ', 'Ǚ', 'Ǜ', 'U', 'Ў', 'Ü'],
+            'V' => ['В', 'V'],
+            'W' => ['Ω', 'Ώ', 'Ŵ', 'W'],
+            'X' => ['Χ', 'Ξ', 'X'],
+            'Y' => ['Ý', 'Ỳ', 'Ỷ', 'Ỹ', 'Ỵ', 'Ÿ', 'Ῠ', 'Ῡ', 'Ὺ', 'Ύ', 'Ы', 'Й', 'Υ', 'Ϋ', 'Ŷ', 'Y'],
+            'Z' => ['Ź', 'Ž', 'Ż', 'З', 'Ζ', 'Z'],
+            'AE' => ['Æ', 'Ǽ'],
+            'Ch' => ['Ч'],
+            'Dj' => ['Ђ'],
+            'Dz' => ['Џ'],
+            'Gx' => ['Ĝ'],
+            'Hx' => ['Ĥ'],
+            'Ij' => ['IJ'],
+            'Jx' => ['Ĵ'],
+            'Kh' => ['Х'],
+            'Lj' => ['Љ'],
+            'Nj' => ['Њ'],
+            'Oe' => ['Œ'],
+            'Ps' => ['Ψ'],
+            'Sh' => ['Ш'],
+            'Shch' => ['Щ'],
+            'Ss' => ['ẞ'],
+            'Th' => ['Þ'],
+            'Ts' => ['Ц'],
+            'Ya' => ['Я'],
+            'Yu' => ['Ю'],
+            'Zh' => ['Ж'],
+            ' ' => ["\xC2\xA0", "\xE2\x80\x80", "\xE2\x80\x81", "\xE2\x80\x82", "\xE2\x80\x83", "\xE2\x80\x84", "\xE2\x80\x85", "\xE2\x80\x86", "\xE2\x80\x87", "\xE2\x80\x88", "\xE2\x80\x89", "\xE2\x80\x8A", "\xE2\x80\xAF", "\xE2\x81\x9F", "\xE3\x80\x80", "\xEF\xBE\xA0"],
+        ];
+    }
+
+    /**
+     * Returns the language specific replacements for the ascii method.
+     *
+     * Note: Adapted from Stringy\Stringy.
+     *
+     * @see https://github.com/danielstjules/Stringy/blob/3.1.0/LICENSE.txt
+     */
+    protected static function languageSpecificCharsArray(string $language): ?array
+    {
+        static $languageSpecific;
+        if (!isset($languageSpecific)) {
+            $languageSpecific = [
+                'bg' => [
+                    ['х', 'Х', 'щ', 'Щ', 'ъ', 'Ъ', 'ь', 'Ь'],
+                    ['h', 'H', 'sht', 'SHT', 'a', 'А', 'y', 'Y'],
+                ],
+                'de' => [
+                    ['ä',  'ö',  'ü',  'Ä',  'Ö',  'Ü'],
+                    ['ae', 'oe', 'ue', 'AE', 'OE', 'UE'],
+                ],
+            ];
+        }
+
+        return isset($languageSpecific[$language]) ? $languageSpecific[$language] : null;
+    }
+}

+ 142 - 0
addons/epay/library/Yansongda/Supports/Traits/Accessable.php

@@ -0,0 +1,142 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Yansongda\Supports\Traits;
+
+trait Accessable
+{
+    /**
+     * __get.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return mixed
+     */
+    public function __get(string $key)
+    {
+        return $this->get($key);
+    }
+
+    /**
+     * __set.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param mixed $value
+     *
+     * @return mixed
+     */
+    public function __set(string $key, $value)
+    {
+        return $this->set($key, $value);
+    }
+
+    /**
+     * get.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param mixed $default
+     *
+     * @return mixed
+     */
+    public function get(?string $key = null, $default = null)
+    {
+        if (is_null($key)) {
+            return method_exists($this, 'toArray') ? $this->toArray() : $default;
+        }
+
+        $method = 'get';
+        foreach (explode('_', $key) as $item) {
+            $method .= ucfirst($item);
+        }
+
+        if (method_exists($this, $method)) {
+            return $this->{$method}();
+        }
+
+        return $default;
+    }
+
+    /**
+     * set.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param mixed $value
+     *
+     * @return $this
+     */
+    public function set(string $key, $value)
+    {
+        $method = 'set';
+        foreach (explode('_', $key) as $item) {
+            $method .= ucfirst($item);
+        }
+
+        if (method_exists($this, $method)) {
+            return $this->{$method}($value);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Whether a offset exists.
+     *
+     * @see https://php.net/manual/en/arrayaccess.offsetexists.php
+     *
+     * @param mixed $offset an offset to check for
+     *
+     * @return bool true on success or false on failure.
+     *
+     * The return value will be casted to boolean if non-boolean was returned.
+     */
+    public function offsetExists($offset)
+    {
+        return !is_null($this->get($offset));
+    }
+
+    /**
+     * Offset to retrieve.
+     *
+     * @see https://php.net/manual/en/arrayaccess.offsetget.php
+     *
+     * @param mixed $offset the offset to retrieve
+     *
+     * @return mixed can return all value types
+     */
+    public function offsetGet($offset)
+    {
+        return $this->get($offset);
+    }
+
+    /**
+     * Offset to set.
+     *
+     * @see https://php.net/manual/en/arrayaccess.offsetset.php
+     *
+     * @param mixed $offset the offset to assign the value to
+     * @param mixed $value  the value to set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value)
+    {
+        $this->set($offset, $value);
+    }
+
+    /**
+     * Offset to unset.
+     *
+     * @see https://php.net/manual/en/arrayaccess.offsetunset.php
+     *
+     * @param mixed $offset the offset to unset
+     *
+     * @return void
+     */
+    public function offsetUnset($offset)
+    {
+    }
+}

+ 32 - 0
addons/epay/library/Yansongda/Supports/Traits/Arrayable.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Yansongda\Supports\Traits;
+
+use ReflectionClass;
+use Yansongda\Supports\Str;
+
+trait Arrayable
+{
+    /**
+     * toArray.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @throws \ReflectionException
+     */
+    public function toArray(): array
+    {
+        $result = [];
+
+        foreach ((new ReflectionClass($this))->getProperties() as $item) {
+            $k = $item->getName();
+            $method = 'get'.Str::studly($k);
+
+            $result[Str::snake($k)] = method_exists($this, $method) ? $this->{$method}() : $this->{$k};
+        }
+
+        return $result;
+    }
+}

+ 229 - 0
addons/epay/library/Yansongda/Supports/Traits/HasHttpRequest.php

@@ -0,0 +1,229 @@
+<?php
+
+namespace Yansongda\Supports\Traits;
+
+use GuzzleHttp\Client;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Trait HasHttpRequest.
+ *
+ * @property string $baseUri
+ * @property float  $timeout
+ * @property float  $connectTimeout
+ */
+trait HasHttpRequest
+{
+    /**
+     * Http client.
+     *
+     * @var Client|null
+     */
+    protected $httpClient = null;
+
+    /**
+     * Http client options.
+     *
+     * @var array
+     */
+    protected $httpOptions = [];
+
+    /**
+     * Send a GET request.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return array|string
+     */
+    public function get(string $endpoint, array $query = [], array $headers = [])
+    {
+        return $this->request('get', $endpoint, [
+            'headers' => $headers,
+            'query' => $query,
+        ]);
+    }
+
+    /**
+     * Send a POST request.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|array $data
+     *
+     * @return array|string
+     */
+    public function post(string $endpoint, $data, array $options = [])
+    {
+        if (!is_array($data)) {
+            $options['body'] = $data;
+        } else {
+            $options['form_params'] = $data;
+        }
+
+        return $this->request('post', $endpoint, $options);
+    }
+
+    /**
+     * Send request.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return array|string
+     */
+    public function request(string $method, string $endpoint, array $options = [])
+    {
+        return $this->unwrapResponse($this->getHttpClient()->{$method}($endpoint, $options));
+    }
+
+    /**
+     * Set http client.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return $this
+     */
+    public function setHttpClient(Client $client): self
+    {
+        $this->httpClient = $client;
+
+        return $this;
+    }
+
+    /**
+     * Return http client.
+     */
+    public function getHttpClient(): Client
+    {
+        if (is_null($this->httpClient)) {
+            $this->httpClient = $this->getDefaultHttpClient();
+        }
+
+        return $this->httpClient;
+    }
+
+    /**
+     * Get default http client.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function getDefaultHttpClient(): Client
+    {
+        return new Client($this->getOptions());
+    }
+
+    /**
+     * setBaseUri.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return $this
+     */
+    public function setBaseUri(string $url): self
+    {
+        if (property_exists($this, 'baseUri')) {
+            $parsedUrl = parse_url($url);
+
+            $this->baseUri = ($parsedUrl['scheme'] ?? 'http').'://'.
+                $parsedUrl['host'].(isset($parsedUrl['port']) ? (':'.$parsedUrl['port']) : '');
+        }
+
+        return $this;
+    }
+
+    /**
+     * getBaseUri.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function getBaseUri(): string
+    {
+        return property_exists($this, 'baseUri') ? $this->baseUri : '';
+    }
+
+    public function getTimeout(): float
+    {
+        return property_exists($this, 'timeout') ? $this->timeout : 5.0;
+    }
+
+    public function setTimeout(float $timeout): self
+    {
+        if (property_exists($this, 'timeout')) {
+            $this->timeout = $timeout;
+        }
+
+        return $this;
+    }
+
+    public function getConnectTimeout(): float
+    {
+        return property_exists($this, 'connectTimeout') ? $this->connectTimeout : 3.0;
+    }
+
+    public function setConnectTimeout(float $connectTimeout): self
+    {
+        if (property_exists($this, 'connectTimeout')) {
+            $this->connectTimeout = $connectTimeout;
+        }
+
+        return $this;
+    }
+
+    /**
+     * Get default options.
+     *
+     * @author yansongda <me@yansongda.cn>
+     */
+    public function getOptions(): array
+    {
+        return array_merge([
+            'base_uri' => $this->getBaseUri(),
+            'timeout' => $this->getTimeout(),
+            'connect_timeout' => $this->getConnectTimeout(),
+        ], $this->getHttpOptions());
+    }
+
+    /**
+     * setOptions.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return $this
+     */
+    public function setOptions(array $options): self
+    {
+        return $this->setHttpOptions($options);
+    }
+
+    public function getHttpOptions(): array
+    {
+        return $this->httpOptions;
+    }
+
+    public function setHttpOptions(array $httpOptions): self
+    {
+        $this->httpOptions = $httpOptions;
+
+        return $this;
+    }
+
+    /**
+     * Convert response.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return array|string
+     */
+    public function unwrapResponse(ResponseInterface $response)
+    {
+        $contentType = $response->getHeaderLine('Content-Type');
+        $contents = $response->getBody()->getContents();
+
+        if (false !== stripos($contentType, 'json') || stripos($contentType, 'javascript')) {
+            return json_decode($contents, true);
+        } elseif (false !== stripos($contentType, 'xml')) {
+            return json_decode(json_encode(simplexml_load_string($contents, 'SimpleXMLElement', LIBXML_NOCDATA), JSON_UNESCAPED_UNICODE), true);
+        }
+
+        return $contents;
+    }
+}

+ 85 - 0
addons/epay/library/Yansongda/Supports/Traits/Serializable.php

@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Yansongda\Supports\Traits;
+
+use RuntimeException;
+
+trait Serializable
+{
+    /**
+     * toJson.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @return string
+     */
+    public function toJson()
+    {
+        return $this->serialize();
+    }
+
+    /**
+     * Specify data which should be serialized to JSON.
+     *
+     * @see   https://php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed data which can be serialized by <b>json_encode</b>,
+     *               which is a value of any type other than a resource
+     *
+     * @since 5.4.0
+     */
+    public function jsonSerialize()
+    {
+        if (method_exists($this, 'toArray')) {
+            return $this->toArray();
+        }
+
+        return [];
+    }
+
+    /**
+     * String representation of object.
+     *
+     * @see   https://php.net/manual/en/serializable.serialize.php
+     *
+     * @return string the string representation of the object or null
+     *
+     * @since 5.1.0
+     */
+    public function serialize()
+    {
+        if (method_exists($this, 'toArray')) {
+            return json_encode($this->toArray());
+        }
+
+        return json_encode([]);
+    }
+
+    /**
+     * Constructs the object.
+     *
+     * @see   https://php.net/manual/en/serializable.unserialize.php
+     *
+     * @param string $serialized <p>
+     *                           The string representation of the object.
+     *                           </p>
+     *
+     * @since 5.1.0
+     */
+    public function unserialize($serialized)
+    {
+        $data = json_decode($serialized, true);
+
+        if (JSON_ERROR_NONE !== json_last_error()) {
+            throw new RuntimeException('Invalid Json Format');
+        }
+
+        foreach ($data as $key => $item) {
+            if (method_exists($this, 'set')) {
+                $this->set($key, $item);
+            }
+        }
+    }
+}

+ 147 - 0
addons/epay/library/Yansongda/Supports/Traits/ShouldThrottle.php

@@ -0,0 +1,147 @@
+<?php
+
+namespace Yansongda\Supports\Traits;
+
+use Predis\Client;
+
+/**
+ * Trait ShouldThrottle.
+ *
+ * @property Client $redis
+ */
+trait ShouldThrottle
+{
+    /**
+     * _throttle.
+     *
+     * @var array
+     */
+    protected $_throttle = [
+        'limit' => 60,
+        'period' => 60,
+        'count' => 0,
+        'reset_time' => 0,
+    ];
+
+    /**
+     * isThrottled.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $key
+     * @param int    $limit
+     * @param int    $period
+     * @param bool   $auto_add
+     *
+     * @return bool
+     */
+    public function isThrottled($key, $limit = 60, $period = 60, $auto_add = false)
+    {
+        if (-1 === $limit) {
+            return false;
+        }
+
+        $now = microtime(true) * 1000;
+
+        $this->redis->zremrangebyscore($key, 0, $now - $period * 1000);
+
+        $this->_throttle = [
+            'limit' => $limit,
+            'period' => $period,
+            'count' => $this->getThrottleCounts($key, $period),
+            'reset_time' => $this->getThrottleResetTime($key, $now),
+        ];
+
+        if ($this->_throttle['count'] < $limit) {
+            if ($auto_add) {
+                $this->throttleAdd($key, $period);
+            }
+
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 限流 + 1.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $key
+     * @param int    $period
+     */
+    public function throttleAdd($key, $period = 60)
+    {
+        $now = microtime(true) * 1000;
+
+        $this->redis->zadd($key, [$now => $now]);
+        $this->redis->expire($key, $period * 2);
+    }
+
+    /**
+     * getResetTime.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param $key
+     * @param $now
+     *
+     * @return int
+     */
+    public function getThrottleResetTime($key, $now)
+    {
+        $data = $this->redis->zrangebyscore(
+            $key,
+            $now - $this->_throttle['period'] * 1000,
+            $now,
+            ['limit' => [0, 1]]
+        );
+
+        if (0 === count($data)) {
+            return $this->_throttle['reset_time'] = time() + $this->_throttle['period'];
+        }
+
+        return intval($data[0] / 1000) + $this->_throttle['period'];
+    }
+
+    /**
+     * 获取限流相关信息.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string|null $key
+     * @param mixed|null  $default
+     *
+     * @return array|null
+     */
+    public function getThrottleInfo($key = null, $default = null)
+    {
+        if (is_null($key)) {
+            return $this->_throttle;
+        }
+
+        if (isset($this->_throttle[$key])) {
+            return $this->_throttle[$key];
+        }
+
+        return $default;
+    }
+
+    /**
+     * 获取已使用次数.
+     *
+     * @author yansongda <me@yansongda.cn>
+     *
+     * @param string $key
+     * @param int    $period
+     *
+     * @return string
+     */
+    public function getThrottleCounts($key, $period = 60)
+    {
+        $now = microtime(true) * 1000;
+
+        return $this->redis->zcount($key, $now - $period * 1000, $now);
+    }
+}

+ 47 - 0
addons/epay/view/api/alipay.html

@@ -0,0 +1,47 @@
+<div class="container">
+    <h2 class="scanpay-title">
+        <img src="__ADDON__/images/logo-alipay.png" alt="" height="32" class="pull-left" style="margin-right:5px;"> 支付宝支付
+        <div class="scanpay-time">
+            请在 <span>60</span> 秒内完成支付
+        </div>
+    </h2>
+
+    <div class="scanpay scanpay-alipay">
+        <div class="row">
+            <div class="col-xs-12 col-sm-12">
+                <div class="row">
+                    <div class="col-xs-12 col-sm-5">
+                        <div class="scanpay-body">
+                            <div class="scanpay-order clearfix">
+                                <p>订单标题:<em>{$orderData.title}</em></p>
+                                <p>订单编号:<em>{$orderData.orderid}</em></p>
+                                <p>订单价格:<em class="scanpay-price">¥{$orderData.amount}</em> 元</p>
+                            </div>
+                            <div class="scanpay-qrcode">
+                                <img src="{:addon_url('epay/api/qrcode',[],false)}?text={$payData.qr_code}">
+                                <div class="expired hidden"></div>
+                                <div class="paid hidden"></div>
+                            </div>
+                            <div class="scanpay-tips">
+                                <p>请使用支付宝扫一扫<br>扫描二维码支付</p>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="col-sm-1"></div>
+                    <div class="col-sm-6 hidden-xs">
+                        <div class="scanpay-screenshot">
+                            <img src="__ADDON__/images/screenshot-alipay.png" class="img-responsive" alt=""/>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+</div>
+
+<!--@formatter:off-->
+<script>
+    var queryParams = {"paytype":"alipay", "orderid":"{$orderData.orderid}", "returnurl":"{$orderData.returnurl}"};
+</script>
+<!--@formatter:on-->

+ 90 - 0
addons/epay/view/api/wechat.html

@@ -0,0 +1,90 @@
+{if $type=='jsapi'}
+<div class="container">
+    <div class="row" style="margin-top:20px;">
+        <div class="col-xs-12">
+            <button type="button" class="btn btn-success btn-lg btn-block">正在发起微信支付</button>
+            <button type="button" class="btn btn-default btn-lg btn-block" onclick="location.href='{$orderData.returnurl}'">如果页面未自动跳转</button>
+        </div>
+    </div>
+</div>
+<!--@formatter:off-->
+<script>
+    function onBridgeReady() {
+        WeixinJSBridge.invoke('getBrandWCPayRequest', {$payData|json_encode}, function(res) {
+            if (res.err_msg == "get_brand_wcpay_request:ok") {
+                layer.msg('支付成功!');
+            } else if (res.err_msg == "get_brand_wcpay_request:cancel") {
+                layer.msg('您取消了支付');
+            } else if (res.err_msg == "get_brand_wcpay_request:fail") {
+                layer.msg('支付失败');
+            }else{
+                layer.msg(typeof res.err_msg!='undefined' ? res.err_msg : (typeof res.errMsg !=='undefined' ? res.errMsg : "未知支付状态"));
+            }
+            setTimeout(function () {
+                location.href = '{$orderData.returnurl}';
+            }, 1500);
+        });
+    }
+
+    if (typeof WeixinJSBridge == "undefined") {
+        if (document.addEventListener) {
+            document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
+        } else if (document.attachEvent) {
+            document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
+            document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
+        }
+    } else {
+        onBridgeReady();
+    }
+</script>
+<!--@formatter:on-->
+
+{elseif $type=='pc' /}
+<div class="container">
+    <h2 class="scanpay-title">
+        <img src="__ADDON__/images/logo-wechat.png" alt="" height="32" class="pull-left" style="margin-right:5px;"> 微信支付
+        <div class="scanpay-time">
+            请在 <span>60</span> 秒内完成支付
+        </div>
+    </h2>
+
+    <div class="scanpay scanpay-wechat">
+        <div class="row">
+            <div class="col-xs-12 col-sm-12">
+                <div class="row">
+                    <div class="col-xs-12 col-sm-5">
+                        <div class="scanpay-body">
+                            <div class="scanpay-order clearfix">
+                                <p>订单标题:<em>{$orderData.title}</em></p>
+                                <p>订单编号:<em>{$orderData.orderid}</em></p>
+                                <p>订单价格:<em class="scanpay-price">¥{$orderData.amount}</em> 元</p>
+                            </div>
+                            <div class="scanpay-qrcode">
+                                <img src="{:addon_url('epay/api/qrcode',[],false)}?text={$payData.code_url}">
+                                <div class="expired hidden"></div>
+                                <div class="paid hidden"></div>
+                            </div>
+                            <div class="scanpay-tips">
+                                <p>请使用微信扫一扫<br>扫描二维码支付</p>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="col-sm-1"></div>
+                    <div class="col-sm-6 hidden-xs">
+                        <div class="scanpay-screenshot">
+                            <img src="__ADDON__/images/screenshot-wechat.png" class="img-responsive" alt=""/>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+</div>
+
+<!--@formatter:off-->
+<script>
+    var queryParams = {"paytype":"wechat", "orderid":"{$orderData.orderid}", "returnurl":"{$orderData.returnurl}"};
+</script>
+<!--@formatter:on-->
+{/if}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 12 - 0
addons/epay/view/index/index.html


+ 103 - 0
addons/epay/view/layout/default.html

@@ -0,0 +1,103 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <meta name="description" content="">
+    <meta name="author" content="">
+
+    <title>{$title} - {$site.name}</title>
+
+    <link href="__CDN__/assets/libs/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
+    <link href="__ADDON__/css/common.css" rel="stylesheet">
+    <link href="__CDN__/assets/libs/font-awesome/css/font-awesome.min.css" rel="stylesheet">
+
+    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
+    <!--[if lt IE 9]>
+    <script src="https://cdn.staticfile.org/html5shiv/3.7.3/html5shiv.min.js"></script>
+    <script src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></script>
+    <![endif]-->
+
+</head>
+
+<body>
+
+<!-- Navigation -->
+<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
+    <div class="container">
+        <!-- Brand and toggle get grouped for better mobile display -->
+        <div class="navbar-header">
+            <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
+                <span class="sr-only">Toggle navigation</span>
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+            </button>
+            <a class="navbar-brand" href="{:addon_url('epay/index/index')}">{$site.name}</a>
+        </div>
+        <!-- Collect the nav links, forms, and other content for toggling -->
+        <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
+            <ul class="nav navbar-nav navbar-right">
+                <li>
+                </li>
+                {if $user}
+                <li class="dropdown">
+                    <a href="#" class="dropdown-toggle" data-toggle="dropdown">欢迎你! {$user.nickname}<b class="caret"></b></a>
+                    <ul class="dropdown-menu">
+                        <li>
+                            <a href="{:url('index/user/index')}">会员中心</a>
+                        </li>
+                        <li>
+                            <a href="{:url('index/user/profile')}">个人资料</a>
+                        </li>
+                        <li>
+                            <a href="{:url('index/user/logout')}">退出登录</a>
+                        </li>
+                    </ul>
+                </li>
+                {else /}
+                <li class="dropdown">
+                    <a href="{:url('index/user/index')}" class="dropdown-toggle" data-toggle="dropdown">会员中心 <b class="caret"></b></a>
+                    <ul class="dropdown-menu">
+                        <li>
+                            <a href="{:url('index/user/login')}">登录</a>
+                        </li>
+                        <li>
+                            <a href="{:url('index/user/register')}">注册</a>
+                        </li>
+                    </ul>
+                </li>
+                {/if}
+            </ul>
+        </div>
+        <!-- /.navbar-collapse -->
+    </div>
+    <!-- /.container -->
+</nav>
+
+{__CONTENT__}
+
+<div class="container">
+    <!-- Footer -->
+    <footer>
+        <div class="row">
+            <div class="col-lg-12">
+                <hr>
+                <p>Copyright &copy; {$site.name} 2017-2020</p>
+            </div>
+        </div>
+    </footer>
+
+</div>
+<!-- /.container -->
+
+<script src="__CDN__/assets/libs/jquery/dist/jquery.min.js"></script>
+<script src="__CDN__/assets/libs/bootstrap/dist/js/bootstrap.min.js"></script>
+<script src="__CDN__/assets/libs/fastadmin-layer/dist/layer.js"></script>
+<script src="__ADDON__/js/common.js"></script>
+
+</body>
+</html>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
addons/kefu/.addonrc


+ 297 - 0
addons/kefu/Kefu.php

@@ -0,0 +1,297 @@
+<?php
+
+namespace addons\kefu;
+
+use app\common\library\Menu;
+use think\Addons;
+use think\Db;
+
+/**
+ * 插件
+ */
+class Kefu extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        // 创建菜单
+        $menu = [
+            [
+                'name'    => 'kefu',
+                'title'   => '客服管理',
+                'icon'    => 'fa fa-comment',
+                'sublist' => [
+                    [
+                        'name'    => 'kefu/config',
+                        'title'   => '客服配置',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '99',
+                        'sublist' => [
+                            ['name' => 'kefu/config/index', 'title' => '查看'],
+                            ['name' => 'kefu/config/update', 'title' => '编辑'],
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/user',
+                        'title'   => '用户管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '98',
+                        'sublist' => [
+                            ['name' => 'kefu/user/index', 'title' => '查看'],
+                            ['name' => 'kefu/user/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/user/del', 'title' => '删除'],
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/session',
+                        'title'   => '会话管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '97',
+                        'sublist' => [
+                            ['name' => 'kefu/session/index', 'title' => '查看'],
+                            ['name' => 'kefu/session/del', 'title' => '删除'],
+                            ['name' => 'kefu/session/recyclebin', 'title' => '回收站'],
+                            ['name' => 'kefu/session/destroy', 'title' => '真实删除'],
+                            ['name' => 'kefu/session/restore', 'title' => '还原'],
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/kbs',
+                        'title'   => '知识库管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '96',
+                        'sublist' => [
+                            ['name' => 'kefu/kbs/index', 'title' => '查看'],
+                            ['name' => 'kefu/kbs/add', 'title' => '增加'],
+                            ['name' => 'kefu/kbs/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/kbs/del', 'title' => '删除'],
+                            ['name' => 'kefu/kbs/multi', 'title' => '批量更新'],
+                            ['name' => 'kefu/kbs/recyclebin', 'title' => '回收站'],
+                            ['name' => 'kefu/kbs/destroy', 'title' => '真实删除'],
+                            ['name' => 'kefu/kbs/restore', 'title' => '还原'],
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/csrkpi',
+                        'title'   => '客服代表管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '95',
+                        'sublist' => [
+                            ['name' => 'kefu/csrkpi/index', 'title' => '查看'],
+                            ['name' => 'kefu/csrkpi/add', 'title' => '添加'],
+                            ['name' => 'kefu/csrkpi/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/csrkpi/del', 'title' => '删除'],
+                            ['name' => 'kefu/csrkpi/multi', 'title' => '批量更新']
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/leavemessage',
+                        'title'   => '用户留言管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '94',
+                        'sublist' => [
+                            ['name' => 'kefu/leavemessage/index', 'title' => '查看'],
+                            ['name' => 'kefu/leavemessage/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/leavemessage/del', 'title' => '删除']
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/record',
+                        'title'   => '聊天记录汇总',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '93',
+                        'sublist' => [
+                            ['name' => 'kefu/record/index', 'title' => '查看'],
+                            ['name' => 'kefu/record/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/record/del', 'title' => '删除'],
+                            ['name' => 'kefu/record/multi', 'title' => '批量更新']
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/fastreply',
+                        'title'   => '快捷回复管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '92',
+                        'sublist' => [
+                            ['name' => 'kefu/fastreply/index', 'title' => '查看'],
+                            ['name' => 'kefu/fastreply/add', 'title' => '增加'],
+                            ['name' => 'kefu/fastreply/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/fastreply/del', 'title' => '删除'],
+                            ['name' => 'kefu/fastreply/multi', 'title' => '批量更新'],
+                            ['name' => 'kefu/fastreply/recyclebin', 'title' => '回收站'],
+                            ['name' => 'kefu/fastreply/destroy', 'title' => '真实删除'],
+                            ['name' => 'kefu/fastreply/restore', 'title' => '还原'],
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/blacklist',
+                        'title'   => '用户黑名单管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '91',
+                        'sublist' => [
+                            ['name' => 'kefu/blacklist/index', 'title' => '查看'],
+                            ['name' => 'kefu/blacklist/add', 'title' => '增加'],
+                            ['name' => 'kefu/blacklist/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/blacklist/del', 'title' => '删除'],
+                            ['name' => 'kefu/blacklist/recyclebin', 'title' => '回收站'],
+                            ['name' => 'kefu/blacklist/destroy', 'title' => '真实删除'],
+                            ['name' => 'kefu/blacklist/restore', 'title' => '还原'],
+                        ]
+                    ],
+                    [
+                        'name'    => 'kefu/toolbar',
+                        'title'   => '窗口工具栏管理',
+                        'icon'    => 'fa fa-circle-o',
+                        'weigh'   => '90',
+                        'remark'  => '此功能用于管理会话窗口工具栏基本信息及状态,若需添加自定义工具,请先自行实现对应功能',
+                        'sublist' => [
+                            ['name' => 'kefu/toolbar/index', 'title' => '查看'],
+                            ['name' => 'kefu/toolbar/add', 'title' => '增加'],
+                            ['name' => 'kefu/toolbar/edit', 'title' => '编辑'],
+                            ['name' => 'kefu/toolbar/del', 'title' => '删除'],
+                            ['name' => 'kefu/toolbar/recyclebin', 'title' => '回收站'],
+                            ['name' => 'kefu/toolbar/destroy', 'title' => '真实删除'],
+                            ['name' => 'kefu/toolbar/restore', 'title' => '还原'],
+                        ]
+                    ],
+                ]
+            ]
+        ];
+        Menu::create($menu);
+        return true;
+    }
+
+    /**
+     * 插件更新方法
+     * @return bool
+     */
+    public function upgrade()
+    {
+        // v1.0.2 审查聊天记录
+        if (!Db::name('auth_rule')->where('name', 'kefu/record/sessionRecord')->value('id')) {
+            $menu = [
+                ['name' => 'kefu/record/sessionRecord', 'title' => '审查聊天记录']
+            ];
+            Menu::create($menu, 'kefu/record');
+        }
+
+        // v1.0.3 知识库
+        if (!Db::name('auth_rule')->where('name', 'kefu/kbs')->value('id')) {
+            $menu = [
+                [
+                    'name'    => 'kefu/kbs',
+                    'title'   => '知识库管理',
+                    'icon'    => 'fa fa-circle-o',
+                    'sublist' => [
+                        ['name' => 'kefu/kbs/index', 'title' => '查看'],
+                        ['name' => 'kefu/kbs/add', 'title' => '增加'],
+                        ['name' => 'kefu/kbs/edit', 'title' => '编辑'],
+                        ['name' => 'kefu/kbs/del', 'title' => '删除'],
+                        ['name' => 'kefu/kbs/multi', 'title' => '批量更新'],
+                        ['name' => 'kefu/kbs/recyclebin', 'title' => '回收站'],
+                        ['name' => 'kefu/kbs/destroy', 'title' => '真实删除'],
+                        ['name' => 'kefu/kbs/restore', 'title' => '还原'],
+                    ]
+                ]
+            ];
+            Menu::create($menu, 'kefu');
+        }
+
+        // v1.0.3 客服代表管理
+        $kefu_csrkpi_menu = Db::name('auth_rule')->where('name', 'kefu/csrkpi')->find();
+        if ($kefu_csrkpi_menu['title'] == '客服绩效报表') {
+            Db::name('auth_rule')->where('name', 'kefu/csrkpi')->update(['title' => '客服代表管理']);
+        }
+        if (!Db::name('auth_rule')->where('name', 'kefu/csrkpi/add')->value('id')) {
+            $menu = [
+                ['name' => 'kefu/csrkpi/add', 'title' => '添加'],
+                ['name' => 'kefu/csrkpi/edit', 'title' => '编辑'],
+                ['name' => 'kefu/csrkpi/del', 'title' => '删除'],
+            ];
+            Menu::create($menu, 'kefu/csrkpi');
+        }
+
+        // v1.0.4 窗口工具栏管理
+        if (!Db::name('auth_rule')->where('name', 'kefu/toolbar')->value('id')) {
+            $menu = [
+                [
+                    'name'    => 'kefu/toolbar',
+                    'title'   => '窗口工具栏管理',
+                    'icon'    => 'fa fa-circle-o',
+                    'remark'  => '此功能用于管理会话窗口工具栏基本信息及状态,若需添加自定义工具,请先自行实现对应功能',
+                    'sublist' => [
+                        ['name' => 'kefu/toolbar/index', 'title' => '查看'],
+                        ['name' => 'kefu/toolbar/add', 'title' => '增加'],
+                        ['name' => 'kefu/toolbar/edit', 'title' => '编辑'],
+                        ['name' => 'kefu/toolbar/del', 'title' => '删除'],
+                        ['name' => 'kefu/toolbar/recyclebin', 'title' => '回收站'],
+                        ['name' => 'kefu/toolbar/destroy', 'title' => '真实删除'],
+                        ['name' => 'kefu/toolbar/restore', 'title' => '还原'],
+                    ]
+                ]
+            ];
+            Menu::create($menu, 'kefu');
+        }
+
+        // v1.0.6 修复客服配置功能权限分配bug
+        if (!Db::name('auth_rule')->where('name', 'kefu/config/update')->value('id')) {
+            $menu = [
+                ['name' => 'kefu/config/update', 'title' => '编辑']
+            ];
+            Menu::create($menu, 'kefu/config');
+
+            Db::name('auth_rule')->where('name', 'kefu/config/index')->update([
+                'title' => '查看'
+            ]);
+        }
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        Menu::delete('kefu');
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+        $this->upgrade();
+        Menu::enable('kefu');
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+        Menu::disable('kefu');
+        return true;
+    }
+
+    /**
+     * 增加命令
+     */
+    public function appInit($param)
+    {
+        if (request()->isCli()) {
+            \think\Console::addDefaultCommands([
+                'addons\kefu\library\GatewayWorker\start'
+            ]);
+        }
+    }
+
+}

+ 80 - 0
addons/kefu/bootstrap.js

@@ -0,0 +1,80 @@
+if (Config.modulename == 'admin' && Config.controllername == 'index' && Config.actionname == 'index') {
+
+    require.config({
+        paths: {
+            'kefu': '../addons/kefu/js/kefu'
+        },
+        shim: {
+            'kefu': {
+                deps: ['css!../addons/kefu/css/kefu_admin_default.css'],
+                exports: 'KeFu'
+            }
+        }
+    });
+
+    require(['kefu'], function (KeFu) {
+        KeFu.initialize(document.domain, 'admin');
+    });
+
+} else {
+
+    try {
+        var parentConifg = window.parent.Config;
+    } catch (err) {
+        var parentConifg = false;
+    }
+
+    if (parentConifg && parentConifg.modulename == 'admin') {
+        // 监听后台iframe内的快捷键打开会话窗口
+        $(document).on('keyup', function (event) {
+
+            if (window.parent.KeFu) {
+
+                // console.log('当前按钮的code-iframe内:', event.keyCode);
+
+                // 对打开会话窗口的监听
+                // 打开会话窗口快捷键[ctrl + /],若需修改,请拿到对应键的keyCode替换下一行的191即可,191代表[/]键的keyCode
+                if (event.keyCode === 191 && event.ctrlKey) {
+
+                    if (window.parent.KeFu.last_sender) {
+                        if (parseInt(window.parent.KeFu.last_sender) === window.parent.KeFu.session_id) {
+                            // 展开分组
+                            if (!window.parent.KeFu.group_show.dialogue) {
+                                $('#heading_dialogue a').click();
+                            }
+                        } else {
+                            window.parent.KeFu.changeSession(window.parent.KeFu.last_sender);
+                            window.parent.KeFu.last_sender = null;
+                        }
+                    } else if (window.parent.KeFu.window_is_show) {
+                        window.parent.KeFu.toggle_window('hide');
+                    }
+
+                    if (!window.parent.KeFu.window_is_show) {
+                        window.parent.KeFu.toggle_window('show');
+                    }
+                    return ;
+                }
+            }
+
+        });
+
+    } else {
+
+        require.config({
+            paths: {
+                'kefu': '../addons/kefu/js/kefu'
+            },
+            shim: {
+                'kefu': {
+                    deps: ['css!../addons/kefu/css/kefu_default.css'],
+                    exports: 'KeFu'
+                }
+            }
+        });
+
+        require(['kefu'], function (KeFu) {
+            KeFu.initialize(document.domain, 'index');
+        });
+    }
+}

+ 144 - 0
addons/kefu/config.php

@@ -0,0 +1,144 @@
+<?php
+
+return [
+    [
+        'name' => '__tips__',
+        'title' => '温馨提示',
+        'type' => 'string',
+        'content' => [],
+        'value' => '1. <b><font color="red">本窗口的所有配置项,非技术人员不建议进行调整</font></b><br>'."\n"
+            .'                      2. 若需开启wss协议,请先配置<b>ssl证书</b>、<b>ssl证书KEY</b>并重启Workerman服务,才会生效;https站点必须配置wss<br>'."\n"
+            .'                      3. 仅需对外开放<b>WebSocket端口</b>,另外的两项端口<b>未被占用</b>即可<br>'."\n"
+            .'                      4. 消息提醒铃声建议大小100kb,文件无法上传请参考插件文档<a target="_blank" href="https://doc.fastadmin.net/kefu/38.html">常见问题</a>',
+        'rule' => '',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'wss_switch',
+        'title' => 'wss协议',
+        'type' => 'radio',
+        'content' => [
+            '不开启',
+            '开启',
+        ],
+        'value' => '1',
+        'rule' => '',
+        'tip' => '请先参考常见问题配置好wss服务再开启,否则将无法链接',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'ssl_cert',
+        'title' => 'ssl证书',
+        'type' => 'string',
+        'content' => [],
+        'value' => '/www/wwwroot/lvye.zhousi.hdlkeji.com/public/ssl/fullchain.pem',
+        'rule' => '',
+        'tip' => '请填写证书pem或crt文件的绝对路径',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'ssl_cert_key',
+        'title' => 'ssl证书KEY',
+        'type' => 'string',
+        'content' => [],
+        'value' => '/www/wwwroot/lvye.zhousi.hdlkeji.com/public/ssl/privkey.key',
+        'rule' => '',
+        'tip' => '请填写证书密匙(key)文件的绝对路径',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'websocket_port',
+        'title' => 'WebSocket端口',
+        'type' => 'string',
+        'content' => [],
+        'value' => '1818',
+        'rule' => 'required,range(1024~65535)',
+        'tip' => '请在安全组、防火墙等开放此端口',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'register_port',
+        'title' => '服务注册端口',
+        'type' => 'string',
+        'content' => [],
+        'value' => '1260',
+        'rule' => 'required,range(1024~65535)',
+        'tip' => '无需对外开放,属未被占用的端口即可',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'internal_start_port',
+        'title' => '内部通讯起始端口',
+        'type' => 'string',
+        'content' => [],
+        'value' => '3200',
+        'rule' => 'required,range(1024~65535)',
+        'tip' => '无需对外开放,属未被占用的端口即可',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'gateway_process_number',
+        'title' => 'Gateway进程数',
+        'type' => 'string',
+        'content' => [],
+        'value' => '1',
+        'rule' => '',
+        'tip' => '设置为CPU核数相等的数量性能最好',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'worker_process_number',
+        'title' => 'BusinessWorker进程数',
+        'type' => 'string',
+        'content' => [],
+        'value' => '2',
+        'rule' => '',
+        'tip' => '根据业务有无阻塞式IO,设为CPU核数的1-3倍',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'ringing',
+        'title' => '消息提醒铃声',
+        'type' => 'file',
+        'content' => [],
+        'value' => '/assets/addons/kefu/audio/message_prompt.wav',
+        'rule' => 'required,file',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'theme',
+        'title' => '主题模板',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'default',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '请确保addons/kefu/view有相应的目录',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'rule_out_url',
+        'title' => '以下页面不启动<br>(一行一个)',
+        'type' => 'text',
+        'content' => [],
+        'value' => 'http://kefu_local.com/index/user/index.html?tip=自动排除对应https地址、带参数地址,此处的URL带参数无效',
+        'rule' => '',
+        'tip' => '这些前台页面将不启动在线客服',
+        'ok' => '',
+        'extend' => '',
+    ],
+];

+ 24 - 0
addons/kefu/controller/Base.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace addons\kefu\controller;
+
+use think\addons\Controller;
+use think\Request;
+
+class Base extends Controller
+{
+
+    public function __construct(Request $request = null)
+    {
+        parent::__construct($request);
+        $config = get_addon_config('kefu');
+        // 设定主题模板目录
+        $this->view->engine->config('view_path', $this->view->engine->config('view_path') . trim($config['theme']) . DS);
+    }
+
+    protected function _initialize()
+    {
+        parent::_initialize();
+    }
+
+}

+ 473 - 0
addons/kefu/controller/Index.php

@@ -0,0 +1,473 @@
+<?php
+
+namespace addons\kefu\controller;
+
+use addons\kefu\library\Common;
+use fast\Random;
+use think\Config;
+use think\Db;
+use think\Cookie;
+
+class Index extends Base
+{
+    protected $noNeedLogin = ['initialize', 'loadMessagePrompt', 'upload', 'mobile', 'index'];
+
+    protected $chat_config;
+
+    protected $token_info = false;// 用户、游客、管理员的资料
+
+    protected $token_list = [];// 要发送给前台的token(前台利用这些token链接websocket)
+
+    protected $referrer = '';
+
+    public function index()
+    {
+        $this->view->assign('chat_name', $this->chat_config['chat_name']);
+        return $this->view->fetch();
+    }
+
+    public function mobile()
+    {
+        $this->view->assign('toolbar', $this->chat_config['toolbar']);// 工具栏配置
+        $this->view->assign('chat_name', $this->chat_config['chat_name']);
+        return $this->view->fetch();
+    }
+
+    /**
+     * 获取配置、初始化AJAX请求过来的用户的身份等
+     */
+    public function _initialize()
+    {
+        parent::_initialize();
+
+        //跨域请求检测
+        check_cors_request();
+
+        $this->referrer = Common::trajectoryAnalysis($this->request->get('referrer'));
+
+        // 读取工具栏
+        $toolbar_temp = Db::name('kefu_toolbar')->where('status', 1)->where('deletetime', null)->select();
+        // 以mark为键
+        foreach ($toolbar_temp as $key => $value) {
+            $value['icon_image']                = Common::imgSrcFill($value['icon_image'], false);
+            $toolbar['toolbar'][$value['mark']] = $value;
+        }
+
+        $this->chat_config      = Db::name('kefu_config')->column('name,value');
+        $kefu_config            = get_addon_config('kefu');
+        $kefu_config['__CDN__'] = config('view_replace_str.__CDN__');
+        $kefu_config['__CDN__'] = $kefu_config['__CDN__'] ? $kefu_config['__CDN__'] : $this->request->domain();
+
+        // 上传配置
+        $upload['upload'] = \app\common\model\Config::upload();
+        // 上传信息配置后
+        \think\Hook::listen("upload_config_init", $upload['upload']);
+        if ($upload['upload']['storage'] != 'local') {
+            $upload['upload']['cdnurl']    = $upload['upload']['cdnurl'] ? $upload['upload']['cdnurl'] : cdnurl('', true);
+            $upload['upload']['uploadurl'] = preg_match('/^http(s)?:\/\//', $upload['upload']['uploadurl']) ? $upload['upload']['uploadurl'] : $this->request->domain() . $upload['upload']['uploadurl'];
+        } else {
+            $upload['upload']['cdnurl']    = $this->request->domain();
+            $upload['upload']['uploadurl'] = addon_url('kefu/index/upload', [], '', true);
+        }
+        $this->chat_config = array_merge($this->chat_config, $kefu_config, $upload, $toolbar);
+        unset($toolbar_temp);
+        unset($toolbar);
+
+        // 页面排除
+        if ($this->chat_config['rule_out_url'] && isset($_SERVER['HTTP_REFERER'])) {
+
+            $http_referer = $this->urlDealWith($_SERVER['HTTP_REFERER']);
+            $rule_out_url = explode(PHP_EOL, $this->chat_config['rule_out_url']);
+
+            foreach ($rule_out_url as $key => $value) {
+                if ($this->urlDealWith($value) == $http_referer) {
+                    $this->result(null, 401, $this->chat_config['chat_name'] . ' 当前页面被设置为不启动', 'json');
+                }
+            }
+        }
+
+        // 用户登录
+        $data                                    = $this->request->only(['modulename', 'token', 'kefu_tourists_token']);
+        $this->token_list['kefu_tourists_token'] = Cookie::get('kefu_user');
+        if (!$this->token_list['kefu_tourists_token'] && isset($data['kefu_tourists_token'])) {
+            $this->token_list['kefu_tourists_token'] = $data['kefu_tourists_token'];
+        }
+
+        if (!isset($data['modulename'])) {
+            if ($this->request->action() == 'mobile' || $this->request->action() == 'index') {
+                $data['modulename'] = 'index';
+            } else {
+                $this->result(null, 0, $this->chat_config['chat_name'] . ' 模块未知' . $this->request->action(), 'json');
+            }
+        }
+
+        if ($data['modulename'] == 'admin') {
+
+            // 验证管理员身份
+            $auth = \app\admin\library\Auth::instance();
+            if ($auth->isLogin()) {
+                $this->token_info = Common::checkAdmin(false, $auth->id);
+            } elseif ($data['token']) {
+                $this->token_info = Common::checkAdmin($data['token']);
+            }
+
+            if ($this->token_info) {
+                // workerman 中不支持 PHP session和cookie,所以 $auth 类失效
+                // 此处对管理员 token 加密稍作修改,供客服自动登录使用
+                $keeptime   = 864000;
+                $expiretime = time() + $keeptime;
+
+                // 原规则为单纯的id,若需修改附加的字符串,请将`Common::checkAdmin`方法里边的附加字符串一起修改
+                $sign = $this->token_info['id'] . 'kefu_admin_sign_additional';
+
+                $key                            = md5(md5($sign) . md5($keeptime) . md5($expiretime) . $this->token_info['token']);
+                $cookie_data                    = [$this->token_info['id'], $keeptime, $expiretime, $key];
+                $this->token_list['kefu_token'] = implode('|', $cookie_data);
+                unset($this->token_info['token']);
+            }
+
+            // 清理轨迹
+            switch ($this->chat_config['trajectory_save_cycle']) {
+                case 0:
+                    $where_time = 604800; // 清理7天前的
+                    break;
+                case 1:
+                    $where_time = 2592000; // 30天
+                    break;
+                case 2:
+                    $where_time = 5184000; // 60天
+                    break;
+
+                default:
+                    $where_time = false; // 不清理
+                    break;
+            }
+
+            if ($where_time) {
+                Db::name('kefu_trajectory')->where('createtime', '<', time() - $where_time)->delete();
+            }
+
+        } elseif ($data['modulename'] != 'admin') {
+            // 验证用户身份
+            $auth  = \app\common\library\Auth::instance();
+            $token = Cookie::get('token');
+            $token = (!$token && isset($data['token'])) ? $data['token'] : $token;
+
+            if ($token) {
+                $auth->init($token);
+                if ($auth->isLogin()) {
+                    $this->token_info = Common::checkKefuUser($this->token_list['kefu_tourists_token'], $auth->id);
+
+                    if (!$this->token_info && !$this->token_list['kefu_tourists_token']) {
+                        // 该用户未绑定游客,建立游客身份并绑定
+                        $tourists = Common::createTourists($this->referrer . ' IP:' . $this->request->ip());
+                        if ($tourists) {
+                            $this->token_list['kefu_tourists_token'] = $tourists['kefu_user_cookie'];
+                            Cookie::set('kefu_user', $tourists['kefu_user_cookie'], 315360000);
+                        } else {
+                            $this->result(null, 401, $this->chat_config['chat_name'] . ' 游客创建失败!', 'json');
+                        }
+
+                        $this->token_info = Common::checkKefuUser($this->token_list['kefu_tourists_token'], $auth->id);
+                    }
+
+                    // workerman 中不支持 PHP session和cookie,所以 $auth 类失效
+                    // 在开启 $cookie_httponly 时,对用户的 token 加密稍作修改,供客服自动登录使用
+                    if ($this->token_info) {
+                        $cookie_httponly = config('cookie.httponly');
+                        if (!$cookie_httponly) {
+                            $this->token_list['kefu_token'] = $token;
+                        } else {
+
+                            // 若需修改附加的字符串,请将`Events::onWebSocketConnect`方法里边的附加字符串一起修改
+                            // 先用 user_token 数据表中的token字段同样的加密算法对token进行加密,否则workerman无法识别用户身份
+                            $sign                           = Common::getEncryptedToken($token) . 'kefu_user_sign_additional';
+                            $key                            = md5(md5($auth->id) . md5($sign));
+                            $cookie_data                    = [$auth->id, $key];
+                            $this->token_list['kefu_token'] = implode('|', $cookie_data);
+                        }
+                    }
+                }
+            }
+
+            if (!$this->token_info && $this->token_list['kefu_tourists_token']) {
+                // 验证游客用户身份
+                $this->token_info = Common::checkKefuUser($this->token_list['kefu_tourists_token'], 0);
+            }
+        }
+
+        if (!$this->token_info && $data['modulename'] != 'admin') {
+            $tourists = Common::createTourists($this->referrer . ' IP:' . $this->request->ip());
+            if ($tourists) {
+                $this->token_list['kefu_tourists_token'] = $tourists['kefu_user_cookie'];
+                Cookie::set('kefu_user', $tourists['kefu_user_cookie'], 315360000);
+                $this->token_info = Common::checkKefuUser($this->token_list['kefu_tourists_token'], 0);
+            } else {
+                $this->result(null, 401, $this->chat_config['chat_name'] . ' 游客创建失败!', 'json');
+            }
+        }
+
+        $this->view->assign('cdnurl', $kefu_config['__CDN__']);
+    }
+
+    public function initialize()
+    {
+        $res_data    = [];
+        $data        = $this->request->only(['modulename']);
+        $current_url = $this->request->header('referer');
+
+        if ($this->token_info) {
+            if (isset($this->token_info['blacklist']) && $this->token_info['blacklist']) {
+                $this->result(null, 401, '!', 'json');// 黑名单用户
+            }
+        } else {
+            $this->result(null, 401, $this->chat_config['chat_name'] . ' 无法识别用户!', 'json');
+        }
+
+        $this->view->assign('toolbar', $this->chat_config['toolbar']);// 工具栏配置
+
+        if ($data['modulename'] == 'admin') {
+
+            if (isset($this->chat_config['toolbar']['fastreply'])) {
+                // 快捷回复
+                $fast_reply = Db::name('kefu_fast_reply')
+                    ->where('admin_id=' . $this->token_info['id'] . ' OR admin_id=0')
+                    ->where('status', '1')
+                    ->where('deletetime', null)
+                    ->select();
+
+                $fast_reply_temp = [];
+                foreach ($fast_reply as $key => $value) {
+                    $fast_reply_temp[$value['id']] = $value;
+                }
+                unset($fast_reply);
+                $res_data['fast_reply'] = $fast_reply_temp;
+
+                $this->view->assign('fast_reply', $fast_reply_temp);
+            }
+
+            $res_data['window_html'] = $this->view->fetch(ROOT_PATH . 'addons/kefu/view/' . trim($this->chat_config['theme']) . '/modaltpl/admin.html');
+        } else {
+
+            $this->chat_config['invite_box_img']         = $this->chat_config['invite_box_img'] ? Common::imgSrcFill($this->chat_config['invite_box_img'], false) : false;
+            $this->chat_config['auto_invitation_switch'] = ($this->chat_config['invite_box_img']) ? $this->chat_config['auto_invitation_switch'] : 0;
+
+            // 只在有客服在线时弹出邀请框
+            if ($this->chat_config['only_csr_online_invitation'] && $this->chat_config['auto_invitation_switch']) {
+                $online_csr                                  = Db::name('kefu_csr_config')
+                    ->where('status', 3)
+                    ->value('admin_id');
+                $this->chat_config['auto_invitation_switch'] = $online_csr ? $this->chat_config['auto_invitation_switch'] : 0;
+            }
+
+            // 前台轮播图
+            $this->chat_config['slider_images'] = $this->chat_config['slider_images'] ? explode(',', trim($this->chat_config['slider_images'], ',')) : [];
+            foreach ($this->chat_config['slider_images'] as $key => $value) {
+                $this->chat_config['slider_images'][$key] = Common::imgSrcFill($value, false);
+            }
+
+            $res_data['window_html'] = $this->view->fetch(ROOT_PATH . 'addons/kefu/view/' . trim($this->chat_config['theme']) . '/modaltpl/index.html');
+        }
+
+        // 记录轨迹
+        if (isset($this->token_info['trajectory'])) {
+            // 用户轨迹
+            $trajectory = [
+                'user_id'    => $this->token_info['id'],
+                'csr_id'     => $this->token_info['trajectory']['csr_id'],
+                'log_type'   => 0,
+                'note'       => $this->token_info['trajectory']['note'],
+                'url'        => $current_url,
+                'referrer'   => $this->referrer,
+                'createtime' => time(),
+            ];
+
+            Db::name('kefu_trajectory')->insert($trajectory);
+        }
+
+        // 窗口抖动配置处理
+        $this->chat_config['is_shake'] = false;
+        if ($this->chat_config['new_message_shake'] == 3) {
+            $this->chat_config['is_shake'] = true;
+        } elseif ($this->chat_config['new_message_shake'] == 1 && $data['modulename'] != 'admin') {
+            $this->chat_config['is_shake'] = true;
+        } elseif ($this->chat_config['new_message_shake'] == 2 && $data['modulename'] == 'admin') {
+            $this->chat_config['is_shake'] = true;
+        }
+
+        // 配置排除
+        $except_config = [
+            'wechat_app_id',
+            'wechat_app_secret',
+            'wechat_encodingkey',
+            'wechat_token',
+            'worker_process_number',
+            'csr_admin',
+            'csr_distribution',
+            'register_port',
+            'gateway_process_number',
+            'internal_start_port',
+            'kbs_switch',
+            'trajectory_save_cycle',
+            'ssl_cert',
+            'ssl_cert_key'
+        ];
+        foreach ($except_config as $key => $value) {
+            if (in_array($value, $except_config)) {
+                unset($this->chat_config[$value]);
+            }
+        }
+
+        // 防止商品和订单卡片暴露后台地址
+        if ($this->token_info['source'] != 'csr') {
+            unset($this->chat_config['toolbar']['goods']['card_url']);
+            unset($this->chat_config['toolbar']['order']['card_url']);
+        }
+
+        $res_data['user_info']           = $this->token_info;
+        $res_data['new_msg']             = Common::getUnreadMessages($this->token_info['user_id']);
+        $this->chat_config['modulename'] = $data['modulename'];
+        $res_data['config']              = $this->chat_config;
+        $res_data['token_list']          = $this->token_list;
+        $this->result($res_data, 1, 'ok', 'json');
+    }
+
+    /**
+     * 去除URL中的 index.php、https://、http://、去除参数
+     * @param  [type] $url [description]
+     * @return string      处理结果
+     */
+    private function urlDealWith($url)
+    {
+        $url = explode('?', $url);
+        $url = isset($url[0]) ? $url[0] : ''; // 只要 ? 号前的字符串
+        return str_replace(['http://', 'https://'], '', trim($url));
+    }
+
+    /**
+     * 供跨站下载来信提示音文件(未使用云存储)
+     */
+    public function loadMessagePrompt()
+    {
+        $file = ROOT_PATH . 'public' . $this->chat_config['ringing'];
+        header("Content-type:application/octet-stream");
+        $filename = basename($file);
+        header("Content-Disposition:attachment;filename = " . $filename);
+        header("Accept-ranges:bytes");
+        header("Accept-length:" . filesize($file));
+        readfile($file);
+    }
+
+    public function upload()
+    {
+        $file = $this->request->file('file');
+        if (empty($file)) {
+            $this->result(null, 0, '没有文件被上传或上传超过限制', 'json');
+        }
+
+        //判断是否已经存在附件
+        $sha1     = $file->hash();
+        $extparam = $this->request->post();
+
+        $upload = Config::get('upload');
+
+        preg_match('/(\d+)(\w+)/', $upload['maxsize'], $matches);
+        $type     = strtolower($matches[2]);
+        $typeDict = ['b' => 0, 'k' => 1, 'kb' => 1, 'm' => 2, 'mb' => 2, 'gb' => 3, 'g' => 3];
+        $size     = (int)$upload['maxsize'] * pow(1024, isset($typeDict[$type]) ? $typeDict[$type] : 0);
+        $fileInfo = $file->getInfo();
+        $suffix   = strtolower(pathinfo($fileInfo['name'], PATHINFO_EXTENSION));
+        $suffix   = $suffix ? $suffix : 'file';
+
+        $mimetypeArr = explode(',', strtolower($upload['mimetype']));
+        $typeArr     = explode('/', $fileInfo['type']);
+
+        //禁止上传PHP和HTML文件
+        if (in_array($fileInfo['type'], ['text/x-php', 'text/html']) || in_array($suffix, ['php', 'html', 'htm'])) {
+            $this->error(__('上传格式限制'));
+        }
+
+        //验证文件后缀
+        if ($upload['mimetype'] !== '*' && (!in_array($suffix, $mimetypeArr) || (stripos($typeArr[0] . '/', $upload['mimetype']) !== false && (!in_array($fileInfo['type'], $mimetypeArr) && !in_array($typeArr[0] . '/*', $mimetypeArr))))) {
+            $this->error(__('上传格式限制'));
+        }
+
+        //验证是否为图片文件
+        $imagewidth = $imageheight = 0;
+        if (in_array($fileInfo['type'], [
+                'image/gif',
+                'image/jpg',
+                'image/jpeg',
+                'image/bmp',
+                'image/png',
+                'image/webp'
+            ]) || in_array($suffix, ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'])) {
+            $imgInfo = getimagesize($fileInfo['tmp_name']);
+            if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
+                $this->error(__('上传的文件不是图片'));
+            }
+            $imagewidth  = isset($imgInfo[0]) ? $imgInfo[0] : $imagewidth;
+            $imageheight = isset($imgInfo[1]) ? $imgInfo[1] : $imageheight;
+        }
+
+        $replaceArr = [
+            '{year}'     => date("Y"),
+            '{mon}'      => date("m"),
+            '{day}'      => date("d"),
+            '{hour}'     => date("H"),
+            '{min}'      => date("i"),
+            '{sec}'      => date("s"),
+            '{random}'   => Random::alnum(16),
+            '{random32}' => Random::alnum(32),
+            '{filename}' => $suffix ? substr($fileInfo['name'], 0, strripos($fileInfo['name'], '.')) : $fileInfo['name'],
+            '{suffix}'   => $suffix,
+            '{.suffix}'  => $suffix ? '.' . $suffix : '',
+            '{filemd5}'  => md5_file($fileInfo['tmp_name']),
+        ];
+        $savekey    = $upload['savekey'];
+        $savekey    = str_replace(array_keys($replaceArr), array_values($replaceArr), $savekey);
+
+        $uploadDir = substr($savekey, 0, strripos($savekey, '/') + 1);
+        $fileName  = substr($savekey, strripos($savekey, '/') + 1);
+
+        $splInfo = $file->validate(['size' => $size])->move(ROOT_PATH . '/public' . $uploadDir, $fileName);
+        if ($splInfo) {
+            $admin_id = 0;
+            $user_id  = 0;
+
+            if ($this->token_info) {
+                $user_info = Common::userInfo($this->token_info['user_id']);
+
+                if ($user_info['session_type'] == 0) {
+                    $user_id = $user_info['id'];
+                } elseif ($user_info['session_type'] == 1) {
+                    $admin_id = $user_info['id'];
+                }
+            }
+
+            $params     = [
+                'admin_id'    => $admin_id,
+                'user_id'     => $user_id,
+                'filesize'    => $fileInfo['size'],
+                'imagewidth'  => $imagewidth,
+                'imageheight' => $imageheight,
+                'imagetype'   => $suffix,
+                'imageframes' => 0,
+                'mimetype'    => $fileInfo['type'],
+                'url'         => $uploadDir . $splInfo->getSaveName(),
+                'uploadtime'  => time(),
+                'storage'     => 'local',
+                'sha1'        => $sha1,
+                'extparam'    => json_encode($extparam),
+            ];
+            $attachment = model("common/attachment");
+            $attachment->data(array_filter($params));
+            $attachment->save();
+            \think\Hook::listen("upload_after", $attachment);
+            $this->result(['url' => $uploadDir . $splInfo->getSaveName()], 1, null, 'json');
+        } else {
+            // 上传失败获取错误信息
+            $this->result(null, 1, $file->getError(), 'json');
+        }
+    }
+}

+ 28 - 0
addons/kefu/example/stand_out.html

@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>站外调用在线客服例子</title>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+
+    <!-- 在线客服依赖以下css和js文件,请按需引入 -->
+    <link rel="stylesheet" type="text/css" href="http://example.com/assets/css/bootstrap.min.css">
+    <link rel="stylesheet" type="text/css" href="http://example.com/assets/addons/kefu/css/kefu_default.css">
+    <script type="text/javascript" src="http://example.com/assets/libs/jquery/dist/jquery.min.js"></script>
+    <script type="text/javascript" src="http://example.com/assets/addons/kefu/js/kefu.js"></script>
+    <script type="text/javascript" src="http://example.com/assets/libs/fastadmin-layer/dist/layer.js"></script>
+    <script type="text/javascript" src="http://example.com/assets/libs/bootstrap/dist/js/bootstrap.min.js"></script>
+</head>
+<body>
+<script type="text/javascript">
+    KeFu.initialize('example.com', 'index');
+    // 参数一为在线客服所在网站的域名(启动Workerman服务的网站的域名)
+    // 参数二为模块名,站外直接填写index
+    // 参数三为初始化完成后的回调方法
+    // 参数四为指定客服,可在此处填写客服代表的后台账户id
+    // 若要站外调用,请于后台-》插件管理-》本插件的配置中-》跨站调用允许域名-》填写外站的域名
+
+    // 您也可以参考站内其他模块调用的可运行示例,访问路径 `http://您的域名/addons/kefu`
+</script>
+</body>
+</html>

+ 14 - 0
addons/kefu/example/uni-customer/App.vue

@@ -0,0 +1,14 @@
+<script>
+	export default {
+		onLaunch: function() {
+		},
+		onShow: function() {
+		},
+		onHide: function() {
+		}
+	}
+</script>
+
+<style>
+	/*每个页面公共css */
+</style>

+ 633 - 0
addons/kefu/example/uni-customer/components/jyf-parser/jyf-parser.vue

@@ -0,0 +1,633 @@
+<template>
+	<view>
+		<slot v-if="!nodes.length" />
+		<!--#ifdef APP-PLUS-NVUE-->
+		<web-view id="_top" ref="web" :style="'margin-top:-2px;height:'+height+'px'" @onPostMessage="_message" />
+		<!--#endif-->
+		<!--#ifndef APP-PLUS-NVUE-->
+		<view id="_top" :style="showAm+(selectable?';user-select:text;-webkit-user-select:text':'')">
+			<!--#ifdef H5 || MP-360-->
+			<div :id="'rtf'+uid"></div>
+			<!--#endif-->
+			<!--#ifndef H5 || MP-360-->
+			<trees :nodes="nodes" :lazyLoad="lazyLoad" :loading="loadingImg" />
+			<!--#endif-->
+		</view>
+		<!--#endif-->
+	</view>
+</template>
+
+<script>
+	// #ifndef H5 || APP-PLUS-NVUE || MP-360
+	import trees from './libs/trees';
+	var cache = {},
+		// #ifdef MP-WEIXIN || MP-TOUTIAO
+		fs = uni.getFileSystemManager ? uni.getFileSystemManager() : null,
+		// #endif
+		Parser = require('./libs/MpHtmlParser.js');
+	var dom;
+	// 计算 cache 的 key
+	function hash(str) {
+		for (var i = str.length, val = 5381; i--;)
+			val += (val << 5) + str.charCodeAt(i);
+		return val;
+	}
+	// #endif
+	// #ifdef H5 || APP-PLUS-NVUE || MP-360
+	var rpx = uni.getSystemInfoSync().windowWidth / 750,
+		cfg = require('./libs/config.js');
+	// #endif
+	// #ifdef APP-PLUS-NVUE
+	var weexDom = weex.requireModule('dom');
+	// #endif
+	/**
+	 * Parser 富文本组件
+	 * @tutorial https://github.com/jin-yufeng/Parser
+	 * @property {String} html 富文本数据
+	 * @property {Boolean} autopause 是否在播放一个视频时自动暂停其他视频
+	 * @property {Boolean} autoscroll 是否自动给所有表格添加一个滚动层
+	 * @property {Boolean} autosetTitle 是否自动将 title 标签中的内容设置到页面标题
+	 * @property {Number} compress 压缩等级
+	 * @property {String} domain 图片、视频等链接的主域名
+	 * @property {Boolean} lazyLoad 是否开启图片懒加载
+	 * @property {String} loadingImg 图片加载完成前的占位图
+	 * @property {Boolean} selectable 是否开启长按复制
+	 * @property {Object} tagStyle 标签的默认样式
+	 * @property {Boolean} showWithAnimation 是否使用渐显动画
+	 * @property {Boolean} useAnchor 是否使用锚点
+	 * @property {Boolean} useCache 是否缓存解析结果
+	 * @event {Function} parse 解析完成事件
+	 * @event {Function} load dom 加载完成事件
+	 * @event {Function} ready 所有图片加载完毕事件
+	 * @event {Function} error 错误事件
+	 * @event {Function} imgtap 图片点击事件
+	 * @event {Function} linkpress 链接点击事件
+	 * @author JinYufeng
+	 * @version 20200615
+	 * @listens MIT
+	 */
+	export default {
+		name: 'parser',
+		data() {
+			return {
+				// #ifdef H5 || MP-360
+				uid: this._uid,
+				// #endif
+				// #ifdef APP-PLUS-NVUE
+				height: 1,
+				// #endif
+				// #ifndef APP-PLUS-NVUE
+				showAm: '',
+				// #endif
+				nodes: []
+			}
+		},
+		// #ifndef H5 || APP-PLUS-NVUE || MP-360
+		components: {
+			trees
+		},
+		// #endif
+		props: {
+			html: String,
+			autopause: {
+				type: Boolean,
+				default: true
+			},
+			autoscroll: Boolean,
+			autosetTitle: {
+				type: Boolean,
+				default: true
+			},
+			// #ifndef H5 || APP-PLUS-NVUE || MP-360
+			compress: Number,
+			loadingImg: String,
+			useCache: Boolean,
+			// #endif
+			domain: String,
+			lazyLoad: Boolean,
+			selectable: Boolean,
+			tagStyle: Object,
+			showWithAnimation: Boolean,
+			useAnchor: Boolean
+		},
+		watch: {
+			html(html) {
+				this.setContent(html);
+			}
+		},
+		created() {
+			// 图片数组
+			this.imgList = [];
+			this.imgList.each = function(f) {
+				for (var i = 0, len = this.length; i < len; i++)
+					this.setItem(i, f(this[i], i, this));
+			}
+			this.imgList.setItem = function(i, src) {
+				if (i == void 0 || !src) return;
+				// #ifndef MP-ALIPAY || APP-PLUS
+				// 去重
+				if (src.indexOf('http') == 0 && this.includes(src)) {
+					var newSrc = src.split('://')[0];
+					for (var j = newSrc.length, c; c = src[j]; j++) {
+						if (c == '/' && src[j - 1] != '/' && src[j + 1] != '/') break;
+						newSrc += Math.random() > 0.5 ? c.toUpperCase() : c;
+					}
+					newSrc += src.substr(j);
+					return this[i] = newSrc;
+				}
+				// #endif
+				this[i] = src;
+				// 暂存 data src
+				if (src.includes('data:image')) {
+					var filePath, info = src.match(/data:image\/(\S+?);(\S+?),(.+)/);
+					if (!info) return;
+					// #ifdef MP-WEIXIN || MP-TOUTIAO
+					filePath = `${wx.env.USER_DATA_PATH}/${Date.now()}.${info[1]}`;
+					fs && fs.writeFile({
+						filePath,
+						data: info[3],
+						encoding: info[2],
+						success: () => this[i] = filePath
+					})
+					// #endif
+					// #ifdef APP-PLUS
+					filePath = `_doc/parser_tmp/${Date.now()}.${info[1]}`;
+					var bitmap = new plus.nativeObj.Bitmap();
+					bitmap.loadBase64Data(src, () => {
+						bitmap.save(filePath, {}, () => {
+							bitmap.clear()
+							this[i] = filePath;
+						})
+					})
+					// #endif
+				}
+			}
+		},
+		mounted() {
+			// #ifdef H5 || MP-360
+			this.document = document.getElementById('rtf' + this._uid);
+			// #endif
+			// #ifndef H5 || APP-PLUS-NVUE || MP-360
+			if (dom) this.document = new dom(this);
+			// #endif
+			// #ifdef APP-PLUS-NVUE
+			this.document = this.$refs.web;
+			setTimeout(() => {
+				// #endif
+				if (this.html) this.setContent(this.html);
+				// #ifdef APP-PLUS-NVUE
+			}, 30)
+			// #endif
+		},
+		beforeDestroy() {
+			// #ifdef H5 || MP-360
+			if (this._observer) this._observer.disconnect();
+			// #endif
+			this.imgList.each(src => {
+				// #ifdef APP-PLUS
+				if (src && src.includes('_doc')) {
+					plus.io.resolveLocalFileSystemURL(src, entry => {
+						entry.remove();
+					});
+				}
+				// #endif
+				// #ifdef MP-WEIXIN || MP-TOUTIAO
+				if (src && src.includes(uni.env.USER_DATA_PATH))
+					fs && fs.unlink({
+						filePath: src
+					})
+				// #endif
+			})
+			clearInterval(this._timer);
+		},
+		methods: {
+			// #ifdef H5 || APP-PLUS-NVUE || MP-360
+			_handleHtml(html, append) {
+				if (!append) {
+					// 处理 tag-style 和 userAgentStyles
+					var style = '<style>@keyframes _show{0%{opacity:0}100%{opacity:1}}img{max-width:100%}';
+					for (var item in cfg.userAgentStyles)
+						style += `${item}{${cfg.userAgentStyles[item]}}`;
+					for (item in this.tagStyle)
+						style += `${item}{${this.tagStyle[item]}}`;
+					style += '</style>';
+					html = style + html;
+				}
+				// 处理 rpx
+				if (html.includes('rpx'))
+					html = html.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * rpx + 'px');
+				return html;
+			},
+			// #endif
+			setContent(html, append) {
+				// #ifdef APP-PLUS-NVUE
+				if (!html)
+					return this.height = 1;
+				if (append)
+					this.$refs.web.evalJs("var b=document.createElement('div');b.innerHTML='" + html.replace(/'/g, "\\'") +
+						"';document.getElementById('parser').appendChild(b)");
+				else {
+					html =
+						'<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"><base href="' +
+						this.domain + '"><div id="parser"' + (this.selectable ? '>' : ' style="user-select:none">') + this._handleHtml(html).replace(/\n/g, '\\n') +
+						'</div><script>"use strict";function e(e){if(window.__dcloud_weex_postMessage||window.__dcloud_weex_){var t={data:[e]};window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessage(t):window.__dcloud_weex_.postMessage(JSON.stringify(t))}}' +
+						(this.showWithAnimation ? 'document.body.style.animation="_show .5s",' : '') +
+						'setTimeout(function(){e({action:"load",text:document.body.innerText,height:document.getElementById("parser").scrollHeight+16})},50);\x3c/script>';
+					this.$refs.web.evalJs("document.write('" + html.replace(/'/g, "\\'") + "');document.close()");
+				}
+				this.$refs.web.evalJs(
+					'var t=document.getElementsByTagName("title");t.length&&e({action:"getTitle",title:t[0].innerText});for(var o,n=document.getElementsByTagName("style"),r=0;o=n[r++];)o.innerHTML=o.innerHTML.replace(/body/g,"#parser");for(var i,a=document.getElementsByTagName("img"),s=[],c=0,l=0;i=a[c];c++)i.onerror=function(){' +
+					(cfg.errorImg ? 'this.src="' + cfg.errorImg + '",' : '') +
+					'e({action:"error",source:"img",target:this})},i.hasAttribute("ignore")||"A"==i.parentElement.nodeName||(i.i=l++,s.push(i.src),i.onclick=function(){e({action:"preview",img:{i:this.i,src:this.src}})});e({action:"getImgList",imgList:s});for(var d,u=document.getElementsByTagName("a"),g=0;d=u[g];g++)d.onclick=function(){var t,o=this.getAttribute("href");if("#"==o[0]){var n=document.getElementById(o.substr(1));n&&(t=n.offsetTop)}return e({action:"linkpress",href:o,offset:t}),!1};for(var m,f=document.getElementsByTagName("video"),h=0;m=f[h];h++)m.style.maxWidth="100%",m.onerror=function(){e({action:"error",source:"video",target:this})}' +
+					(this.autopause ? ',m.onplay=function(){for(var e,t=0;e=f[t];t++)e!=this&&e.pause()}' : '') +
+					';for(var v,y=document.getElementsByTagName("audio"),_=0;v=y[_];_++)v.onerror=function(){e({action:"error",source:"audio",target:this})};' +
+					(this.autoscroll ? 'for(var p,w=document.getElementsByTagName("table"),T=0;p=w[T];T++){var E=document.createElement("div");E.style.overflow="scroll",p.parentNode.replaceChild(E,p),E.appendChild(p)}' : '') +
+					'(function(){return new Promise(function(e){var t=document.getElementById("parser"),o=t.scrollHeight,n=setInterval(function(){o==t.scrollHeight?(clearInterval(n),e(o)):o=t.scrollHeight},500)})})().then(function(t){e({action:"ready",height:t+16})})'
+				)
+				this.nodes = [1];
+				// #endif
+				// #ifdef H5 || MP-360
+				if (!html) {
+					if (this.rtf && !append) this.rtf.parentNode.removeChild(this.rtf);
+					return;
+				}
+				var div = document.createElement('div');
+				if (!append) {
+					if (this.rtf) this.rtf.parentNode.removeChild(this.rtf);
+					this.rtf = div;
+				} else {
+					if (!this.rtf) this.rtf = div;
+					else this.rtf.appendChild(div);
+				}
+				div.innerHTML = this._handleHtml(html, append);
+				for (var styles = this.rtf.getElementsByTagName('style'), i = 0, style; style = styles[i++];) {
+					style.innerHTML = style.innerHTML.replace(/body/g, '#rtf' + this._uid);
+					style.setAttribute('scoped', 'true');
+				}
+				// 懒加载
+				if (!this._observer && this.lazyLoad && IntersectionObserver) {
+					this._observer = new IntersectionObserver(changes => {
+						for (let item, i = 0; item = changes[i++];) {
+							if (item.isIntersecting) {
+								item.target.src = item.target.getAttribute('data-src');
+								item.target.removeAttribute('data-src');
+								this._observer.unobserve(item.target);
+							}
+						}
+					}, {
+						rootMargin: '500px 0px 500px 0px'
+					})
+				}
+				var _ts = this;
+				// 获取标题
+				var title = this.rtf.getElementsByTagName('title');
+				if (title.length && this.autosetTitle)
+					uni.setNavigationBarTitle({
+						title: title[0].innerText
+					})
+				// 图片处理
+				this.imgList.length = 0;
+				var imgs = this.rtf.getElementsByTagName('img');
+				for (let i = 0, j = 0, img; img = imgs[i]; i++) {
+					var src = img.getAttribute('src');
+					if (this.domain && src) {
+						if (src[0] == '/') {
+							if (src[1] == '/')
+								img.src = (this.domain.includes('://') ? this.domain.split('://')[0] : '') + ':' + src;
+							else img.src = this.domain + src;
+						} else if (!src.includes('://')) img.src = this.domain + '/' + src;
+					}
+					if (!img.hasAttribute('ignore') && img.parentElement.nodeName != 'A') {
+						img.i = j++;
+						_ts.imgList.push(img.src || img.getAttribute('data-src'));
+						img.onclick = function() {
+							var preview = true;
+							this.ignore = () => preview = false;
+							_ts.$emit('imgtap', this);
+							if (preview) {
+								uni.previewImage({
+									current: this.i,
+									urls: _ts.imgList
+								});
+							}
+						}
+					}
+					img.onerror = function() {
+						if (cfg.errorImg)
+							_ts.imgList[this.i] = this.src = cfg.errorImg;
+						_ts.$emit('error', {
+							source: 'img',
+							target: this
+						});
+					}
+					if (_ts.lazyLoad && this._observer && img.src && img.i != 0) {
+						img.setAttribute('data-src', img.src);
+						img.removeAttribute('src');
+						this._observer.observe(img);
+					}
+				}
+				// 链接处理
+				var links = this.rtf.getElementsByTagName('a');
+				for (var link of links) {
+					link.onclick = function() {
+						var jump = true,
+							href = this.getAttribute('href');
+						_ts.$emit('linkpress', {
+							href,
+							ignore: () => jump = false
+						});
+						if (jump && href) {
+							if (href[0] == '#') {
+								if (_ts.useAnchor) {
+									_ts.navigateTo({
+										id: href.substr(1)
+									})
+								}
+							} else if (href.indexOf('http') == 0 || href.indexOf('//') == 0)
+								return true;
+							else
+								uni.navigateTo({
+									url: href
+								})
+						}
+						return false;
+					}
+				}
+				// 视频处理
+				var videos = this.rtf.getElementsByTagName('video');
+				_ts.videoContexts = videos;
+				for (let video, i = 0; video = videos[i++];) {
+					video.style.maxWidth = '100%';
+					video.onerror = function() {
+						_ts.$emit('error', {
+							source: 'video',
+							target: this
+						});
+					}
+					video.onplay = function() {
+						if (_ts.autopause)
+							for (let item, i = 0; item = _ts.videoContexts[i++];)
+								if (item != this) item.pause();
+					}
+				}
+				// 音频处理
+				var audios = this.rtf.getElementsByTagName('audio');
+				for (var audio of audios)
+					audio.onerror = function() {
+						_ts.$emit('error', {
+							source: 'audio',
+							target: this
+						});
+					}
+				// 表格处理
+				if (this.autoscroll) {
+					var tables = this.rtf.getElementsByTagName('table');
+					for (var table of tables) {
+						let div = document.createElement('div');
+						div.style.overflow = 'scroll';
+						table.parentNode.replaceChild(div, table);
+						div.appendChild(table);
+					}
+				}
+				if (!append) this.document.appendChild(this.rtf);
+				this.$nextTick(() => {
+					this.nodes = [1];
+					this.$emit('load');
+				});
+				setTimeout(() => this.showAm = '', 500);
+				// #endif
+				// #ifndef APP-PLUS-NVUE
+				// #ifndef H5 || MP-360
+				var nodes;
+				if (!html) return this.nodes = [];
+				var parser = new Parser(html, this);
+				// 缓存读取
+				if (this.useCache) {
+					var hashVal = hash(html);
+					if (cache[hashVal])
+						nodes = cache[hashVal];
+					else {
+						nodes = parser.parse();
+						cache[hashVal] = nodes;
+					}
+				} else nodes = parser.parse();
+				this.$emit('parse', nodes);
+				if (append) this.nodes = this.nodes.concat(nodes);
+				else this.nodes = nodes;
+				if (nodes.length && nodes.title && this.autosetTitle)
+					uni.setNavigationBarTitle({
+						title: nodes.title
+					})
+				if (this.imgList) this.imgList.length = 0;
+				this.videoContexts = [];
+				this.$nextTick(() => {
+					(function f(cs) {
+						for (var i = cs.length; i--;) {
+							if (cs[i].top) {
+								cs[i].controls = [];
+								cs[i].init();
+								f(cs[i].$children);
+							}
+						}
+					})(this.$children)
+					this.$emit('load');
+				})
+				// #endif
+				var height;
+				clearInterval(this._timer);
+				this._timer = setInterval(() => {
+					// #ifdef H5 || MP-360
+					this.rect = this.rtf.getBoundingClientRect();
+					// #endif
+					// #ifndef H5 || MP-360
+					uni.createSelectorQuery().in(this)
+						.select('#_top').boundingClientRect().exec(res => {
+							if (!res) return;
+							this.rect = res[0];
+							// #endif
+							if (this.rect.height == height) {
+								this.$emit('ready', this.rect)
+								clearInterval(this._timer);
+							}
+							height = this.rect.height;
+							// #ifndef H5 || MP-360
+						});
+					// #endif
+				}, 350);
+				if (this.showWithAnimation && !append) this.showAm = 'animation:_show .5s';
+				// #endif
+			},
+			getText(ns = this.nodes) {
+				var txt = '';
+				// #ifdef APP-PLUS-NVUE
+				txt = this._text;
+				// #endif
+				// #ifdef H5 || MP-360
+				txt = this.rtf.innerText;
+				// #endif
+				// #ifndef H5 || APP-PLUS-NVUE || MP-360
+				for (var i = 0, n; n = ns[i++];) {
+					if (n.type == 'text') txt += n.text.replace(/&nbsp;/g, '\u00A0').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
+						.replace(/&amp;/g, '&');
+					else if (n.type == 'br') txt += '\n';
+					else {
+						// 块级标签前后加换行
+						var block = n.name == 'p' || n.name == 'div' || n.name == 'tr' || n.name == 'li' || (n.name[0] == 'h' && n.name[1] >
+							'0' && n.name[1] < '7');
+						if (block && txt && txt[txt.length - 1] != '\n') txt += '\n';
+						if (n.children) txt += this.getText(n.children);
+						if (block && txt[txt.length - 1] != '\n') txt += '\n';
+						else if (n.name == 'td' || n.name == 'th') txt += '\t';
+					}
+				}
+				// #endif
+				return txt;
+			},
+			navigateTo(obj) {
+				if (!this.useAnchor)
+					return obj.fail && obj.fail({
+						errMsg: 'Anchor is disabled'
+					})
+				// #ifdef APP-PLUS-NVUE
+				if (!obj.id)
+					weexDom.scrollToElement(this.$refs.web);
+				else
+					this.$refs.web.evalJs('var pos=document.getElementById("' + obj.id +
+						'");if(pos)post({action:"linkpress",href:"#",offset:pos.offsetTop+' + (obj.offset || 0) + '})');
+				obj.success && obj.success({
+					errMsg: 'pageScrollTo:ok'
+				});
+				// #endif
+				// #ifdef H5 || MP-360
+				if (!obj.id) {
+					window.scrollTo(0, this.rtf.offsetTop);
+					return obj.success && obj.success({
+						errMsg: 'pageScrollTo:ok'
+					});
+				}
+				var target = document.getElementById(obj.id);
+				if (!target) return obj.fail && obj.fail({
+					errMsg: 'Label not found'
+				});
+				obj.scrollTop = this.rtf.offsetTop + target.offsetTop + (obj.offset || 0);
+				uni.pageScrollTo(obj);
+				// #endif
+				// #ifndef H5 || APP-PLUS-NVUE || MP-360
+				var d = ' ';
+				// #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
+				d = '>>>';
+				// #endif
+				uni.createSelectorQuery().in(this).select('#_top' + (obj.id ? d + '#' + obj.id + ',#_top' + d + '.' + obj.id : '')).boundingClientRect()
+					.selectViewport().scrollOffset().exec(res => {
+						if (!res || !res[0])
+							return obj.fail && obj.fail({
+								errMsg: 'Label not found'
+							});
+						obj.scrollTop = res[1].scrollTop + res[0].top + (obj.offset || 0);
+						// #ifdef MP-ALIPAY
+						obj.duration = 300;
+						my.
+						// #endif
+						// #ifndef MP-ALIPAY
+						uni.
+						// #endif
+						pageScrollTo(obj);
+					})
+				// #endif
+			},
+			getVideoContext(id) {
+				// #ifndef APP-PLUS-NVUE
+				if (!id) return this.videoContexts;
+				else
+					for (var i = this.videoContexts.length; i--;)
+						if (this.videoContexts[i].id == id) return this.videoContexts[i];
+				// #endif
+			},
+			// #ifdef APP-PLUS-NVUE
+			_message(e) {
+				// 接收 web-view 消息
+				var data = e.detail.data[0];
+				if (data.action == 'load') {
+					this.$emit('load');
+					this.height = data.height;
+					this._text = data.text;
+				} else if (data.action == 'getTitle') {
+					if (this.autosetTitle)
+						uni.setNavigationBarTitle({
+							title: data.title
+						})
+				} else if (data.action == 'getImgList') {
+					this.imgList.length = 0;
+					for (var i = data.imgList.length; i--;)
+						this.imgList.setItem(i, data.imgList[i]);
+				} else if (data.action == 'preview') {
+					var preview = true;
+					data.img.ignore = () => preview = false;
+					this.$emit('imgtap', data.img);
+					if (preview)
+						uni.previewImage({
+							current: data.img.i,
+							urls: this.imgList
+						})
+				} else if (data.action == 'linkpress') {
+					var jump = true,
+						href = data.href;
+					this.$emit('linkpress', {
+						href,
+						ignore: () => jump = false
+					})
+					if (jump && href) {
+						if (href[0] == '#') {
+							if (this.useAnchor)
+								weexDom.scrollToElement(this.$refs.web, {
+									offset: data.offset
+								})
+						} else if (href.includes('://'))
+							plus.runtime.openWeb(href);
+						else
+							uni.navigateTo({
+								url: href
+							})
+					}
+				} else if (data.action == 'error') {
+					if (data.source == 'img' && cfg.errorImg)
+						this.imgList.setItem(data.target.i, cfg.errorImg);
+					this.$emit('error', {
+						source: data.source,
+						target: data.target
+					})
+				} else if (data.action == 'ready') {
+					this.height = data.height;
+					this.$nextTick(() => {
+						uni.createSelectorQuery().in(this).select('#_top').boundingClientRect().exec(res => {
+							this.rect = res[0];
+							this.$emit('ready', res[0]);
+						})
+					})
+				}
+			},
+			// #endif
+		}
+	}
+</script>
+
+<style>
+	@keyframes _show {
+		0% {
+			opacity: 0;
+		}
+
+		100% {
+			opacity: 1;
+		}
+	}
+
+	/* #ifdef MP-WEIXIN */
+	:host {
+		display: block;
+		/* overflow: scroll; */
+		-webkit-overflow-scrolling: touch;
+	}
+
+	/* #endif */
+</style>

+ 97 - 0
addons/kefu/example/uni-customer/components/jyf-parser/libs/CssHandler.js

@@ -0,0 +1,97 @@
+const cfg = require('./config.js'),
+	isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+
+function CssHandler(tagStyle) {
+	var styles = Object.assign(Object.create(null), cfg.userAgentStyles);
+	for (var item in tagStyle)
+		styles[item] = (styles[item] ? styles[item] + ';' : '') + tagStyle[item];
+	this.styles = styles;
+}
+CssHandler.prototype.getStyle = function(data) {
+	this.styles = new parser(data, this.styles).parse();
+}
+CssHandler.prototype.match = function(name, attrs) {
+	var tmp, matched = (tmp = this.styles[name]) ? tmp + ';' : '';
+	if (attrs.class) {
+		var items = attrs.class.split(' ');
+		for (var i = 0, item; item = items[i]; i++)
+			if (tmp = this.styles['.' + item])
+				matched += tmp + ';';
+	}
+	if (tmp = this.styles['#' + attrs.id])
+		matched += tmp + ';';
+	return matched;
+}
+module.exports = CssHandler;
+
+function parser(data, init) {
+	this.data = data;
+	this.floor = 0;
+	this.i = 0;
+	this.list = [];
+	this.res = init;
+	this.state = this.Space;
+}
+parser.prototype.parse = function() {
+	for (var c; c = this.data[this.i]; this.i++)
+		this.state(c);
+	return this.res;
+}
+parser.prototype.section = function() {
+	return this.data.substring(this.start, this.i);
+}
+// 状态机
+parser.prototype.Space = function(c) {
+	if (c == '.' || c == '#' || isLetter(c)) {
+		this.start = this.i;
+		this.state = this.Name;
+	} else if (c == '/' && this.data[this.i + 1] == '*')
+		this.Comment();
+	else if (!cfg.blankChar[c] && c != ';')
+		this.state = this.Ignore;
+}
+parser.prototype.Comment = function() {
+	this.i = this.data.indexOf('*/', this.i) + 1;
+	if (!this.i) this.i = this.data.length;
+	this.state = this.Space;
+}
+parser.prototype.Ignore = function(c) {
+	if (c == '{') this.floor++;
+	else if (c == '}' && !--this.floor) this.state = this.Space;
+}
+parser.prototype.Name = function(c) {
+	if (cfg.blankChar[c]) {
+		this.list.push(this.section());
+		this.state = this.NameSpace;
+	} else if (c == '{') {
+		this.list.push(this.section());
+		this.Content();
+	} else if (c == ',') {
+		this.list.push(this.section());
+		this.Comma();
+	} else if (!isLetter(c) && (c < '0' || c > '9') && c != '-' && c != '_')
+		this.state = this.Ignore;
+}
+parser.prototype.NameSpace = function(c) {
+	if (c == '{') this.Content();
+	else if (c == ',') this.Comma();
+	else if (!cfg.blankChar[c]) this.state = this.Ignore;
+}
+parser.prototype.Comma = function() {
+	while (cfg.blankChar[this.data[++this.i]]);
+	if (this.data[this.i] == '{') this.Content();
+	else {
+		this.start = this.i--;
+		this.state = this.Name;
+	}
+}
+parser.prototype.Content = function() {
+	this.start = ++this.i;
+	if ((this.i = this.data.indexOf('}', this.i)) == -1) this.i = this.data.length;
+	var content = this.section();
+	for (var i = 0, item; item = this.list[i++];)
+		if (this.res[item]) this.res[item] += ';' + content;
+		else this.res[item] = content;
+	this.list = [];
+	this.state = this.Space;
+}

+ 534 - 0
addons/kefu/example/uni-customer/components/jyf-parser/libs/MpHtmlParser.js

@@ -0,0 +1,534 @@
+/**
+ * html 解析器
+ * @tutorial https://github.com/jin-yufeng/Parser
+ * @version 20200615
+ * @author JinYufeng
+ * @listens MIT
+ */
+const cfg = require('./config.js'),
+	blankChar = cfg.blankChar,
+	CssHandler = require('./CssHandler.js'),
+	windowWidth = uni.getSystemInfoSync().windowWidth;
+var emoji;
+
+function MpHtmlParser(data, options = {}) {
+	this.attrs = {};
+	this.CssHandler = new CssHandler(options.tagStyle, windowWidth);
+	this.data = data;
+	this.domain = options.domain;
+	this.DOM = [];
+	this.i = this.start = this.audioNum = this.imgNum = this.videoNum = 0;
+	options.prot = (this.domain || '').includes('://') ? this.domain.split('://')[0] : 'http';
+	this.options = options;
+	this.state = this.Text;
+	this.STACK = [];
+	// 工具函数
+	this.bubble = () => {
+		for (var i = this.STACK.length, item; item = this.STACK[--i];) {
+			if (cfg.richOnlyTags[item.name]) {
+				if (item.name == 'table' && !Object.hasOwnProperty.call(item, 'c')) item.c = 1;
+				return false;
+			}
+			item.c = 1;
+		}
+		return true;
+	}
+	this.decode = (val, amp) => {
+		var i = -1,
+			j, en;
+		while (1) {
+			if ((i = val.indexOf('&', i + 1)) == -1) break;
+			if ((j = val.indexOf(';', i + 2)) == -1) break;
+			if (val[i + 1] == '#') {
+				en = parseInt((val[i + 2] == 'x' ? '0' : '') + val.substring(i + 2, j));
+				if (!isNaN(en)) val = val.substr(0, i) + String.fromCharCode(en) + val.substr(j + 1);
+			} else {
+				en = val.substring(i + 1, j);
+				if (cfg.entities[en] || en == amp)
+					val = val.substr(0, i) + (cfg.entities[en] || '&') + val.substr(j + 1);
+			}
+		}
+		return val;
+	}
+	this.getUrl = url => {
+		if (url[0] == '/') {
+			if (url[1] == '/') url = this.options.prot + ':' + url;
+			else if (this.domain) url = this.domain + url;
+		} else if (this.domain && url.indexOf('data:') != 0 && !url.includes('://'))
+			url = this.domain + '/' + url;
+		return url;
+	}
+	this.isClose = () => this.data[this.i] == '>' || (this.data[this.i] == '/' && this.data[this.i + 1] == '>');
+	this.section = () => this.data.substring(this.start, this.i);
+	this.parent = () => this.STACK[this.STACK.length - 1];
+	this.siblings = () => this.STACK.length ? this.parent().children : this.DOM;
+}
+MpHtmlParser.prototype.parse = function() {
+	if (emoji) this.data = emoji.parseEmoji(this.data);
+	for (var c; c = this.data[this.i]; this.i++)
+		this.state(c);
+	if (this.state == this.Text) this.setText();
+	while (this.STACK.length) this.popNode(this.STACK.pop());
+	return this.DOM;
+}
+// 设置属性
+MpHtmlParser.prototype.setAttr = function() {
+	var name = this.attrName.toLowerCase(),
+		val = this.attrVal;
+	if (cfg.boolAttrs[name]) this.attrs[name] = 'T';
+	else if (val) {
+		if (name == 'src' || (name == 'data-src' && !this.attrs.src)) this.attrs.src = this.getUrl(this.decode(val, 'amp'));
+		else if (name == 'href' || name == 'style') this.attrs[name] = this.decode(val, 'amp');
+		else if (name.substr(0, 5) != 'data-') this.attrs[name] = val;
+	}
+	this.attrVal = '';
+	while (blankChar[this.data[this.i]]) this.i++;
+	if (this.isClose()) this.setNode();
+	else {
+		this.start = this.i;
+		this.state = this.AttrName;
+	}
+}
+// 设置文本节点
+MpHtmlParser.prototype.setText = function() {
+	var back, text = this.section();
+	if (!text) return;
+	text = (cfg.onText && cfg.onText(text, () => back = true)) || text;
+	if (back) {
+		this.data = this.data.substr(0, this.start) + text + this.data.substr(this.i);
+		let j = this.start + text.length;
+		for (this.i = this.start; this.i < j; this.i++) this.state(this.data[this.i]);
+		return;
+	}
+	if (!this.pre) {
+		// 合并空白符
+		var tmp = [];
+		for (let i = text.length, c; c = text[--i];)
+			if (!blankChar[c] || (!blankChar[tmp[0]] && (c = ' '))) tmp.unshift(c);
+		text = tmp.join('');
+	}
+	this.siblings().push({
+		type: 'text',
+		text: this.decode(text)
+	});
+}
+// 设置元素节点
+MpHtmlParser.prototype.setNode = function() {
+	var node = {
+			name: this.tagName.toLowerCase(),
+			attrs: this.attrs
+		},
+		close = cfg.selfClosingTags[node.name];
+	this.attrs = {};
+	if (!cfg.ignoreTags[node.name]) {
+		// 处理属性
+		var attrs = node.attrs,
+			style = this.CssHandler.match(node.name, attrs, node) + (attrs.style || ''),
+			styleObj = {};
+		if (attrs.id) {
+			if (this.options.compress & 1) attrs.id = void 0;
+			else if (this.options.useAnchor) this.bubble();
+		}
+		if ((this.options.compress & 2) && attrs.class) attrs.class = void 0;
+		switch (node.name) {
+			case 'a':
+			case 'ad': // #ifdef APP-PLUS
+			case 'iframe':
+				// #endif
+				this.bubble();
+				break;
+			case 'font':
+				if (attrs.color) {
+					styleObj['color'] = attrs.color;
+					attrs.color = void 0;
+				}
+				if (attrs.face) {
+					styleObj['font-family'] = attrs.face;
+					attrs.face = void 0;
+				}
+				if (attrs.size) {
+					var size = parseInt(attrs.size);
+					if (size < 1) size = 1;
+					else if (size > 7) size = 7;
+					var map = ['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'];
+					styleObj['font-size'] = map[size - 1];
+					attrs.size = void 0;
+				}
+				break;
+			case 'embed':
+				// #ifndef APP-PLUS
+				var src = node.attrs.src || '',
+					type = node.attrs.type || '';
+				if (type.includes('video') || src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8'))
+					node.name = 'video';
+				else if (type.includes('audio') || src.includes('.m4a') || src.includes('.wav') || src.includes('.mp3') || src.includes(
+						'.aac'))
+					node.name = 'audio';
+				else break;
+				if (node.attrs.autostart)
+					node.attrs.autoplay = 'T';
+				node.attrs.controls = 'T';
+				// #endif
+				// #ifdef APP-PLUS
+				this.bubble();
+				break;
+				// #endif
+			case 'video':
+			case 'audio':
+				if (!attrs.id) attrs.id = node.name + (++this[`${node.name}Num`]);
+				else this[`${node.name}Num`]++;
+				if (node.name == 'video') {
+					if (this.videoNum > 3)
+						node.lazyLoad = 1;
+					if (attrs.width) {
+						styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px');
+						attrs.width = void 0;
+					}
+					if (attrs.height) {
+						styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px');
+						attrs.height = void 0;
+					}
+				}
+				attrs.source = [];
+				if (attrs.src) {
+					attrs.source.push(attrs.src);
+					attrs.src = void 0;
+				}
+				this.bubble();
+				break;
+			case 'td':
+			case 'th':
+				if (attrs.colspan || attrs.rowspan)
+					for (var k = this.STACK.length, item; item = this.STACK[--k];)
+						if (item.name == 'table') {
+							item.c = void 0;
+							break;
+						}
+		}
+		if (attrs.align) {
+			styleObj['text-align'] = attrs.align;
+			attrs.align = void 0;
+		}
+		// 压缩 style
+		var styles = style.split(';');
+		style = '';
+		for (var i = 0, len = styles.length; i < len; i++) {
+			var info = styles[i].split(':');
+			if (info.length < 2) continue;
+			let key = info[0].trim().toLowerCase(),
+				value = info.slice(1).join(':').trim();
+			if (value.includes('-webkit') || value.includes('-moz') || value.includes('-ms') || value.includes('-o') || value.includes(
+					'safe'))
+				style += `;${key}:${value}`;
+			else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import'))
+				styleObj[key] = value;
+		}
+		if (node.name == 'img') {
+			if (attrs.src && !attrs.ignore) {
+				if (this.bubble())
+					attrs.i = (this.imgNum++).toString();
+				else attrs.ignore = 'T';
+			}
+			if (attrs.ignore) {
+				style += ';-webkit-touch-callout:none';
+				styleObj['max-width'] = '100%';
+			}
+			var width;
+			if (styleObj.width) width = styleObj.width;
+			else if (attrs.width) width = attrs.width.includes('%') ? attrs.width : attrs.width + 'px';
+			if (width) {
+				styleObj.width = width;
+				attrs.width = '100%';
+				if (parseInt(width) > windowWidth) {
+					styleObj.height = '';
+					if (attrs.height) attrs.height = void 0;
+				}
+			}
+			if (styleObj.height) {
+				attrs.height = styleObj.height;
+				styleObj.height = '';
+			} else if (attrs.height && !attrs.height.includes('%'))
+				attrs.height += 'px';
+		}
+		for (var key in styleObj) {
+			var value = styleObj[key];
+			if (!value) continue;
+			if (key.includes('flex') || key == 'order' || key == 'self-align') node.c = 1;
+			// 填充链接
+			if (value.includes('url')) {
+				var j = value.indexOf('(');
+				if (j++ != -1) {
+					while (value[j] == '"' || value[j] == "'" || blankChar[value[j]]) j++;
+					value = value.substr(0, j) + this.getUrl(value.substr(j));
+				}
+			}
+			// 转换 rpx
+			else if (value.includes('rpx'))
+				value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px');
+			else if (key == 'white-space' && value.includes('pre'))
+				this.pre = node.pre = true;
+			style += `;${key}:${value}`;
+		}
+		style = style.substr(1);
+		if (style) attrs.style = style;
+		if (!close) {
+			node.children = [];
+			if (node.name == 'pre' && cfg.highlight) {
+				this.remove(node);
+				this.pre = node.pre = true;
+			}
+			this.siblings().push(node);
+			this.STACK.push(node);
+		} else if (!cfg.filter || cfg.filter(node, this) != false)
+			this.siblings().push(node);
+	} else {
+		if (!close) this.remove(node);
+		else if (node.name == 'source') {
+			var parent = this.parent();
+			if (parent && (parent.name == 'video' || parent.name == 'audio') && node.attrs.src)
+				parent.attrs.source.push(node.attrs.src);
+		} else if (node.name == 'base' && !this.domain) this.domain = node.attrs.href;
+	}
+	if (this.data[this.i] == '/') this.i++;
+	this.start = this.i + 1;
+	this.state = this.Text;
+}
+// 移除标签
+MpHtmlParser.prototype.remove = function(node) {
+	var name = node.name,
+		j = this.i;
+	// 处理 svg
+	var handleSvg = () => {
+		var src = this.data.substring(j, this.i + 1);
+		if (!node.attrs.xmlns) src = ' xmlns="http://www.w3.org/2000/svg"' + src;
+		var i = j;
+		while (this.data[j] != '<') j--;
+		src = this.data.substring(j, i) + src;
+		var parent = this.parent();
+		if (node.attrs.width == '100%' && parent && (parent.attrs.style || '').includes('inline'))
+			parent.attrs.style = 'width:300px;max-width:100%;' + parent.attrs.style;
+		this.siblings().push({
+			name: 'img',
+			attrs: {
+				src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
+				style: (/vertical[^;]+/.exec(node.attrs.style) || []).shift(),
+				ignore: 'T'
+			}
+		})
+	}
+	if (node.name == 'svg' && this.data[j] == '/') return handleSvg(this.i++);
+	while (1) {
+		if ((this.i = this.data.indexOf('</', this.i + 1)) == -1) {
+			if (name == 'pre' || name == 'svg') this.i = j;
+			else this.i = this.data.length;
+			return;
+		}
+		this.start = (this.i += 2);
+		while (!blankChar[this.data[this.i]] && !this.isClose()) this.i++;
+		if (this.section().toLowerCase() == name) {
+			// 代码块高亮
+			if (name == 'pre') {
+				this.data = this.data.substr(0, j + 1) + cfg.highlight(this.data.substring(j + 1, this.i - 5), node.attrs) + this.data
+					.substr(this.i - 5);
+				return this.i = j;
+			} else if (name == 'style')
+				this.CssHandler.getStyle(this.data.substring(j + 1, this.i - 7));
+			else if (name == 'title')
+				this.DOM.title = this.data.substring(j + 1, this.i - 7);
+			if ((this.i = this.data.indexOf('>', this.i)) == -1) this.i = this.data.length;
+			if (name == 'svg') handleSvg();
+			return;
+		}
+	}
+}
+// 节点出栈处理
+MpHtmlParser.prototype.popNode = function(node) {
+	// 空白符处理
+	if (node.pre) {
+		node.pre = this.pre = void 0;
+		for (let i = this.STACK.length; i--;)
+			if (this.STACK[i].pre)
+				this.pre = true;
+	}
+	var siblings = this.siblings(),
+		len = siblings.length,
+		childs = node.children;
+	if (node.name == 'head' || (cfg.filter && cfg.filter(node, this) == false))
+		return siblings.pop();
+	var attrs = node.attrs;
+	// 替换一些标签名
+	if (cfg.blockTags[node.name]) node.name = 'div';
+	else if (!cfg.trustTags[node.name]) node.name = 'span';
+	// 去除块标签前后空串
+	if (node.name == 'div' || node.name == 'p' || node.name[0] == 't') {
+		if (len > 1 && siblings[len - 2].text == ' ')
+			siblings.splice(--len - 1, 1);
+		if (childs.length && childs[childs.length - 1].text == ' ')
+			childs.pop();
+	}
+	// 处理列表
+	if (node.c && (node.name == 'ul' || node.name == 'ol')) {
+		if ((node.attrs.style || '').includes('list-style:none')) {
+			for (let i = 0, child; child = childs[i++];)
+				if (child.name == 'li')
+					child.name = 'div';
+		} else if (node.name == 'ul') {
+			var floor = 1;
+			for (let i = this.STACK.length; i--;)
+				if (this.STACK[i].name == 'ul') floor++;
+			if (floor != 1)
+				for (let i = childs.length; i--;)
+					childs[i].floor = floor;
+		} else {
+			for (let i = 0, num = 1, child; child = childs[i++];)
+				if (child.name == 'li') {
+					child.type = 'ol';
+					child.num = ((num, type) => {
+						if (type == 'a') return String.fromCharCode(97 + (num - 1) % 26);
+						if (type == 'A') return String.fromCharCode(65 + (num - 1) % 26);
+						if (type == 'i' || type == 'I') {
+							num = (num - 1) % 99 + 1;
+							var one = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
+								ten = ['X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
+								res = (ten[Math.floor(num / 10) - 1] || '') + (one[num % 10 - 1] || '');
+							if (type == 'i') return res.toLowerCase();
+							return res;
+						}
+						return num;
+					})(num++, attrs.type) + '.';
+				}
+		}
+	}
+	// 处理表格的边框
+	if (node.name == 'table') {
+		var padding = attrs.cellpadding,
+			spacing = attrs.cellspacing,
+			border = attrs.border;
+		if (node.c) {
+			this.bubble();
+			attrs.style = (attrs.style || '') + ';display:table';
+			if (!padding) padding = 2;
+			if (!spacing) spacing = 2;
+		}
+		if (border) attrs.style = `border:${border}px solid gray;${attrs.style || ''}`;
+		if (spacing) attrs.style = `border-spacing:${spacing}px;${attrs.style || ''}`;
+		if (border || padding || node.c)
+			(function f(ns) {
+				for (var i = 0, n; n = ns[i]; i++) {
+					if (n.type == 'text') continue;
+					var style = n.attrs.style || '';
+					if (node.c && n.name[0] == 't') {
+						n.c = 1;
+						style += ';display:table-' + (n.name == 'th' || n.name == 'td' ? 'cell' : (n.name == 'tr' ? 'row' : 'row-group'));
+					}
+					if (n.name == 'th' || n.name == 'td') {
+						if (border) style = `border:${border}px solid gray;${style}`;
+						if (padding) style = `padding:${padding}px;${style}`;
+					} else f(n.children || []);
+					if (style) n.attrs.style = style;
+				}
+			})(childs)
+		if (this.options.autoscroll) {
+			var table = Object.assign({}, node);
+			node.name = 'div';
+			node.attrs = {
+				style: 'overflow:scroll'
+			}
+			node.children = [table];
+		}
+	}
+	this.CssHandler.pop && this.CssHandler.pop(node);
+	// 自动压缩
+	if (node.name == 'div' && !Object.keys(attrs).length && childs.length == 1 && childs[0].name == 'div')
+		siblings[len - 1] = childs[0];
+}
+// 状态机
+MpHtmlParser.prototype.Text = function(c) {
+	if (c == '<') {
+		var next = this.data[this.i + 1],
+			isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+		if (isLetter(next)) {
+			this.setText();
+			this.start = this.i + 1;
+			this.state = this.TagName;
+		} else if (next == '/') {
+			this.setText();
+			if (isLetter(this.data[++this.i + 1])) {
+				this.start = this.i + 1;
+				this.state = this.EndTag;
+			} else this.Comment();
+		} else if (next == '!') {
+			this.setText();
+			this.Comment();
+		}
+	}
+}
+MpHtmlParser.prototype.Comment = function() {
+	var key;
+	if (this.data.substring(this.i + 2, this.i + 4) == '--') key = '-->';
+	else if (this.data.substring(this.i + 2, this.i + 9) == '[CDATA[') key = ']]>';
+	else key = '>';
+	if ((this.i = this.data.indexOf(key, this.i + 2)) == -1) this.i = this.data.length;
+	else this.i += key.length - 1;
+	this.start = this.i + 1;
+	this.state = this.Text;
+}
+MpHtmlParser.prototype.TagName = function(c) {
+	if (blankChar[c]) {
+		this.tagName = this.section();
+		while (blankChar[this.data[this.i]]) this.i++;
+		if (this.isClose()) this.setNode();
+		else {
+			this.start = this.i;
+			this.state = this.AttrName;
+		}
+	} else if (this.isClose()) {
+		this.tagName = this.section();
+		this.setNode();
+	}
+}
+MpHtmlParser.prototype.AttrName = function(c) {
+	if (c == '=' || blankChar[c] || this.isClose()) {
+		this.attrName = this.section();
+		if (blankChar[c])
+			while (blankChar[this.data[++this.i]]);
+		if (this.data[this.i] == '=') {
+			while (blankChar[this.data[++this.i]]);
+			this.start = this.i--;
+			this.state = this.AttrValue;
+		} else this.setAttr();
+	}
+}
+MpHtmlParser.prototype.AttrValue = function(c) {
+	if (c == '"' || c == "'") {
+		this.start++;
+		if ((this.i = this.data.indexOf(c, this.i + 1)) == -1) return this.i = this.data.length;
+		this.attrVal = this.section();
+		this.i++;
+	} else {
+		for (; !blankChar[this.data[this.i]] && !this.isClose(); this.i++);
+		this.attrVal = this.section();
+	}
+	this.setAttr();
+}
+MpHtmlParser.prototype.EndTag = function(c) {
+	if (blankChar[c] || c == '>' || c == '/') {
+		var name = this.section().toLowerCase();
+		for (var i = this.STACK.length; i--;)
+			if (this.STACK[i].name == name) break;
+		if (i != -1) {
+			var node;
+			while ((node = this.STACK.pop()).name != name) this.popNode(node);
+			this.popNode(node);
+		} else if (name == 'p' || name == 'br')
+			this.siblings().push({
+				name,
+				attrs: {}
+			});
+		this.i = this.data.indexOf('>', this.i);
+		this.start = this.i + 1;
+		if (this.i == -1) this.i = this.data.length;
+		else this.state = this.Text;
+	}
+}
+module.exports = MpHtmlParser;

+ 93 - 0
addons/kefu/example/uni-customer/components/jyf-parser/libs/config.js

@@ -0,0 +1,93 @@
+/* 配置文件 */
+// #ifdef MP-WEIXIN
+const canIUse = wx.canIUse('editor'); // 高基础库标识,用于兼容
+// #endif
+module.exports = {
+	// 出错占位图
+	errorImg: null,
+	// 过滤器函数
+	filter: null,
+	// 代码高亮函数
+	highlight: null,
+	// 文本处理函数
+	onText: null,
+	// 实体编码列表
+	entities: {
+		quot: '"',
+		apos: "'",
+		semi: ';',
+		nbsp: '\xA0',
+		ensp: '\u2002',
+		emsp: '\u2003',
+		ndash: '–',
+		mdash: '—',
+		middot: '·',
+		lsquo: '‘',
+		rsquo: '’',
+		ldquo: '“',
+		rdquo: '”',
+		bull: '•',
+		hellip: '…'
+	},
+	blankChar: makeMap(' ,\xA0,\t,\r,\n,\f'),
+	boolAttrs: makeMap('allowfullscreen,autoplay,autostart,controls,ignore,loop,muted'),
+	// 块级标签,将被转为 div
+	blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,section' + (
+		// #ifdef MP-WEIXIN
+		canIUse ? '' :
+		// #endif
+		',pre')),
+	// 将被移除的标签
+	ignoreTags: makeMap(
+		'area,base,canvas,frame,input,link,map,meta,param,script,source,style,svg,textarea,title,track,wbr'
+		// #ifdef MP-WEIXIN
+		+ (canIUse ? ',rp' : '')
+		// #endif
+		// #ifndef APP-PLUS
+		+ ',iframe'
+		// #endif
+	),
+	// 只能被 rich-text 显示的标签
+	richOnlyTags: makeMap('a,colgroup,fieldset,legend,table'
+		// #ifdef MP-WEIXIN
+		+ (canIUse ? ',bdi,bdo,caption,rt,ruby' : '')
+		// #endif
+	),
+	// 自闭合的标签
+	selfClosingTags: makeMap(
+		'area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'
+	),
+	// 信任的标签
+	trustTags: makeMap(
+		'a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video'
+		// #ifdef MP-WEIXIN
+		+ (canIUse ? ',bdi,bdo,caption,pre,rt,ruby' : '')
+		// #endif
+		// #ifdef APP-PLUS
+		+ ',embed,iframe'
+		// #endif
+	),
+	// 默认的标签样式
+	userAgentStyles: {
+		address: 'font-style:italic',
+		big: 'display:inline;font-size:1.2em',
+		blockquote: 'background-color:#f6f6f6;border-left:3px solid #dbdbdb;color:#6c6c6c;padding:5px 0 5px 10px',
+		caption: 'display:table-caption;text-align:center',
+		center: 'text-align:center',
+		cite: 'font-style:italic',
+		dd: 'margin-left:40px',
+		mark: 'background-color:yellow',
+		pre: 'font-family:monospace;white-space:pre;overflow:scroll',
+		s: 'text-decoration:line-through',
+		small: 'display:inline;font-size:0.8em',
+		u: 'text-decoration:underline'
+	}
+}
+
+function makeMap(str) {
+	var map = Object.create(null),
+		list = str.split(',');
+	for (var i = list.length; i--;)
+		map[list[i]] = true;
+	return map;
+}

+ 20 - 0
addons/kefu/example/uni-customer/components/jyf-parser/libs/handler.wxs

@@ -0,0 +1,20 @@
+var inline = {
+	abbr: 1,
+	b: 1,
+	big: 1,
+	code: 1,
+	del: 1,
+	em: 1,
+	i: 1,
+	ins: 1,
+	label: 1,
+	q: 1,
+	small: 1,
+	span: 1,
+	strong: 1
+}
+module.exports = {
+	use: function(item) {
+		return !item.c && !inline[item.name] && (item.attrs.style || '').indexOf('display:inline') == -1
+	}
+}

+ 519 - 0
addons/kefu/example/uni-customer/components/jyf-parser/libs/trees.vue

@@ -0,0 +1,519 @@
+<template>
+	<view class="interlayer">
+		<block v-for="(n, i) in nodes" v-bind:key="i">
+			<!--图片-->
+			<view v-if="n.name=='img'" :class="'_img '+n.attrs.class" :style="n.attrs.style" :data-attrs="n.attrs" @tap="imgtap">
+				<rich-text v-if="controls[i]!=0" :nodes="[{attrs:{src:loading&&(controls[i]||0)<2?loading:(lazyLoad&&!controls[i]?placeholder:(controls[i]==3?errorImg:n.attrs.src||'')),alt:n.attrs.alt||'',width:n.attrs.width||'',style:'-webkit-touch-callout:none;max-width:100%;display:block'+(n.attrs.height?';height:'+n.attrs.height:'')},name:'img'}]" />
+				<image class="_image" :src="lazyLoad&&!controls[i]?placeholder:n.attrs.src" :lazy-load="lazyLoad"
+				 :show-menu-by-longpress="!n.attrs.ignore" :data-i="i" :data-index="n.attrs.i" data-source="img" @load="loadImg"
+				 @error="error" />
+			</view>
+			<!--文本-->
+			<text v-else-if="n.type=='text'" decode>{{n.text}}</text>
+			<!--#ifndef MP-BAIDU-->
+			<text v-else-if="n.name=='br'">\n</text>
+			<!--#endif-->
+			<!--视频-->
+			<view v-else-if="((n.lazyLoad&&!n.attrs.autoplay)||(n.name=='video'&&!loadVideo))&&controls[i]==undefined" :id="n.attrs.id" :class="'_video '+(n.attrs.class||'')"
+			 :style="n.attrs.style" :data-i="i" @tap="_loadVideo" />
+			<video v-else-if="n.name=='video'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :autoplay="n.attrs.autoplay||controls[i]==0"
+			 :controls="!n.attrs.autoplay||n.attrs.controls" :loop="n.attrs.loop" :muted="n.attrs.muted" :poster="n.attrs.poster" :src="n.attrs.source[controls[i]||0]"
+			 :unit-id="n.attrs['unit-id']" :data-id="n.attrs.id" :data-i="i" data-source="video" @error="error" @play="play" />
+			<!--音频-->
+			<audio v-else-if="n.name=='audio'" :ref="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :author="n.attrs.author"
+			 :autoplay="n.attrs.autoplay" :controls="n.attrs.controls" :loop="n.attrs.loop" :name="n.attrs.name" :poster="n.attrs.poster"
+			 :src="n.attrs.source[controls[i]||0]" :data-i="i" :data-id="n.attrs.id" data-source="audio"
+			 @error.native="error" @play.native="play" />
+			<!--链接-->
+			<view v-else-if="n.name=='a'" :id="n.attrs.id" :class="'_a '+(n.attrs.class||'')" hover-class="_hover" :style="n.attrs.style"
+			 :data-attrs="n.attrs" @tap="linkpress">
+				<trees class="_span" :nodes="n.children" />
+			</view>
+			<!--广告-->
+			<!--<ad v-else-if="n.name=='ad'" :class="n.attrs.class" :style="n.attrs.style" :unit-id="n.attrs['unit-id']" :appid="n.attrs.appid" :apid="n.attrs.apid" :type="n.attrs.type" :adpid="n.attrs.adpid" data-source="ad" @error="error" />-->
+			<!--列表-->
+			<view v-else-if="n.name=='li'" :id="n.attrs.id" :class="n.attrs.class" :style="(n.attrs.style||'')+';display:flex'">
+				<view v-if="n.type=='ol'" class="_ol-bef">{{n.num}}</view>
+				<view v-else class="_ul-bef">
+					<view v-if="n.floor%3==0" class="_ul-p1">█</view>
+					<view v-else-if="n.floor%3==2" class="_ul-p2" />
+					<view v-else class="_ul-p1" style="border-radius:50%">█</view>
+				</view>
+				<!--#ifdef MP-ALIPAY-->
+				<view class="_li">
+					<trees :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
+				</view>
+				<!--#endif-->
+				<!--#ifndef MP-ALIPAY-->
+				<trees class="_li" :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
+				<!--#endif-->
+			</view>
+			<!--表格-->
+			<view v-else-if="n.name=='table'&&n.c" :id="n.attrs.id" :class="n.attrs.class" :style="(n.attrs.style||'')+';display:table'">
+				<view v-for="(tbody, o) in n.children" v-bind:key="o" :class="tbody.attrs.class" :style="(tbody.attrs.style||'')+(tbody.name[0]=='t'?';display:table-'+(tbody.name=='tr'?'row':'row-group'):'')">
+					<view v-for="(tr, p) in tbody.children" v-bind:key="p" :class="tr.attrs.class" :style="(tr.attrs.style||'')+(tr.name[0]=='t'?';display:table-'+(tr.name=='tr'?'row':'cell'):'')">
+						<trees v-if="tr.name=='td'" :nodes="tr.children" />
+						<block v-else>
+							<!--#ifdef MP-ALIPAY-->
+							<view v-for="(td, q) in tr.children" v-bind:key="q" :class="td.attrs.class" :style="(td.attrs.style||'')+(td.name[0]=='t'?';display:table-'+(td.name=='tr'?'row':'cell'):'')">
+								<trees :nodes="td.children" />
+							</view>
+							<!--#endif-->
+							<!--#ifndef MP-ALIPAY-->
+							<trees v-for="(td, q) in tr.children" v-bind:key="q" :class="td.attrs.class" :style="(td.attrs.style||'')+(td.name[0]=='t'?';display:table-'+(td.name=='tr'?'row':'cell'):'')"
+							 :nodes="td.children" />
+							<!--#endif-->
+						</block>
+					</view>
+				</view>
+			</view>
+			<!--#ifdef APP-PLUS-->
+			<iframe v-else-if="n.name=='iframe'" :style="n.attrs.style" :allowfullscreen="n.attrs.allowfullscreen" :frameborder="n.attrs.frameborder"
+			 :width="n.attrs.width" :height="n.attrs.height" :src="n.attrs.src" />
+			<embed v-else-if="n.name=='embed'" :style="n.attrs.style" :width="n.attrs.width" :height="n.attrs.height" :src="n.attrs.src" />
+			<!--#endif-->
+			<!--富文本-->
+			<!--#ifdef MP-WEIXIN || MP-QQ || APP-PLUS-->
+			<rich-text v-else-if="handler.use(n)" :id="n.attrs.id" :class="'_p __'+n.name" :nodes="[n]" />
+			<!--#endif-->
+			<!--#ifndef MP-WEIXIN || MP-QQ || APP-PLUS-->
+			<rich-text v-else-if="!n.c" :id="n.attrs.id" :nodes="[n]" style="display:inline" />
+			<!--#endif-->
+			<!--#ifdef MP-ALIPAY-->
+			<view v-else :id="n.attrs.id" :class="'_'+n.name+' '+(n.attrs.class||'')" :style="n.attrs.style">
+				<trees :nodes="n.children" :lazyLoad="lazyLoad" :loading="loading" />
+			</view>
+			<!--#endif-->
+			<!--#ifndef MP-ALIPAY-->
+			<trees v-else :class="(n.attrs.id||'')+' _'+n.name+' '+(n.attrs.class||'')" :style="n.attrs.style" :nodes="n.children"
+			 :lazyLoad="lazyLoad" :loading="loading" />
+			<!--#endif-->
+		</block>
+	</view>
+</template>
+<script module="handler" lang="wxs" src="./handler.wxs"></script>
+<script>
+	global.Parser = {};
+	import trees from './trees'
+	const errorImg = require('../libs/config.js').errorImg;
+	export default {
+		components: {
+			trees
+		},
+		name: 'trees',
+		data() {
+			return {
+				controls: [],
+				placeholder: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="300" height="225"/>',
+				errorImg,
+				loadVideo:
+					// #ifdef APP-PLUS
+					false
+				// #endif
+				// #ifndef APP-PLUS
+				true
+				// #endif
+			}
+		},
+		props: {
+			nodes: Array,
+			lazyLoad: Boolean,
+			loading: String
+		},
+		mounted() {
+			for (this.top = this.$parent; this.top.$options.name != 'parser'; this.top = this.top.$parent);
+			this.init();
+		},
+		// #ifdef APP-PLUS
+		beforeDestroy() {
+			this.observer && this.observer.disconnect();
+		},
+		// #endif
+		methods: {
+			init() {
+				for (var i = this.nodes.length, n; n = this.nodes[--i];) {
+					if (n.name == 'img') {
+						this.top.imgList.setItem(n.attrs.i, n.attrs.src);
+						// #ifdef APP-PLUS
+						if (this.lazyLoad && !this.observer) {
+							this.observer = uni.createIntersectionObserver(this).relativeToViewport({
+								top: 500,
+								bottom: 500
+							});
+							this.$nextTick(() => {
+								this.observer.observe('._img', res => {
+									if (res.intersectionRatio) {
+										for (var j = this.nodes.length; j--;)
+											if (this.nodes[j].name == 'img')
+												this.$set(this.controls, j, 1);
+										this.observer.disconnect();
+									}
+								})
+							})
+						}
+						// #endif
+					} else if (n.name == 'video' || n.name == 'audio') {
+						var ctx;
+						if (n.name == 'video') {
+							ctx = uni.createVideoContext(n.attrs.id
+								// #ifndef MP-BAIDU
+								, this
+								// #endif
+							);
+						} else if (this.$refs[n.attrs.id])
+							ctx = this.$refs[n.attrs.id][0];
+						if (ctx) {
+							ctx.id = n.attrs.id;
+							this.top.videoContexts.push(ctx);
+						}
+					}
+				}
+				// #ifdef APP-PLUS
+				// APP 上避免 video 错位需要延时渲染
+				setTimeout(() => {
+					this.loadVideo = true;
+				}, 1000)
+				// #endif
+			},
+			play(e) {
+				var contexts = this.top.videoContexts;
+				if (contexts.length > 1 && this.top.autopause)
+					for (var i = contexts.length; i--;)
+						if (contexts[i].id != e.currentTarget.dataset.id)
+							contexts[i].pause();
+			},
+			imgtap(e) {
+				var attrs = e.currentTarget.dataset.attrs;
+				if (!attrs.ignore) {
+					var preview = true,
+						data = {
+							id: e.target.id,
+							src: attrs.src,
+							ignore: () => preview = false
+						};
+					global.Parser.onImgtap && global.Parser.onImgtap(data);
+					this.top.$emit('imgtap', data);
+					if (preview) {
+						var urls = this.top.imgList,
+							current = urls[attrs.i] ? parseInt(attrs.i) : (urls = [attrs.src], 0);
+						uni.previewImage({
+							current,
+							urls
+						})
+					}
+				}
+			},
+			loadImg(e) {
+				var i = e.currentTarget.dataset.i;
+				if (this.lazyLoad && !this.controls[i]) {
+					// #ifdef QUICKAPP-WEBVIEW
+					this.$set(this.controls, i, 0);
+					this.$nextTick(function() {
+						// #endif
+						// #ifndef APP-PLUS
+						this.$set(this.controls, i, 1);
+						// #endif
+						// #ifdef QUICKAPP-WEBVIEW
+					})
+					// #endif
+				} else if (this.loading && this.controls[i] != 2) {
+					// #ifdef QUICKAPP-WEBVIEW
+					this.$set(this.controls, i, 0);
+					this.$nextTick(function() {
+						// #endif
+						this.$set(this.controls, i, 2);
+						// #ifdef QUICKAPP-WEBVIEW
+					})
+					// #endif
+				}
+			},
+			linkpress(e) {
+				var jump = true,
+					attrs = e.currentTarget.dataset.attrs;
+				attrs.ignore = () => jump = false;
+				global.Parser.onLinkpress && global.Parser.onLinkpress(attrs);
+				this.top.$emit('linkpress', attrs);
+				if (jump) {
+					// #ifdef MP
+					if (attrs['app-id']) {
+						return uni.navigateToMiniProgram({
+							appId: attrs['app-id'],
+							path: attrs.path
+						})
+					}
+					// #endif
+					if (attrs.href) {
+						if (attrs.href[0] == '#') {
+							if (this.top.useAnchor)
+								this.top.navigateTo({
+									id: attrs.href.substring(1)
+								})
+						} else if (attrs.href.indexOf('http') == 0 || attrs.href.indexOf('//') == 0) {
+							// #ifdef APP-PLUS
+							plus.runtime.openWeb(attrs.href);
+							// #endif
+							// #ifndef APP-PLUS
+							uni.setClipboardData({
+								data: attrs.href,
+								success: () =>
+									uni.showToast({
+										title: '链接已复制'
+									})
+							})
+							// #endif
+						} else
+							uni.navigateTo({
+								url: attrs.href,
+								fail() {
+									uni.switchTab({
+										url: attrs.href,
+									})
+								}
+							})
+					}
+				}
+			},
+			error(e) {
+				var target = e.currentTarget,
+					source = target.dataset.source,
+					i = target.dataset.i;
+				if (source == 'video' || source == 'audio') {
+					// 加载其他 source
+					var index = this.controls[i] ? this.controls[i].i + 1 : 1;
+					if (index < this.nodes[i].attrs.source.length)
+						this.$set(this.controls, i, index);
+					if (e.detail.__args__)
+						e.detail = e.detail.__args__[0];
+				} else if (errorImg && source == 'img') {
+					this.top.imgList.setItem(target.dataset.index, errorImg);
+					this.$set(this.controls, i, 3);
+				}
+				this.top && this.top.$emit('error', {
+					source,
+					target,
+					errMsg: e.detail.errMsg
+				});
+			},
+			_loadVideo(e) {
+				this.$set(this.controls, e.target.dataset.i, 0);
+			}
+		}
+	}
+</script>
+
+<style>
+	/* 在这里引入自定义样式 */
+
+	/* 链接和图片效果 */
+	._a {
+		display: inline;
+		padding: 1.5px 0 1.5px 0;
+		color: #366092;
+		word-break: break-all;
+	}
+
+	._hover {
+		text-decoration: underline;
+		opacity: 0.7;
+	}
+
+	._img {
+		position: relative;
+		display: inline-block;
+		max-width: 100%;
+	}
+
+	/* #ifdef MP-WEIXIN */
+	:host {
+		display: inline;
+	}
+
+	/* #endif */
+
+	/* #ifdef MP || QUICKAPP-WEBVIEW */
+	.interlayer {
+		display: inherit;
+		flex-direction: inherit;
+		flex-wrap: inherit;
+		align-content: inherit;
+		align-items: inherit;
+		justify-content: inherit;
+		width: 100%;
+		white-space: inherit;
+	}
+
+	/* #endif */
+
+	._b,
+	._strong {
+		font-weight: bold;
+	}
+
+	._blockquote,
+	._div,
+	._p,
+	._ol,
+	._ul,
+	._li {
+		display: block;
+	}
+
+	._code {
+		font-family: monospace;
+	}
+
+	._del {
+		text-decoration: line-through;
+	}
+
+	._em,
+	._i {
+		font-style: italic;
+	}
+
+	._h1 {
+		font-size: 2em;
+	}
+
+	._h2 {
+		font-size: 1.5em;
+	}
+
+	._h3 {
+		font-size: 1.17em;
+	}
+
+	._h5 {
+		font-size: 0.83em;
+	}
+
+	._h6 {
+		font-size: 0.67em;
+	}
+
+	._h1,
+	._h2,
+	._h3,
+	._h4,
+	._h5,
+	._h6 {
+		display: block;
+		font-weight: bold;
+	}
+
+	._image {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		opacity: 0;
+	}
+
+	._ins {
+		text-decoration: underline;
+	}
+
+	._li {
+		flex: 1;
+		width: 0;
+	}
+
+	._ol-bef {
+		width: 36px;
+		margin-right: 5px;
+		text-align: right;
+	}
+
+	._ul-bef {
+		margin: 0 12px 0 23px;
+		line-height: normal;
+	}
+
+	._ol-bef,
+	._ul_bef {
+		flex: none;
+		user-select: none;
+	}
+
+	._ul-p1 {
+		display: inline-block;
+		width: 0.3em;
+		height: 0.3em;
+		overflow: hidden;
+		line-height: 0.3em;
+	}
+
+	._ul-p2 {
+		display: inline-block;
+		width: 0.23em;
+		height: 0.23em;
+		border: 0.05em solid black;
+		border-radius: 50%;
+	}
+
+	._q::before {
+		content: '"';
+	}
+
+	._q::after {
+		content: '"';
+	}
+
+	._sub {
+		font-size: smaller;
+		vertical-align: sub;
+	}
+
+	._sup {
+		font-size: smaller;
+		vertical-align: super;
+	}
+
+	/* #ifdef MP-ALIPAY || APP-PLUS || QUICKAPP-WEBVIEW*/
+	._abbr,
+	._b,
+	._code,
+	._del,
+	._em,
+	._i,
+	._ins,
+	._label,
+	._q,
+	._span,
+	._strong,
+	._sub,
+	._sup {
+		display: inline;
+	}
+
+	/* #endif */
+
+	/* #ifdef MP-WEIXIN || MP-QQ */
+	.__bdo,
+	.__bdi,
+	.__ruby,
+	.__rt {
+		display: inline-block;
+	}
+
+	/* #endif */
+	._video {
+		position: relative;
+		display: inline-block;
+		width: 300px;
+		height: 225px;
+		background-color: black;
+	}
+
+	._video::after {
+		position: absolute;
+		top: 50%;
+		left: 50%;
+		margin: -15px 0 0 -15px;
+		content: '';
+		border-color: transparent transparent transparent white;
+		border-style: solid;
+		border-width: 15px 0 15px 30px;
+	}
+</style>

+ 11 - 0
addons/kefu/example/uni-customer/main.js

@@ -0,0 +1,11 @@
+import Vue from 'vue'
+import App from './App'
+
+Vue.config.productionTip = false
+
+App.mpType = 'app'
+
+const app = new Vue({
+    ...App
+})
+app.$mount()

+ 86 - 0
addons/kefu/example/uni-customer/manifest.json

@@ -0,0 +1,86 @@
+{
+    "name" : "在线客服",
+    "appid" : "__UNI__4E887D0",
+    "description" : "FastAdmin在线客服插件uni-app",
+    "versionName" : "1.0.0",
+    "versionCode" : "100",
+    "transformPx" : false,
+    /* 5+App特有相关 */
+    "app-plus" : {
+        "usingComponents" : true,
+        "nvueCompiler" : "uni-app",
+        "compilerVersion" : 3,
+        "splashscreen" : {
+            "alwaysShowBeforeRender" : true,
+            "waiting" : true,
+            "autoclose" : true,
+            "delay" : 0
+        },
+        /* 模块配置 */
+        "modules" : {},
+        /* 应用发布信息 */
+        "distribute" : {
+            /* android打包配置 */
+            "android" : {
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>",
+                    "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+                    "<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
+                    "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
+                    "<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
+                    "<uses-permission android:name=\"android.permission.CALL_PHONE\"/>",
+                    "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
+                ]
+            },
+            /* ios打包配置 */
+            "ios" : {},
+            /* SDK配置 */
+            "sdkConfigs" : {}
+        }
+    },
+    /* 快应用特有相关 */
+    "quickapp" : {},
+    /* 小程序特有相关 */
+    "mp-weixin" : {
+        "appid" : "wx4552387af63efaec",
+        "setting" : {
+            "urlCheck" : false,
+            "es6" : false
+        },
+        "usingComponents" : true
+    },
+    "mp-alipay" : {
+        "usingComponents" : true
+    },
+    "mp-baidu" : {
+        "usingComponents" : true
+    },
+    "mp-toutiao" : {
+        "usingComponents" : true
+    },
+    "h5" : {
+        "router" : {
+            "mode" : "hash",
+            "base" : "/h5/"
+        },
+        "devServer" : {
+            "https" : false
+        },
+        "title" : "在线客服"
+    }
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است