Search by

zhangzhangzui / yii2-sms

zhangzhangzui

Yii2 多渠道短信组件 — 模板映射 + 自动 Failover + 回执回调

Package info

gitee.com/zym-zzz/yii2-sms.git

Type:yii2-extension

pkg:composer/zhangzhangzui/yii2-sms

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

1.0.0 2026-10-15 09:25 UTC

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