第十二章 后端会员管理

会员,即用户,在互联网时代中是最具价值的数据之一,谁的会员多,谁就能更有机会成为行业内的巨头,商家总是千方百计的想办法让你成为他的会员,在有一定的会员数据积累之后,就可以利用这堆会员数据做大数据分析,然后基于分析的结果做精准营销来促进二次消费或者消费升级等等,也有一些黑产,专门倒卖各行各业的会员数据,可见会员数据对于有心的商家来说是多么重要的一样东西。当然,一般的公司没有资金和团队可以很好的去捣鼓这些会员数据,但是有一些性价比不错且实用的会员管理手段还是有必要实现一下的,比如说常见的唤醒短信、统计分析等。

查询会员列表

很多会员管理的操作都是需要基于某个会员或者全体会员,我们肯定需要有一个页面是能展示出所有会员的,所以我们首先要先实现一个能够查询所有会员的接口,在控制层下新增一个User控制器类,在控制器类中新增一个getUsersPaginate()方法:

<?php


namespace app\api\controller\v1;


use think\facade\Request;

class User
{
    public function getUsersPaginate()
    {
        $params = Request::get();
        
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

通过方法名,相信读者已经猜到我们要实现什么了,有段时间没有写这种纯CRUD了,有没有觉得生疏了呢?让作者带着大家重温一下,我们先到模型层下新建一个User模型,并在模型类里面封装我们的查询方法:

<?php


namespace app\api\model;


class User extends BaseModel
{
    protected $hidden = ['delete_time','update_time'];

    public static function getUsersPaginate($params)
    {
        $field = ['nickname'];
        $query = self::equalQuery($field, $params);

        list($start, $count) = paginate();
        // 应用条件查询
        $userList = self::where($query);
        // 调用模型的实例方法count计算该条件下会有多少条记录
        $totalNums = $userList->count();
        // 调用模型的limit方法对记录进行分页并获取查询结果
        $userList = $userList->limit($start, $count)
            ->order('create_time desc')
            ->select();
        // 组装返回结果
        $result = [
            'collection' => $userList,
            'total_nums' => $totalNums
        ];

        return $result;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

熟悉的分页查询操作,接着回到控制层中调用一下:

<?php


namespace app\api\controller\v1;


use app\lib\exception\user\UserException;
use think\facade\Request;
use app\api\model\User as UserModel;

class User
{
    /**
     * @auth('会员列表','会员管理')
     */
    public function getUsersPaginate()
    {
        $params = Request::get();
        $users = UserModel::getUsersPaginate($params);
        if ($users['total_nums'] === 0) {
            throw new UserException([
                'code' => 404,
                'msg' => '未查询到会员相关信息',
                'error_code' => 70013
            ]);
        }
        return $users;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

这里我们给这个控制器方法加了权限控制,毕竟会员信息还是比较敏感的,不能让人随意查看。接着来给这个控制器方法定义一条路由,打开route.php,在v1分组下新增一个user路由分组下并新增一条路由规则:

Route::group('', function () {
    Route::group('cms', function () {
        // CMS管理相关的路由规则
        // 内容省略。。。。
    });
    Route::group('v1', function () {
        // 业务接口相关的路由规则
        ............................
        ............................
        ............................
        ............................
        // 会员管理相关接口
        Route::group('user', function () {
            // 查询会员列表
            Route::get('', 'api/v1.User/getUsersPaginate');
        });
    });
})->middleware(['Auth','ReflexValidate'])->allowCrossDomain();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

路由定义完了之后打开Postman,按照路由信息新增并配置一个请求:

点击发送:

{
    "collection": [
        {
            "id": 62,
            "openid": "o5r750FwecqLkQQ0oTtuSOA18QEY",
            "nickname": "沁雪",
            "extend": "https://wx.qlogo.cn/mmopen/vi_32/o42RgcY9XQSk1osWiaic1N3CfJTSwMPwOmNibKr22FZ2TiaA7qe1EgSKkaaADltum7EcbAtmzQGxGHaLzdqmfTf2HA/132",
            "create_time": 1567420116
        },
        {
            "id": 61,
            "openid": "o5r750LTt0qTddYCQ8BbkjnQqIoB",
            "nickname": "沁阳",
            "extend": "https://wx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKEkJNtEdYa6w51kIreKTEMcG6UWnAT1azGlvFVtVDjFWMF7v3dHpAB4LNGqMicTCRic9llX4pk0I5Q/132",
            "create_time": 1561656104
        },
        {
            "id": 59,
            "openid": "o5r750LTt0qTddYCQ8BbkjnQqIoA",
            "nickname": "沁芸",
            "extend": "https://wx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTJrCYh7AraxkSCovXg0lHGkxL2SIo8G3Y8Vzjchh9LbWgKibiaxqIzXaZLnMKrHV6dZGceUcdlWDKUQ/132",
            "create_time": 1561656050
        },
        {
            "id": 58,
            "openid": "o5r750LTt0qTddYCQ8BbkjnQqIoE",
            "nickname": "沁尘",
            "extend": "https://wx.qlogo.cn/mmopen/vi_32/o42RgcY9XQSk1osWiaic1N3CfJTSwMPwOmNibKr22FZ2TiaA7qe1EgSKkaaADltum7EcbAtmzQGxGHaLzdqmfTf2HA/132",
            "create_time": 1561656004
        }
    ],
    "total_nums": 4
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

这里我事先给zerg数据库中的user表添加了一些测试数据,读者可自行添加测试,extend是一个扩展字段,暂时用于存放微信头像的图片地址。

会员短信通知

会员短信群发相信大家都不陌生,我们每天手机都会收到各种平台的问候、营销短信。平台在某些时刻会利用我们注册或完善资料时提供的手机号码给我们发送短信。随着科技的进步和社交习惯的改变,虽然现在已经没人还在利用短信聊天交友了,但是短信在企业应用上还是广泛使用的,比如短信验证码、敏感操作提醒、活动促销、老用户唤醒等。我们的前端应用《零食商贩》,从运营层面来说会存在新品上架、精选主题等等,有时候我们希望能在这些特殊业务时间点通知到所有应用的会员用户,做到第一时间消息触达,同时也能够起到唤醒用户的作用。想要实现短信发送,必须先到一些第三方短信平台开通短信包服务,然后调用平台的接口来实现短信下发。这里我们使用的是腾讯云的短信服务,首次开通会有100条短信赠送,关于腾讯云短信的开通和审核等一系列操作官方有详细的文档和视频教程,这里就不重复带着大家一步步走了,读者请自行查阅并开通。

《腾讯云短信快速入门》:点击访问

在准备好腾讯云短信服务之后,我们就要来实现集成腾讯云短信发送功能到我们的CMS中了,通过查看开发文档,我们找到了PHP 短信发送SDK 的文档。文档中给出了SDK的安装和使用方法,读者可自行阅读,相信到了目前的学习阶段,这种文档的阅读对很多读者来说已经不在话下了。首先我们先来安装一下,由于官方团队已经将SDK发布成了composer扩展包,所以我们可以直接使用composer工具来安装,打开命令行工具然后定位到项目的根目录下,或者直接在打开了项目的IDE中调出命令行工具,在命令行工具中输入:

composer require qcloudsms/qcloudsms_php
1

看到如下提示代表扩展已经安装成功:


Using version ^0.1.4 for qcloudsms/qcloudsms_php
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 1 install, 0 updates, 0 removals
  - Installing qcloudsms/qcloudsms_php (v0.1.4): Downloading (100%)
Writing lock file
Generating autoload files

1
2
3
4
5
6
7
8
9
10

扩展安装成功之后,我们就可以很方便的在项目代码中调用了,在正式动手之前,我们先来思考规划一下。如同前面介绍的一样,短信发送有很多应用场景,所以我们这里要考虑下短信发送这个功能本身的可复用性,要实现可复用,方法就是通过封装一个类,这个类就只负责发短信,至于发给谁,发送什么内容,发给多少人,由外部来决定,这样这个类就可以在任意业务场景下复用了。想法有了,我们来落地实现,在项目根目录下的appliction\lib新建一个tencent目录,在这个目录下新增一个Sms类:

<?php


namespace app\lib\tencent;

/**
 * 腾讯云短信SDK封装
 * Class Sms
 * @package app\lib\tencent
 */
class Sms
{

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这里面要写啥呢?先看看官方的SDK文档,看看发送一个短信都需要什么参数,需要调用什么SDK的方法。在清楚这两个问题之后,我们可以写一段测试代码,这段测试代码一般会存在很多硬编码的内容,但不要紧,我们先跑通了,知道这个SDK怎么用了然后再来根据我们业务需求来重构。这里作者就不重复书写测试代码和讲述重构过程了,推荐读者自己尝试动手,然后再重构。我直接给出自己测试后重构的Sms类,读者可以自行对比下自己的重构代码:

<?php


namespace app\lib\tencent;


use Qcloud\Sms\SmsSingleSender;

/**
 * 腾讯云短信SDK封装
 * Class Sms
 * @package app\lib\tencent
 */
class Sms
{
    // 短信应用 SDK AppID
    protected $appid;
    // 短信应用 SDKAppKey
    protected $appkey;
    // 短信模板ID
    protected $templateId;
    // 短信签名内容
    protected $smsSign;

    public function __construct()
    {
        // 获取配置文件内容
        $config = config('tencent.sms');
        $this->appid = $config['appid'];
        $this->appkey = $config['appkey'];
        $this->templateId = $config['templateId'];
        $this->smsSign = $config['templateId'];
    }

    /**
     * 设置appid
     * @param string $appid
     * @return Sms
     */
    public function setAppid($appid)
    {
        $this->appid = $appid;
        return $this;
    }

    /**
     * 设置appkey
     * @param string $appkey
     * @return Sms
     */
    public function setAppkey($appkey)
    {
        $this->appkey = $appkey;
        return $this;
    }

    /**
     * 设置模板id
     * @param $templateId
     * @return $this
     */
    public function setTemplateId($templateId)
    {
        $this->templateId = $templateId;
        return $this;
    }

    /**
     * 设置短信签名内容
     * @param $sign
     * @return $this
     */
    public function setSmsSign($sign)
    {
        $this->smsSign = $sign;
        return $this;
    }

    /**
     * 发送短信
     * @param $params array 短信变量参数值
     * @param $phoneNumber string 短信接收手机号码
     * @return array|string
     */
    public function send($params, $phoneNumber)
    {
        try {
            $sender = new SmsSingleSender($this->appid, $this->appkey);
            $result = $sender->sendWithParam('86', $phoneNumber, $this->templateId, $params, $this->smsSign);
            // 腾讯云短信API返回的是json结果,这里把结果转成了数组方便存储或者后续处理
            return json_decode($result);
        } catch (\Exception $ex) {
            return $ex->getMessage();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96

作者把四个必传的参数放到了一个配置文件中,在根目录下的config目录下新建一个tencent.php配置文件:

<?php

/**
 * 腾讯云产品相关配置
 */
return [
    // 腾讯云短信
    'sms' => [ // 以下配置参数均需要通过腾讯云短信控制台查看获取
        // 短信应用 SDK AppID
        'appid' => '',
        // 短信应用 SDK AppKey
        'appkey' => '',
        // 短信模板 ID,需要在短信控制台中申请
        'templateId' => '',
        // 短信签名内容,需要是在控制台中已申请成功的
        'smsSign' => ''
    ]
];

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在构造方法中,通过TP内置的助手函数config()读取出来并分别赋值给对应的成员属性,接着我们提供了分别针对这四个成员属性的Set方法,供外部调用时可以通过优雅的链式调用来改变成员属性的值。

这么做的目的就是默认情况下使用配置文件里的配置,在复杂的业务场景下可以通过->setXXXX()在真正调用类的执行方法之前来改变类的成员属性值实现执行不同的行为,而不需要重复实例化一个新的实例。

send()方法是这里类的具体实现方法,就是发送短信,这个方法接收两个参数:

/**
     * 发送短信
     * @param $phoneNumber string 短信接收手机号码
     * @param $params array 短信模板变量的值,可空。
     * @return array|string
     */
    public function send($phoneNumber, $params = [])
    {
        try {
            $sender = new SmsSingleSender($this->appid, $this->appkey);
            $result = $sender->sendWithParam('86', $phoneNumber, $this->templateId, $params, $this->smsSign);
            // 腾讯云短信API返回的是json结果,这里把结果转成了数组方便存储或者后续处理
            return json_decode($result);
        } catch (\Exception $ex) {
            return $ex->getMessage();
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

方法内的实现非常简单,就是调用SDK的类和方法,按要求传入对应的参数,返回发送结果和捕获异常信息。这里唯一要注意的就是$params参数,这个参数用于给我们申请短信模板时定义的那些模板变量赋值,比如说我们申请短信模板时的内容是:

尊敬的{1},最新冬季主题已上线。
1

这里的{1}就是模板变量,它的值就需要我们在调用短信发送API时传递过去,传递方法就是利用这个$params参数,$params参数是一个数组,短信平台会根据数组元素的循序依次填充到模板变量中。比如你的模板里面定义了{1}、{2}、{3}等等多个模板变量,那你这个$params数组就是要有对应数量和顺序的元素。如果模板中不存在模板变量,则传递一个空数组。

Sms类封装好之后,我们就可以来尝试调用了,在控制层下的User控制器类中,我们新增一个控制器方法sendActivityOnlineSms():

<?php


namespace app\api\controller\v1;


use app\lib\exception\user\UserException;
use app\lib\tencent\Sms;
use think\facade\Request;
use app\api\model\User as UserModel;

class User
{
    /**@auth('会员列表','会员管理')*/
    public function getUsersPaginate(){...}

    /**
     * 发送活动上线短信提醒
     * @auth('活动短信发送','会员管理')
     * @return array
     */
    public function sendActivityOnlineSms()
    {
        // 接收前端提交过来ids参数,由,分隔的用户id组成的字符串
        $ids = Request::post('ids');
        // 利用explode() PHP内置函数以,格式化字符串为数组
        $userIds = explode(',', $ids);
        // 为数组的每个元素作用一个函数,并返回每个函数作用的结果
        $result = array_map(function ($uid) {
            // 根据uid查询用户表中的记录
            $user = UserModel::field('nickname,tel')->get($uid);
            // 如果查询到用户记录,调用封装的短信发送类库并返回发送结果。
            if ($user) return (new Sms())->send($user->tel, [$user->nickname]);
        }, $userIds);
        
        return $result;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

如果现在我们在发送完一条短信之后,存在立马要给同一个用户发送另外一个模板短信的需求,这时候只需要稍微改造下代码:
$sms = new Sms();
//第一次发送
$sms->send('手机号码','模板变量参数值');
//第二次发送
$sms->setTemplateId('另一个模板Id')->send('手机号码','模板变量参数值');

这里我们在控制层中利用PHP的内置函数array_map()来为接收到的$tels数组元素作用一个函数,函数内的实现就是调用我们刚刚封装好的Sms类。由于我申请的短信模板中有模板变量,所以这里我给send()方法的第二个参数传入了一个数组,只有一个元素,就是该用户的nickname字段内容。

注意!《零食商贩》项目中user表中是没有tel字段的,这里需要读者手动给zerg数据库中的user表添加一个tel字段,并给已存在的user记录都添加一个tel字段值方便后续测试。

控制器方法定义好之后,接着来给这个控制器方法定义一条路由,打开route.php,在v1分组下的user路由分组下新增一条路由规则:

Route::group('', function () {
    Route::group('cms', function () {
        // CMS管理相关的路由规则
        // 内容省略。。。。
    });
    Route::group('v1', function () {
        // 业务接口相关的路由规则
        ............................
        ............................
        ............................
        ............................
        // 会员管理相关接口
        Route::group('user', function () {
            // 查询会员列表
            Route::get('', 'api/v1.User/getUsersPaginate');
            // 发送会员短信
            Route::post('sms','api/v1.User/sendActivityOnlineSms');
        });
    });
})->middleware(['Auth','ReflexValidate'])->allowCrossDomain();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

路由定义完了之后打开Postman,按照路由信息新增并配置一个请求:

这里我尝试给这两个用户id发送短信

点击发送:

[
    {
        "result": 0,
        "errmsg": "OK",
        "ext": "",
        "sid": "2034:1102171751605884971248407344331",
        "fee": 1
    },
    {
        "result": 0,
        "errmsg": "OK",
        "ext": "",
        "sid": "26:19110217175100202005000000881572",
        "fee": 1
    }
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

没有报错,这里提示发送成功了,然后看看手机,你会收到一条短信:

到这里就说明我们的腾讯云短信发送调用成功了,但是这个功能有个很严重的问题,这里我们给两个用户发送了短信,如果你尝试给1万个用户发送短信,你会发现请求这个接口的耗时会很长,长到超出了PHP脚本默认的执行超时时间,这时候PHP进程管理器会强制中断脚本执行。这时候很可能整个任务只执行到了一半,剩下的5000个用户就没有发送短信了。这里读者可能会说,是不是服务器、带宽或者PHP性能不够,处理得不够快?答案不是。假设你各方面优化到了极致,发送一次短信只需要1ms,那么发送1万次,你也依然需要10000ms,前端依然需要等待这么长的时间才能收到执行结果;那么我们换个思路,一次别发那么多不就可以了,分几次发。答案是可以,但是这显然没啥技术含量,X格不够。这里我们解决这个问题的方法,是引入异步编程。在理解异步编程之前,我们先来了解下同步编程。包括PHP在内,很多编程语言默认情况下执行代码的时候都是同步阻塞的,顾名思义就是要等一行代码的逻辑执行完了,再继续执行下一行代码,前面的代码没执行完,后面的代码都会进入阻塞的状态。比如你有一个方法,方法里有三行代码,第一行代码假设执行完毕需要100秒,那第二行代码就需要等上100秒才开始执行,然后第三行又要等第二行执行完了才会执行。按目前互联网的节奏,一个后端接口响应时间如果太长,轻则影响用户体验,重则拖垮整个系统(大量用户访问执行耗时任务的接口导致阻塞,资源无法释放)。而且有很多耗时任务本身的场景其实并不要求前端需要知道执行结果,就拿我们这个发送短信的功能,最佳的实践应该是我请求了接口,接口就告诉我短信已发送,接口只需要告诉我已经受理了即可,我不必等待整个发送流程执行完。这样子即便我一次性向1万名用户发送短信和给1名用户发送短信的接口响应时间是一致的,那么发送短信的这个逻辑什么时候在执行?执行的时机其实和同步时没区别,区别在于这里的执行不会阻塞后面的代码,这就是异步非阻塞机制。后端在执行这行发送短信的代码的时候,虽然耗时,但并没有阻塞后面的代码,前端感受到的是接口立马返回了结果,但其实后端还在慢慢发短信。这和我们平常使用手机验证码登陆时的体验是一样,你点击了发送验证码,会提示你发送成功,但你什么时候收到就不一定了。

关于同步和异步编程的概念和使用场景及原理由于内容过于庞大,局限于专栏篇幅原因未能作深入讲解,读者可自行查阅资料。

找到解决办法之后,我们就要来实现一下了。要想PHP能够实现异步执行代码方法有两种,第一种是使用已经在业界广泛应用多年的workman,第二种是这两年大火的swoole,TP官方团队都对其做了一些封装,并提供了相应的扩展包。个人感觉swoole会是未来几年里PHP的一个热门技术路线,所以本专栏采用通过利用swoole来实现异步任务功能。由于swoole的安装比较麻烦,特别是在windows环境下,很容易导致你无法进行后面的学习,所以异步实现发送短信的内容我们将放到独立的章节中作为本专栏项目重构优化的内容部分来讲解,读者可关注留意后续相关的章节。

最后更新: 4/15/2020, 4:51:14 PM
0/140
评论
0
暂无评论
  • 上一页
  • 首页
  • 1
  • 尾页
  • 下一页
  • 总共1页