分类 后端 下的文章

在自己电脑(Windows)开发测试代码都没问题,但一上生产环境就报错了。经过对比,本机和服务器的PHP版本和OpenSSL版本不一样,猜测可能是这个原因导致的。经过一番查找,找到了从代码上解决问题的办法,规避了调整生产服务器的风险。

报错的代码

/**
 * 字符串加密(加密方法:DES-ECB)
 * @param string $data 待加密字符串
 * @param string $key 对称加密密钥
 * @return string
 */
function encryptData(string $data, string $key)
{
    // 获取密码iv长度
    $length = openssl_cipher_iv_length('DES-ECB');
    // 生成一个伪随机字节串
    $iv = openssl_random_pseudo_bytes($length);
    // 加密数据
    $ciphertext = openssl_encrypt($data, 'DES-ECB', $key, OPENSSL_RAW_DATA, $iv);

    // 把包含数据的二进制字符串转换为十六进制值,然后返回结果
    return bin2hex($ciphertext);
}

报错信息

Fatal error: Uncaught Error: Length must be greater than 0 in frame.php:87
Stack trace: 
    #0 frame.php(87): openssl_random_pseudo_bytes()
    #1 frame.php(60): encryptData()
    #2 {main} thrown in frame.php on line 87

根据报错信息,主要问题是openssl_cipher_iv_length()返回的长度为0,而openssl_random_pseudo_bytes()的参数的长度必须大于0,所以就产生了报错。
但是openssl_cipher_iv_length()为什么返回0呢?难道是不支持DES-ECB加密方法?
使用openssl_get_cipher_methods()方法获取可用的加密算法的列表,发现DES-ECB在列表内,那应该是支持的!
这时候,我就猜想是不是openssl低版本的BUG,因为以前也见过一些因为openssl版本过低导致的问题,于是继续Google一番。
经过查找,发现了两条有用的结果:

在第一条中得知ECB 加密模式是不安全的,因为它没有初始化矢量openssl_cipher_iv_length()返回的长度为0的原因就得知了。

但结论没有经过仔细论证。也不知道为什么java为什么要ECB。
在第二条中找到了调整代码的思路。
最终得到了以下没有报错的代码~
/**
 * 字符串加密(加密方法:DES-ECB)
 * @param string $data 待加密字符串
 * @param string $key 对称加密密钥
 * @return string
 */
function encryptData(string $data, string $key)
{
    // 获取密码iv长度
    $length = openssl_cipher_iv_length('DES-ECB');
    // 生成一个伪随机字节串
    if ($length > 0) {
        $iv = openssl_random_pseudo_bytes($length);
    } else {
        $iv = '';
    }
    // 加密数据
    $ciphertext = openssl_encrypt($data, 'DES-ECB', $key, OPENSSL_RAW_DATA, $iv);

    // 把包含数据的二进制字符串转换为十六进制值,然后返回结果
    return bin2hex($ciphertext);
}

在制作项目中,难免会遇到有跨域问题,需要增加指定响应头来满足跨域的需求。但ThinkPHP5.1版本的手册中,对跨域怎么设置提供的方法比较局限,所以这里经过研究,总结出了几种办法,推荐使用第三种。

一、路由

这方法是手册当中介绍的,这里简单复制下,深入了解可以查看手册

如果某个路由或者分组需要支持跨域请求,可以使用

Route::get('new/:id', 'News/read')->ext('html')->allowCrossDomain();
Route::group('index', function() {
    Route::get('new/:id', 'News/read');
})->prefix('index/')->allowCrossDomain();

这方法,仅适合定义了路由的情况下使用,如果是默认路由,这方法不适用。

二、header()函数

可以在入口文件index.php、公共函数文件common.php等文件里使用header()函数定义跨域响应头。代码如下:

header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE');
header('Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-Requested-With');
header('Access-Control-Allow-Origin: *');
全局允许跨域的话,在入口文件或全局公共函数文件里增加跨域代码
单模块允许跨域的话,在模块公共函数文件里增加跨域代码
单控制器允许跨域的话,在控制器文件里增加跨域代码(命名空间与类声明之间区域)
单方法允许跨域的话,在方法代码开头增加跨域代码。或者使用路由设置跨域。

这个方法可能会存在响应头被覆盖的问题(框架输出响应内容时,设置了相同的响应头,后设置覆盖前设置)

三、中间件

可以使用中间件,更改响应输出内容。这种方法适合全局或部分模块使用。
创建application/http/middleware/AllowCrossDomain.php文件,文件内容如下:

注意修改允许跨域的域名
<?php
namespace app\http\middleware;

/**
 * 跨域中间件
 * @package app\http\middleware
 */
class AllowCrossDomain
{
    /**
     * @param \think\Request $request
     * @param \Closure $next
     * @return mixed|\think\Response
     */
    public function handle($request, \Closure $next)
    {
        // 允许跨域的域名
        $allowOriginDomain = ['www.kancloud.cn', 'll00.cn'];
        // HTTP请求头中的Origin
        $origin = $request->header('origin', '');
        // 附加响应头
        $header = [];

        if (!empty($origin)) {
            $domain = explode('://', $origin)[1] ?? '';
            if (in_array($domain, $allowOriginDomain)) {
                $header['Access-Control-Allow-Credentials'] = 'true';
                $header['Access-Control-Allow-Methods']     = 'GET, POST, PATCH, PUT, DELETE';
                $header['Access-Control-Allow-Headers']     = 'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-Requested-With';
                $header['Access-Control-Allow-Origin']      = '*';
            }
        }

        return $next($request)->header($header);
    }
}

application/middleware.phpapplication/index/middleware.php文件加入

\app\http\middleware\AllowCrossDomain::class

示例:

<?php
return [
    // 允许跨域
    \app\http\middleware\AllowCrossDomain::class,
    // 登录认证
    //\app\wxamp\middleware\CheckLogin::class,
    // 权限验证
    //\app\wxamp\middleware\CheckPermission::class,
];

四、输出响应对象实例设置

可以在json()jsonp()xml()等函数里设置跨域响应头,如:

return json(
    [
        'code' => 0,
        'msg'  => '操作成功',
    ],
    200,
    [
        'Access-Control-Allow-Credentials' => 'true',
        'Access-Control-Allow-Methods'     => 'GET, POST, PATCH, PUT, DELETE',
        'Access-Control-Allow-Headers'     => 'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-Requested-With',
        'Access-Control-Allow-Origin'      => '*',
    ]
);

记录一下自己写的PHP大文件分段上传代码,方面以后要用的时候直接复制粘贴。使用了Layui、JQuery和ThinkPHP,还有一些优化空间,等下次用到的时候再完善~

样式

<style>
.input-toolbar {
    display: inline-block;
    position: absolute;
    top: 1px;
    right: 1px;
    height: 36px;
    line-height: 36px;
    border-radius: 0 2px 2px 0;
    border-left: 1px solid #e6e6e6;
    background-color: #eee;
    font-size: 0;
}
.input-toolbar .layui-icon {
    width: 36px;
    height: 36px;
    display: inline-block;
    text-align: center;
    border: 0;
    border-right: 1px solid #e6e6e6;
    cursor: pointer;
}
.input-toolbar .layui-icon:hover {
    background-color: #e0e0e0;
}
.input-toolbar .layui-icon:last-child {
    border-right: 0;
}
.video-upload {
    position: absolute;
    opacity: 0;
    top: 0;
    display: block;
    width: 36px;
    height: 36px;
}
</style>

HTML

<div class="layui-form-item" id="video-container">
    <label class="layui-form-label">视频文件</label>
    <div class="layui-input-block">
        <input type="text" name="video" class="layui-input" placeholder="请在右侧上传视频">
        <div class="input-toolbar">
            <button type="button" class="layui-icon layui-icon-upload" title="点击上传">
                <input type="file" class="video-upload" data-bind="[name='video']" data-progress="videoProgress">
            </button>
            <button type="button" class="layui-icon video-preview" data-bind="[name='video']" title="点击查看预览">&#xe64a;</button>
        </div>
        <br/>
        <div class="layui-progress layui-progress-big" lay-filter="videoProgress" lay-showPercent="true">
            <div class="layui-progress-bar" lay-percent="0%"></div>
        </div>
    </div>
</div>

Javascript

layui.use(['jquery', 'layer', 'element'], function(){
    let $ = layui.jquery
        ,layer = layui.layer
        ,element = layui.element;
    
    // 图片预览
    $('.preview').click(function () {
        var bind = $(this).data('bind');
        if (!bind) {
            layer.msg('缺少属性: data-bind');
            return ;
        }

        var src = $(bind).val();
        if (!src) {
            layer.msg('图片地址为空');
            return ;
        }

        layer.photos({
            photos: {
                "title": "预览",
                "data": [{"src": src}]
            }
        });
    });

    $('.video-upload').change(async function () {
        let that = this;
        $(this).prop('disabled', true);
        element.progress(that.dataset.progress, '0%');

        console.log(that)

        try {
            if (!that.files.length) {
                throw new Error('请选择文件');
            }

            let chunkSize = 1 * 1024 * 1024
                ,blob = that.files[0]
                ,chunkTotal = Math.ceil(blob.size / chunkSize)
                ,start = 0
                ,progress = 0
                ,end, formData;

            if (!/.mp4$/i.test(blob.name)) {
                throw new Error('仅允许上传mp4格式的视频');
            }

            for (var i = 1; i <= chunkTotal; i++) {
                if (i == chunkTotal) {
                    end = blob.size;
                } else {
                    end = start + chunkSize;
                }

                formData = new FormData();
                formData.append("file", blob.slice(start, end), blob.name);
                formData.append("batch", i);
                await fetch("{:url('videoUpload')}", {
                    body: formData
                    ,method: 'POST'
                    ,credentials: 'same-origin'
                })
                    .then(response => response.json())
                    .then(function (result) {
                        progress = i / (chunkTotal + 1);
                        element.progress(that.dataset.progress, progress * 100 + '%');
                    })
                    .catch(function (error) {
                        throw new Error(error);
                    });

                start = end;

            }

            formData = new FormData();
            formData.append("filename", blob.name);
            formData.append("success", 1);
            await fetch("{:url('videoUpload')}", {
                body: formData
                ,method: 'POST'
                ,credentials: 'same-origin'
            })
                .then(response => response.json())
                .then(function (result) {
                    $(that.dataset.bind).val(result.data.src);
                })
                .catch(function (error) {
                    throw new Error(error);
                });

            progress = 1;
            element.progress(that.dataset.progress, progress * 100 + '%');
        } catch (e) {
            layer.alert(e.message);
        }

        $(this).prop('disabled', false);
    });

    // 视频预览
    $('.video-preview').click(function () {
        var bind = $(this).data('bind');
        if (!bind) {
            layer.msg('缺少属性: data-bind');
            return ;
        }

        var src = $(bind).val();
        if (!src) {
            layer.msg('视频地址为空');
            return ;
        }

        layer.open({
            type: 1,
            title: '视频预览',
            area: ['450px', '400px'],
            content: '<video src="' + src + '" controls="controls" style="width: 100%;"></video>'
        });
    });
});

PHP ThinkPHP控制器方法代码

public function videoUpload()
{
    if (Request::has('success', 'param', true)) {
        $tmpDir = 'storage/article_tmp/';
        $videoDir = 'storage/article_video/';

        $filenameMd5 = md5(Request::param('filename'));
        $tmpFilePath = $tmpDir . $filenameMd5 . '.' . pathinfo(Request::param('filename'), PATHINFO_EXTENSION);

        $fileResource = fopen($tmpFilePath, 'w+');

        foreach(scandir($tmpDir) as $item) {
            if (0 !== strpos($item, $filenameMd5 . '.mp4_')) continue;
            fwrite($fileResource, file_get_contents($tmpDir . $item));
            unlink($tmpDir . $item);
        }
        fclose($fileResource);

        $newFileName = md5_file($tmpFilePath) . '.mp4';
        $newFilePath = $videoDir . $newFileName;
        if (!file_exists($newFilePath)) {
            rename($tmpFilePath, $newFilePath);
        } else {
            unlink($tmpFilePath);
        }

        return json([
            'code' => 0,
            'msg'  => '上传成功',
            'data' => [
                'src'  => '/storage/article_video/' . $newFileName,
                'size' => filesize($newFilePath),
            ]
        ]);
    } else {
        try {
            $file = Request::file('file');
            if (null === $file) {
                throw new \Exception('请上传文件', UPLOAD_ERR_NO_FILE);
            }

            validate(['file' => [
                'fileSize' => Config::get('filesystem.maxFileSize'),
                'fileExt'  => 'mp4',
            ]])->check(['file' => $file]);

            $name = md5($file->getOriginalName()) . '.' . $file->extension() . '_' . Request::param('batch', 1);
            Filesystem::disk('public')->putFileAs('article_tmp', $file, $name);
        } catch (\Exception $e) {
            return json([
                'code' => 1,
                'msg'  => $e->getMessage(),
            ]);
        }

        return json([
            'code' => 0,
            'msg'  => '上传成功',
        ]);
    }
}

从ThinkPHP6.0.2升级到ThinkPHP6.0.3后,测试整体网站,发现用了多对多关联关联统计的地方均报错SQLSTATE[42000]: Syntax error or access violation: 1066 Not unique table/alias: 'pivot'。经过排查,系think-orm扩展最近一次的升级,调整了多对多关联导致的错误。

应用代码

下面是关键代码部分

控制器

\app\model\TaskLabel::withCount(['task'])->select()

TaskLabel模型

<?php
namespace app\model;

use think\Model;

/**
 * 任务标签模型
 * @package app\model
 */
class TaskLabel extends Model
{
    // 自动写入时间
    protected $autoWriteTimestamp = 'timestamp';

    // 关闭更新时间
    protected $updateTime = false;

    /**
     * 任务关联
     * @return \think\model\relation\BelongsToMany
     */
    public function task()
    {
        return $this->belongsToMany(Task::class, TaskLabelPivot::class, 'task_id', 'label_id')
            ->where('status', '>', 0);
    }
}

中间表模型

<?php
namespace app\model;

use think\model\Pivot;

/**
 * 任务标签中间表模型
 * @package app\model
 */
class TaskLabelPivot extends Pivot
{
    // 自动写入时间
    protected $autoWriteTimestamp = 'timestamp';

    // 关闭更新时间
    protected $updateTime = false;
}

任务模型

<?php
namespace app\model;

use think\Model;

/**
 * 任务
 * @package app\model
 */
class Task extends Model
{
    // 自动写入时间
    protected $autoWriteTimestamp = 'timestamp';
}

流程解刨

1.执行withCount方法

\think\db\BaseQuery::withCount()

2.执行withAggregate方法

\think\db\BaseQuery::withAggregate()

3.执行relationCount方法

\think\Model::relationCount()

4.执行task方法

\app\model\TaskLabel::task()

5.触发__call方法

因为where是Query里面的方法,在关联类里面不存在,所以会触发魔术方法

\think\model\Relation::__call()

6.执行baseQuery方法

\think\model\relation\BelongsToMany::baseQuery()

7.执行belongsToManyQuery方法

这方法里面会调用Query类的fieldjoinwhere等方法

\think\model\relation\BelongsToMany::belongsToManyQuery()

8.执行getRelationCountQuery方法

\think\model\relation\BelongsToMany::getRelationCountQuery()

9.执行belongsToManyQuery方法

这方法里面会调用Query类的fieldjoinwhere等方法

\think\model\relation\BelongsToMany::belongsToManyQuery()

问题总结

从执行流程可以看出,\think\model\relation\BelongsToMany::belongsToManyQuery()执行了两次,导致的结果就是join也执行了两次,出现了开头的报错SQLSTATE[42000]: Syntax error or access violation: 1066 Not unique table/alias: 'pivot'

belongsToManyQuery为什么会执行两次呢?
主要原因就是我在关联方法里面使用了where方法,这个方法是Query类的,在关联类里面不存在,所以会触发魔术方法。
而魔术方法里会执行baseQuerybaseQuery又会执行belongsToManyQuerygetRelationCountQuery也会执行belongsToManyQuery。就这样,join重复了。

问题已找到,关联方法后面不能跟着Query类的方法,否则就会出错。目前尚不清楚是框架的问题还是自己的用法问题,但框架问题的可能性大些,毕竟用法按照手册也报错。

解决办法

虽然找问题的时间很长,但最终的解决办法也很简单,只需要将think-orm扩展降级即可。

composer require topthink/think-orm:v2.0.32

好了,散了散了,该干嘛干嘛去。等官方出结果

2020-07-15:目前最新开发版已修复该问题,除了降级,我们还可以使用下列命令升级到最新开发版。

composer require topthink/think-orm:2.0.x-dev

其它

下面是完整的执行流程记录,备份记录下吧

array(9) {
  [0]=>
  array(5) {
    ["file"]=>
    string(63) "/vendor/topthink/think-orm/src/model/relation/BelongsToMany.php"
    ["line"]=>
    int(687)
    ["function"]=>
    string(18) "belongsToManyQuery"
    ["class"]=>
    string(34) "think\model\relation\BelongsToMany"
    ["type"]=>
    string(2) "->"
  }
  [1]=>
  array(5) {
    ["file"]=>
    string(49) "/vendor/topthink/think-orm/src/model/Relation.php"
    ["line"]=>
    int(249)
    ["function"]=>
    string(9) "baseQuery"
    ["class"]=>
    string(34) "think\model\relation\BelongsToMany"
    ["type"]=>
    string(2) "->"
  }
  [2]=>
  array(5) {
    ["file"]=>
    string(24) "/app/model/TaskLabel.php"
    ["line"]=>
    int(25)
    ["function"]=>
    string(6) "__call"
    ["class"]=>
    string(20) "think\model\Relation"
    ["type"]=>
    string(2) "->"
  }
  [3]=>
  array(5) {
    ["file"]=>
    string(61) "/vendor/topthink/think-orm/src/model/concern/RelationShip.php"
    ["line"]=>
    int(392)
    ["function"]=>
    string(4) "task"
    ["class"]=>
    string(19) "app\model\TaskLabel"
    ["type"]=>
    string(2) "->"
  }
  [4]=>
  array(5) {
    ["file"]=>
    string(64) "/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php"
    ["line"]=>
    int(273)
    ["function"]=>
    string(13) "relationCount"
    ["class"]=>
    string(11) "think\Model"
    ["type"]=>
    string(2) "->"
  }
  [5]=>
  array(5) {
    ["file"]=>
    string(64) "/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php"
    ["line"]=>
    int(325)
    ["function"]=>
    string(13) "withAggregate"
    ["class"]=>
    string(18) "think\db\BaseQuery"
    ["type"]=>
    string(2) "->"
  }
  [6]=>
  array(3) {
    ["function"]=>
    string(9) "withCount"
    ["class"]=>
    string(18) "think\db\BaseQuery"
    ["type"]=>
    string(2) "->"
  }
  [7]=>
  array(3) {
    ["file"]=>
    string(40) "/vendor/topthink/think-orm/src/Model.php"
    ["line"]=>
    int(1047)
    ["function"]=>
    string(20) "call_user_func_array"
  }
  [8]=>
  array(5) {
    ["file"]=>
    string(24) "/app/controller/Task.php"
    ["line"]=>
    int(573)
    ["function"]=>
    string(12) "__callStatic"
    ["class"]=>
    string(11) "think\Model"
    ["type"]=>
    string(2) "::"
  }
}
array(7) {
  [0]=>
  array(5) {
    ["file"]=>
    string(63) "/vendor/topthink/think-orm/src/model/relation/BelongsToMany.php"
    ["line"]=>
    int(379)
    ["function"]=>
    string(18) "belongsToManyQuery"
    ["class"]=>
    string(34) "think\model\relation\BelongsToMany"
    ["type"]=>
    string(2) "->"
  }
  [1]=>
  array(5) {
    ["file"]=>
    string(61) "/vendor/topthink/think-orm/src/model/concern/RelationShip.php"
    ["line"]=>
    int(392)
    ["function"]=>
    string(21) "getRelationCountQuery"
    ["class"]=>
    string(34) "think\model\relation\BelongsToMany"
    ["type"]=>
    string(2) "->"
  }
  [2]=>
  array(5) {
    ["file"]=>
    string(64) "/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php"
    ["line"]=>
    int(273)
    ["function"]=>
    string(13) "relationCount"
    ["class"]=>
    string(11) "think\Model"
    ["type"]=>
    string(2) "->"
  }
  [3]=>
  array(5) {
    ["file"]=>
    string(64) "/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php"
    ["line"]=>
    int(325)
    ["function"]=>
    string(13) "withAggregate"
    ["class"]=>
    string(18) "think\db\BaseQuery"
    ["type"]=>
    string(2) "->"
  }
  [4]=>
  array(3) {
    ["function"]=>
    string(9) "withCount"
    ["class"]=>
    string(18) "think\db\BaseQuery"
    ["type"]=>
    string(2) "->"
  }
  [5]=>
  array(3) {
    ["file"]=>
    string(40) "/vendor/topthink/think-orm/src/Model.php"
    ["line"]=>
    int(1047)
    ["function"]=>
    string(20) "call_user_func_array"
  }
  [6]=>
  array(5) {
    ["file"]=>
    string(24) "/app/controller/Task.php"
    ["line"]=>
    int(573)
    ["function"]=>
    string(12) "__callStatic"
    ["class"]=>
    string(11) "think\Model"
    ["type"]=>
    string(2) "::"
  }
}

在实际的项目开发过程中,总会遇到多套程序使用一个公众号的情况。而共用一个公众号,首先会遇到的应该是access_token问题了,两个程序互相的去获取access_token,导致被“挤下线”。
这次我遇到的情况比较简单,两套程序都是自己负责开发的,所以比较好处理,只需要修改代码,让它们共用access_token即可。

什么是access_token?

access_token是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。

为什么会被“挤下线”呢?

access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效

怎么解决被“挤下线”呢?

建议公众号开发者使用中控服务器统一获取和刷新access_token,其他业务逻辑服务器所使用的access_token均来自于该中控服务器,不应该各自去刷新,否则容易造成冲突,导致access_token覆盖而影响业务

详细想法

微信那边建议的是,使用中控服务器统一获取和刷新access_token,但要去搞一套中控程序,那就太麻烦了,还是用现有的实在点。
这里想到的是用redis来储存access_token,然后A程序从redis获取access_token
如果access_token不存在或已过期,则A程序就从微信服务器获取access_token,然后更新到redis里去。
这样,B程序去resis获取到的access_token就是最新的了,不用再去微信哪里获取,导致A程序获取到的access_token失效。
当然A跟B的顺序不是固定的,谁先发现access_token过期,就谁去更新。然后,如果刚好并发,也有极有可能会出问题(出问题再说)。

ThinkPHP配置

因为要用TP的缓存,所以要在缓存配置里增加redis配置,这样才能用redis来储存缓存内容。
首先,按照手册配置缓存配置,增加Redis的配置。

<?php
use think\facade\Env;

// +----------------------------------------------------------------------
// | 缓存设置
// +----------------------------------------------------------------------

return [
    // 默认缓存驱动
    'default' => Env::get('cache.driver', 'file'),

    // 缓存连接方式配置
    'stores'  => [
        'file' => [
            // 驱动方式
            'type'       => 'File',
            // 缓存保存目录
            'path'       => '',
            // 缓存前缀
            'prefix'     => '',
            // 缓存有效期 0表示永久缓存
            'expire'     => 0,
            // 缓存标签前缀
            'tag_prefix' => 'tag:',
            // 序列化机制 例如 ['serialize', 'unserialize']
            'serialize'  => [],
        ],
        /*** 下面这些是新加的 ***/
        // Redis缓存驱动
        'redis'   =>  [
            // 驱动方式
            'type'   => 'redis',
            // 服务器地址
            'host'       => '127.0.0.1',
        ],
        /*** 上面这些是新加的 ***/
    ],
];

EasyWeChat使用示例

虽然详细想法里写了很多,但其中很多工作都EasyWeChatThinkPHP完成了,所以代码很简单。
下面是简单的使用示例:

// 初始化EasyWeChat,app_id和secret经过脱敏,不要zhao'chao
$wechat = \EasyWeChat\Factory::officialAccount([
    'app_id'    => 'wx4202f388888',
    'secret'    => 'b06e645090bb1bd0aefc6588888',
]);

// 就这么简单的一句,就可以使用Redis储存AccessToken了
$wechat->access_token->setCache(\think\facade\Cache::store('redis'));

// 发模板消息
$wechat->template_message->send([
    'touser'      => 'dsadasdasdas',
    'template_id' => 'vN_nAl6UiLbCnCT_-lwPoGgaMZUXvY0G72Rr3C-5k6o',
    'url'         => 'https://blog.ll00.cn',
    'data'        => [
        // 头部
        'first'    => '您有新的待审批通行证,请尽快处理!',
        // 申请内容
        'keyword1' => '钞票',
        // 预约时间
        'keyword2' => '2020年6月6日',
        // 申请人
        'keyword3' => '西虹市首富',
        // 申请时间
        'keyword4' => '2020年5月20日 13时14分',
        // 底部
        'remark'   => '王多鱼爱夏竹冠名代码',
    ],
]);