Laravel优雅地支持多SMS

小公司可能为了节省短信成本,会选择多家短信服务商,但短信本身的模板不会太多。本文介绍下Laravel下如何优雅地实现这种场景。

依赖

Laravel社区一个很赞的枚举组件

1
composer require bensampo/laravel-enum

这里以腾讯短信为例

1
composer require tencentcloud/tencentcloud-sdk-php

接口

现在多数短信服务端都是采用模板,发送更快,所以这里接口直接按这种模式设计。
如果需要接入未使用模版的服务商,可以自行实现模板(用sprintf()替换字符串),相当于做了使用模板的服务商所做的工作。

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
<?php

namespace App\Contracts;

interface Sms
{
/**
* 发送短信
*
* @param $phone
* @param $templateId
* @param $templateParams
* @return bool
*/
public function send($phone, $templateId, $templateParams);

/**
* 批量发送短信
*
* @param array $phones
* @param $templateId
* @param $templateParams
* @return bool
*/
public function batchSend($phones, $templateId, $templateParams);

/**
* 返回余额
*
* @return float
*/
public function getBalance();

/**
* 获取模版
*
* @param string $for 模板用途
* @return int
*/
public function getTemplateId($for);
}

枚举

每当要接入一个服务商,首先增加一个枚举,后面在服务,模型,界面选项都使用该枚举。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace App\Enums;

use BenSampo\Enum\Enum;

/**
* 短信服务商
*
* @method static static TENCENT()
*/
final class SmsProvider extends Enum
{
public const TENCENT = 'tencent';

public static function getDescription($value): string
{
if ($value === self::TENCENT) {
return '腾讯';
}

return parent::getDescription($value);
}
}

配置文件

config/sms.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

return [
// 默认服务商
'default_provider' => env('SMS_DEFAULT_PROVIDER'),

// 各服务商配置
'tencent' => [
'app_id' => env('SMS_TENCENT_APP_ID'),
'secret_id' => env('SMS_TENCENT_SECRET_ID'),
'secret_key' => env('SMS_TENCENT_SECRET_KEY'),
'template_ids' => [
'captcha' => env('SMS_TENCENT_TEMPLATE_IDS_CAPTCHA'),
],
]
];

服务实现

创建基类,构造时从配置中初始化模板参数。

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
<?php

namespace App\Services\Sms;

use App\Contracts\Sms;

abstract class BaseSms implements Sms
{
/**
* 服务商名称
*
* @var string
*/
protected $name;

/**
* 短信模板ID映射
*
* @var array
*/
protected $templateIds;

/**
* 短信模板用途集合
*
* @var array
*/
protected $templateUses = ['captcha'];

/**
* BaseSms constructor.
*/
public function __construct()
{
foreach ($this->templateUses as $templateUse) {
$this->templateIds[$templateUse] = config("system.sms.{$this->name}.template_ids.{$templateUse}}");
}
}

/**
* 获取模版
*
* @param $for
* @return int|mixed|null
* @throws \Exception
*/
public function getTemplateId($for)
{
if (empty($this->templateIds[$for])) {
throw new \Exception("{$for}短信模板不存在");
}

return $this->templateIds[$for];
}
}

每个服务类只需要在构造函数中做好参数初始化,并实现三个方法即可。

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<?php

namespace App\Services\Sms;

use App\Enums\SmsProvider;
use TencentCloud\Common\Credential;
use TencentCloud\Common\Profile\ClientProfile;
use TencentCloud\Common\Profile\HttpProfile;
use TencentCloud\Sms\V20190711\Models\SendSmsRequest;
use TencentCloud\Sms\V20190711\Models\SmsPackagesStatisticsRequest;
use TencentCloud\Sms\V20190711\SmsClient;

class TencentSms extends BaseSms
{
protected $appId;

protected $secretId;

protected $secretKey;

/**
* TencentSMS constructor.
*/
public function __construct()
{
$this->name = SmsProvider::TENCENT;
$this->appId = config('system.sms.tencent.app_id');
$this->secretId = config('system.sms.tencent.secret_id');
$this->secretKey = config('system.sms.tencent.secret_key');

parent::__construct();
}

/**
* 发送短信
*
* @param $phone
* @param $templateId
* @param $templateParams
* @return bool
*/
public function send($phone, $templateId, $templateParams)
{
return $this->batchSend([$phone], $templateId, $templateParams);
}

/**
* 批量发送短信
*
* @param array $phones
* @param $templateId
* @param $templateParams
* @return bool
*/
public function batchSend($phones, $templateId, $templateParams)
{
// 处理参数
$phones = is_array($phones) ? $phones : [$phones];
$phones = array_map('addPhoneAreaCode', $phones);
$templateParams = array_map('strval', $templateParams);

// 实例化一个证书对象,入参需要传入腾讯云账户secretId,secretKey
$cred = new Credential($this->secretId, $this->secretKey);

$httpProfile = new HttpProfile();
$httpProfile->setEndpoint("sms.tencentcloudapi.com");

$clientProfile = new ClientProfile();
$clientProfile->setHttpProfile($httpProfile);

// 实例化要请求产品(以cvm为例)的client对象
$client = new SmsClient($cred, "ap-guangzhou");

// 实例化一个请求对象
$req = new SendSmsRequest();
$req->fromJsonString(json_encode([
'PhoneNumberSet' => $phones,
'TemplateID' => $templateId,
'Sign' => config('system.sms.tencent.sign'),
'TemplateParamSet' => $templateParams,
'SmsSdkAppid' => $this->appId,
]));

// 通过client对象调用想要访问的接口,需要传入请求对象
$res = $client->SendSms($req);

foreach ($res->SendStatusSet as $sendStatus) {
/** @var \TencentCloud\Sms\V20190711\Models\SendStatus $sendStatus */
if ($sendStatus->Code != 'Ok') {
return false;
}
}

return true;
}

/**
* 返回余额
*
* @return float
*/
public function getBalance()
{
$cred = new Credential($this->secretId, $this->secretKey);
$httpProfile = new HttpProfile();
$httpProfile->setEndpoint("sms.tencentcloudapi.com");

$clientProfile = new ClientProfile();
$clientProfile->setHttpProfile($httpProfile);
$client = new SmsClient($cred, "", $clientProfile);

$req = new SmsPackagesStatisticsRequest();
$params = ['Limit' => 100, 'Offset' => 0, 'SmsSdkAppid' => $this->appId];
$params = json_encode($params);
$req->fromJsonString($params);

$resp = $client->SmsPackagesStatistics($req);
$smsPackagesStatisticsSet = $resp->SmsPackagesStatisticsSet;

$maxSmsPackagesStatistics = $this->getMaxSmsPackagesStatistics($smsPackagesStatisticsSet);
// 当前套餐包剩余使用量
$remainingNumber = $maxSmsPackagesStatistics->AmountOfPackage - $maxSmsPackagesStatistics->CurrentUsage;
$balance = $remainingNumber * 0.05;

return $balance;
}

/**
* 从集合中查找出剩余短信条数最大的套餐包
*
* @param $smsPackagesStatisticsSet
* @return mixed | object
*/
protected function getMaxSmsPackagesStatistics($smsPackagesStatisticsSet)
{
if (empty($smsPackagesStatisticsSet) || !is_array($smsPackagesStatisticsSet)) {
return $smsPackagesStatisticsSet;
}

$maxRemainingNumber = 0; // 最大套餐包剩余使用量
$maxSmsPackagesStatistics = null; // 剩余短信条数最大的套餐包
foreach ($smsPackagesStatisticsSet as $smsPackagesStatistics) {
// 当前套餐包剩余使用量
$remainingNumber = $smsPackagesStatistics->AmountOfPackage - $smsPackagesStatistics->CurrentUsage;
if ($remainingNumber >= $maxRemainingNumber) {
$maxRemainingNumber = $remainingNumber;
$maxSmsPackagesStatistics = $smsPackagesStatistics;
}
}

return $maxSmsPackagesStatistics;
}
}

服务提供者与发送类

分为两类场景:调用方始终使用同一个服务商、调用方可能使用不同服务商。

调用方始终使用同一个服务商

服务提供者

扩展绑定Sms接口到实现上,并绑定Sender的单例。

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
<?php

namespace App\Providers;

use App\Contracts\Sms;
use App\Services\Sms\TencentSms
use Illuminate\Support\ServiceProvider;

class SmsServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}

/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->app->extend(Sms::class, function ($service, $app) {
$smsProvider = config('system.sms.provider');
if ($smsProvider === SmsProvider::TENCENT) {
return new TencentSMS();
}
return new TencentSms();
});

$this->app->singleton(Sender::class, function ($app) {
return new Sender($app->make(Sms::class));
});
}
}
发送类
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
<?php

namespace App\Services\Sms;

use App\Enums\SmsProvider;
use Illuminate\Support\Facades\Redis;

class Sender
{
/**
* @var TencentSms
*/
protected $sms;

public function __construct(Sms $smsProvider)
{
$this->smsProvider = $smsProvider;
}

/**
* 发送验证码
*
* @template 验证码 {1},15分钟内有效,请勿向任何人泄露。
* @param int|string $phone
* @param int $length 验证码长度, 范围4-6
* @param int $ttl 有效时间(分钟)
* @return bool
* @throws \Exception
*/
public function sendCaptcha($phone, $length = 4, $ttl = 15)
{
$captcha = substr(mt_rand(100000, 999999), 0, $length);
$templateId = $this->sms->getTemplateId('captcha');
Redis::setex("captcha:{$phone}", $ttl * 60, $captcha);
return $this->sms->send($phone, $templateId, [$captcha]);
}
}
调用示例

自动注入获取单例

1
2
3
4
5
6
7
use App\Services\Sms\Sender;

public function index(Request $request, Sender $sender)
{
// do some work and send a message
$sender->sendCaptcha();
}

直接解析获取单例

1
2
3
4
use App\Services\Sms\Sender;

$sender = resolve(Sender::class);
$sender->sendCaptcha();

调用方可能使用不同服务商

服务提供者

每个服务使用单例,采用延迟加载。

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
<?php

namespace App\Providers;

use App\Services\Sms\TencentSms;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;

class SmsServiceProvider extends ServiceProvider implements DeferrableProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(TencentSms::class, function () {
return new TencentSms();
});
}

/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
//
}

/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [TencentSms::class];
}
}
发送类

调用方传入服务商的枚举值。

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
<?php

namespace App\Services\Sms;

use App\Enums\SmsProvider;
use Illuminate\Support\Facades\Redis;

class Sender
{
/**
* @var TencentSms
*/
protected $sms;

public function __construct($smsProvider = null)
{
$smsProvider = $smsProvider ?: config('system.sms.default_provider');
if ($smsProvider === SmsProvider::TENCENT) {
$this->sms = resolve(TencentSms::class); // 获取单例
}
}

/**
* 发送验证码
*
* @template 验证码 {1},15分钟内有效,请勿向任何人泄露。
* @param int|string $phone
* @param int $length 验证码长度, 范围4-6
* @param int $ttl 有效时间(分钟)
* @return bool
* @throws \Exception
*/
public function sendCaptcha($phone, $length = 4, $ttl = 15)
{
$captcha = substr(mt_rand(100000, 999999), 0, $length);
$templateId = $this->sms->getTemplateId('captcha');
Redis::setex("captcha:{$phone}", $ttl * 60, $captcha);
return $this->sms->send($phone, $templateId, [$captcha]);
}
}
调用示例
1
2
3
4
5
use App\Enums\SmsProvider;
use App\Services\Sms\Sender;

$sender = new Sender(SmsProvider::TENCENT);
$sender->sendCaptcha();