zhangzhangzui / yii2-sms
Yii2 多渠道短信组件 — 模板映射 + 自动 Failover + 回执回调
Requires
- php: >=7.4
- alibabacloud/dysmsapi-20170525: ^4.6
- tencentcloud/tencentcloud-sdk-php: ^3.0
- yiisoft/yii2: ~2.0.54
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-15 09:55:44 UTC
README
场景驱动 + 智能 Failover + 回执回调,轻量、可扩展。
特性
- 场景驱动:所有发送必须经过注册的业务场景,杜绝野调用
- 多渠道支持:腾讯云、阿里云内置,华为云骨架预留,新增渠道 3 步搞定
- 模板映射:同一业务场景(验证码/订单通知),自动匹配各渠道的模板 ID 和参数格式
- 智能 Failover:
- 同步失败自动切换下一优先级渠道
- 用户侧原因(停机/空号/关机)不切换
- 支持场景级 failover 状态码白名单
- 异步失败后通过
resend()排除已尝试渠道重发
- 丰富出参:我方唯一编号、短信完整内容、国家码、已尝试渠道列表
- 国际号码:支持任意国家/地区码,不再硬编码
+86 - 回执回调:统一回执格式,各渠道差异透明化
- 零数据库依赖:组件内只走
Yii::log,日志存储由外层决定
目录结构
src/
├── SmsManager.php # 核心入口(场景校验 + failover + 调度)
├── TemplateMap.php # 模板映射器(场景 → 渠道模板 ID + 参数转换)
├── ProviderRegistry.php # 渠道注册表
├── contracts/
│ └── ProviderInterface.php
├── providers/
│ ├── BaseProvider.php # 基类(重试逻辑)
│ ├── TencentProvider.php # 腾讯云
│ ├── AliyunProvider.php # 阿里云
│ └── HuaweiProvider.php # 华为云(骨架)
├── models/
│ ├── SmsMessage.php # 入参(含 countryCode 国际号码)
│ ├── SmsReceipt.php # 出参(含 ourMsgId/content/triedProviders)
│ └── SmsException.php # 异常(含 failoverable 分类)
tests/
├── bootstrap.php # 测试引导(Mock Yii)
├── run-tests.php # 84 项单元测试
└── mock/ # Mock 渠道
安装
composer require zhangzhangzui/yii2sms
按需安装渠道 SDK:
composer require tencentcloud/tencentcloud-sdk-php # 腾讯云
composer require alibabacloud/dysmsapi-20170525 # 阿里云
composer require guzzlehttp/guzzle # 华为云(REST API)
配置
// config/main.php
return [
'components' => [
'sms' => [
'class' => 'zhangzhangzui\yii2sms\SmsManager',
'defaultSignName' => '微涟科技',
// 渠道配置
'providers' => [
'tencent' => [
'secret_id' => 'AKIDxxxxxxxxxxxx',
'secret_key' => 'xxxxxxxxxxxxxxxx',
'sdk_app_id' => '1400000001',
'priority' => 1,
],
'aliyun' => [
'access_key_id' => 'LTAIxxxxxxxxxxxx',
'access_key_secret' => 'xxxxxxxxxxxxxxxx',
'priority' => 2,
],
],
// 模板映射(场景注册 + 模板配置)
'templateMap' => [
'scenes' => [
'verify_code' => [
// 短信完整内容模板(用于出参展示)
'content_template' => '您的验证码是{code},{expire}分钟内有效。',
// 默认参数
'default_params' => ['code' => '', 'expire' => '5'],
// failover 白名单:只有这些状态码才触发渠道切换
'failover_codes' => [
'tencent' => ['InternalError', 'RequestLimitExceeded', 'FrequencyLimitControl'],
'aliyun' => ['isv.BUSINESS_LIMIT_CONTROL', 'isv.OUT_OF_SERVICE'],
],
// 各渠道模板配置
'providers' => [
'tencent' => [
'template_id' => '1234567',
'sign_name' => '微涟科技',
'param_map' => ['code', 'expire'],
],
'aliyun' => [
'template_id' => 'SMS_987654',
'sign_name' => '微涟科技',
'param_map' => ['code', 'expire'],
],
],
],
'order_notify' => [
'content_template' => '订单{order_no},金额{amount}元,商品{product}。',
'default_params' => ['order_no' => '', 'amount' => '', 'product' => ''],
'providers' => [
'tencent' => [
'template_id' => '2345678',
'sign_name' => '微涟平台',
'param_map' => ['order_no', 'amount', 'product'],
],
'aliyun' => [
'template_id' => 'SMS_876543',
'sign_name' => '微涟平台',
'param_map' => ['order_no' => 'order_sn', 'amount' => 'price', 'product' => 'name'],
],
],
],
],
// 各渠道默认签名(场景未配置时的兜底)
'default_signs' => [
'tencent' => '微涟科技',
'aliyun' => '微涟科技',
],
],
// failover 开关
'enableFailover' => true,
// 发送结果回调(存数据库/写 ES/推队列)
'onSend' => function($receipt) {
// $receipt->toArray() 获取完整字段
// SmsLog::save($receipt->toArray());
},
// 回执回调
'onReceipt' => function($receipt) {
// 回执到达时触发
},
// 可选:自定义 ourMsgId 生成器(默认 SMS_{YmdHis}_{seq}_{rand4})
// 'ourMsgIdGenerator' => function() { return Uuid::v4(); },
],
],
];
使用
按场景发送(主入口)
⚠️ 场景必须先在
templateMap.scenes中注册,未注册的场景会抛InvalidArgumentException。
// 验证码
$receipt = Yii::$app->sms->send('verify_code', '13800138000', [
'code' => '123456',
'expire' => '5',
]);
// 订单通知
$receipt = Yii::$app->sms->send('order_notify', '13800138000', [
'order_no' => '20261012001',
'amount' => '99.00',
'product' => '月卡',
]);
// sendByScene 是 send 的别名
$receipt = Yii::$app->sms->sendByScene('verify_code', '13800138000', ['code' => '123456']);
发送选项
$receipt = Yii::$app->sms->send('verify_code', '13800138000', ['code' => '123456'], [
'sign_name' => '自定义签名', // 覆盖全局默认签名
'country_code' => '1', // 国际号码(美国 +1)
'force_provider' => 'aliyun', // 指定渠道,跳过 failover
'out_id' => 'order_12345', // 外部流水号
'extra' => ['user_id' => 42], // 自定义扩展数据
]);
返回结果(SmsSendResult)
send() 返回 SmsSendResult,无论成功失败都不抛异常:
$result->success; // 最终是否提交成功(bool)
$result->ourMsgId; // 我方唯一编号,如 "SMS_20261014153600_0001_a3f2"
$result->scene; // 场景标识
$result->phone; // 手机号
$result->countryCode; // 国家/地区码
$result->content; // 短信完整内容
$result->failReason; // 全部失败时的原因摘要(成功时为 null)
// 每个尝试过的渠道一条记录(按尝试顺序)
$result->receipts; // SmsReceipt[]
// 辅助方法
$result->getSuccessReceipt(); // 获取成功的渠道回执
$result->getTriedProviders(); // 获取已尝试的渠道名列表
$result->getFailReason(); // 获取失败原因汇总
// 转为数组(方便存数据库 / JSON 输出)
$data = $result->toArray();
每条 receipt 包含:
$receipt->ourMsgId; // 我方编号(所有渠道共用同一个)
$receipt->provider; // 渠道名
$receipt->messageId; // 渠道短信唯一编号
$receipt->phone; // 手机号
$receipt->countryCode; // 国家/地区码
$receipt->content; // 短信完整内容
$receipt->scene; // 场景标识
$receipt->accepted; // 是否发送成功
$receipt->code; // 渠道返回码
$receipt->message; // 渠道返回消息
$receipt->failReason; // 失败原因(成功时为 null)
$receipt->failoverFrom; // failover 来源渠道
示例:failover 场景的返回结果
{
"success": true,
"ourMsgId": "SMS_20261014153600_0001_a3f2",
"scene": "verify_code",
"phone": "13800138000",
"countryCode": "86",
"content": "您的验证码是123456,5分钟内有效。",
"failReason": null,
"receipts": [
{
"provider": "tencent",
"accepted": false,
"code": "InternalError",
"message": "腾讯云发送失败: 内部错误",
"failReason": "[tencent:InternalError] 腾讯云发送失败: 内部错误",
"ourMsgId": "SMS_20261014153600_0001_a3f2"
},
{
"provider": "aliyun",
"accepted": true,
"code": "OK",
"message": "OK",
"failReason": null,
"ourMsgId": "SMS_20261014153600_0001_a3f2"
}
]
}
⚡ ourMsgId 会传给渠道方(通过腾讯云的
SessionContext、阿里云的outId),渠道在回调时会原样回传,方便关联。
批量发送
$receipts = Yii::$app->sms->batchSend(
'verify_code', // 场景名(必须已注册)
['13800138000', '13900139000'], // 手机号列表
['code' => '123456'] // 业务参数
);
foreach ($receipts as $receipt) {
echo "{$receipt->phone}: {$receipt->ourMsgId}\n";
}
异步失败后重发(resend)
当渠道异步回调通知投递失败(如运营商拦截、拉黑),外层控制器可调用 resend() 排除已尝试过的渠道重新发送:
class SmsCallbackController extends Controller
{
public $enableCsrfValidation = false;
public function actionCallback($provider)
{
$rawData = json_decode(Yii::$app->request->getRawBody(), true) ?: [];
$headers = Yii::$app->request->headers->toArray();
// 1. 解析回执
$receipts = Yii::$app->sms->handleCallback($provider, $rawData, $headers);
foreach ($receipts as $receipt) {
// 2. 投递失败 → 排除已尝试渠道重发
if ($receipt->isFailed()) {
// 从你的数据库查出原始消息的 scene/phone/params/triedProviders
$original = SmsLog::findByMessageId($receipt->messageId);
try {
$newReceipt = Yii::$app->sms->resend(
$original->scene,
$original->phone,
$original->params,
$original->triedProviders, // 排除这些渠道
$original->ourMsgId // 原始编号,用于追踪关联
);
// 记录新的发送结果...
} catch (SmsException $e) {
// 所有渠道都失败了...
}
}
}
return ['status' => 'ok'];
}
}
注意:包本身不实现回调 HTTP 接口,只提供
handleCallback()解析回调数据 +resend()带渠道排除的重发能力。回调入口由你的控制器自行接入。
模板映射详解
param_map 三种格式
| 格式 | 示例 | 输出 | 说明 |
|---|---|---|---|
| 索引数组 | ['code', 'expire'] | {'code':'123456','expire':'5'} | 保留 key 名,腾讯云用 array_values() 取位置参数 |
| 关联数组 | ['code' => 'verify_code'] | {'verify_code':'123456'} | 渠道模板变量名不同时使用 |
| 闭包 | function($p) { return [...]; } | 自定义 | 复杂转换逻辑 |
content_template
用于拼装短信完整内容,在 receipt->content 中返回(方便前端展示/日志审计):
'verify_code' => [
'content_template' => '您的验证码是{code},{expire}分钟内有效。',
'default_params' => ['code' => '', 'expire' => '5'],
// ...
],
failover_codes(白名单)
只有白名单中的渠道状态码才触发 failover,其他一律不切换:
'failover_codes' => [
'tencent' => ['InternalError', 'RequestLimitExceeded'],
'aliyun' => ['isv.BUSINESS_LIMIT_CONTROL'],
],
不切换的典型场景(用户侧原因,换渠道也没用):
- 停机、空号、关机
- 用户退订/拉黑
- 号码格式错误
Failover 流程
send('verify_code', '138...', ['code'=>'1234'])
│
▼
检查场景是否注册 → 未注册 → 抛 InvalidArgumentException
│ 已注册
▼
tencent(priority=1) 发送失败
│
├─ 用户侧原因(停机/空号)→ 不切换,直接抛异常
├─ 不在 failover 白名单 → 不切换,直接抛异常
└─ 可 failover → 记录 triedProviders,继续
│
▼
aliyun(priority=2) 发送成功
│
▼
返回 SmsReceipt(含 ourMsgId/content/triedProviders=['tencent','aliyun'])
国际号码
// 美国号码
Yii::$app->sms->send('verify_code', '4155552671', ['code' => '1234'], [
'country_code' => '1',
]);
// 日本号码
Yii::$app->sms->send('verify_code', '9012345678', ['code' => '1234'], [
'country_code' => '81',
]);
// 已带 + 号的号码会自动识别,不重复拼接
Yii::$app->sms->send('verify_code', '+447911123456', ['code' => '1234']);
扩展新渠道(3 步)
1. 创建实现
class CustomProvider extends BaseProvider
{
protected $name = 'custom';
public $api_key;
protected function validateConfig(): void { /* 校验必需配置 */ }
protected function doSend(SmsMessage $message): SmsReceipt { /* 发送 */ }
public function parseCallback(array $rawData): array { /* 解析回执 */ }
public function verifyCallback(array $rawData, array $headers = []): bool { /* 验签 */ }
}
2. 注册
ProviderRegistry::register('custom', CustomProvider::class);
3. 配置 + 场景模板
'providers' => [
'custom' => ['api_key' => 'xxx', 'priority' => 5],
],
// 在场景的 providers 中添加模板映射
'verify_code' => [
'providers' => [
'custom' => ['template_id' => 'TPL_001', 'param_map' => ['code']],
],
],
日志
组件内所有日志走 Yii::log,category 为 yii2sms。
'log' => [
'targets' => [
[
'class' => 'yii\log\FileTarget',
'categories' => ['yii2sms'],
'logFile' => '@runtime/logs/sms.log',
],
],
],
测试
cd yii2-sms
php tests/run-tests.php
84 项测试覆盖全部核心功能。
升级说明(v2.0 Breaking Changes)
| 旧版 | 新版 | 说明 |
|---|---|---|
send($phone, $templateId, $params) | send($scene, $phone, $params) | send() 改为场景驱动,第一个参数是场景名 |
batchSend($phones, $templateId, $params) | batchSend($scene, $phones, $params) | 同上,批量发送也必须经过场景 |
返回 SmsReceipt | 返回 SmsSendResult | 新返回类型,含 success + receipts 数组 |
失败抛 SmsException | 失败返回 SmsSendResult(success=false) | 不再抛异常,所有渠道结果都在 receipts 里 |
| 无 | resend($scene, $phone, $params, $excludeProviders) | 新增:异步失败后带渠道排除的重发 |
| 无 | result->ourMsgId | 新增我方编号,传给渠道方,回调时回传 |
| 无 | result->receipts[] | 每个尝试过的渠道一条记录 |
sendByScene() 保留为 send() 的别名,旧代码中用 sendByScene() 的不受影响。
License
MIT