19 changed files with 503 additions and 92 deletions
@ -0,0 +1,68 @@ |
|||
package sms |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"strings" |
|||
|
|||
"github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi" |
|||
) |
|||
|
|||
func newAliyun(options Options) (sys *Aliyun, err error) { |
|||
sys = &Aliyun{options: options} |
|||
sys.client, err = dysmsapi.NewClientWithAccessKey(options.RegionId, options.AccessKeyId, options.AccessKeySecret) |
|||
return |
|||
} |
|||
|
|||
type Aliyun struct { |
|||
options Options |
|||
client *dysmsapi.Client |
|||
} |
|||
|
|||
// SendCaptcha 发送验证码短信(阿里云 Dysmsapi SendSms)。
|
|||
func (this *Aliyun) SendCaptcha(mobile string, captcha string) (err error) { |
|||
template, err := pickTemplate(this.options, mobile) |
|||
if err != nil { |
|||
return |
|||
} |
|||
param, err := json.Marshal(map[string]string{this.options.TemplateParamName: captcha}) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
request := dysmsapi.CreateSendSmsRequest() |
|||
request.Scheme = "https" |
|||
request.PhoneNumbers = aliMobile(mobile) |
|||
request.SignName = this.options.SignName |
|||
request.TemplateCode = template |
|||
request.TemplateParam = string(param) |
|||
|
|||
resp, err := this.client.SendSms(request) |
|||
if err != nil { |
|||
return |
|||
} |
|||
// 阿里云同样是「HTTP 成功 ≠ 发送成功」:真实结果看 Code,成功为 "OK"。
|
|||
if resp == nil { |
|||
return fmt.Errorf("阿里云短信发送失败: 返回为空") |
|||
} |
|||
if resp.Code != "OK" { |
|||
return fmt.Errorf("阿里云短信发送失败: %s %s", resp.Code, resp.Message) |
|||
} |
|||
return |
|||
} |
|||
|
|||
// aliMobile 把号码转成阿里云要求的格式:
|
|||
// 国内号只要 11 位裸号(带 +86/0086 前缀会被判为非法号码),
|
|||
// 国际/港澳台号用 "00+国际区号+号码"(阿里云不接受 "+" 开头)。
|
|||
func aliMobile(mobile string) string { |
|||
m := normalizeMobile(mobile) |
|||
if isDomestic(m) { |
|||
m = strings.TrimPrefix(m, "+86") |
|||
m = strings.TrimPrefix(m, "0086") |
|||
return m |
|||
} |
|||
if strings.HasPrefix(m, "+") { |
|||
return "00" + strings.TrimPrefix(m, "+") |
|||
} |
|||
return m |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
package sms |
|||
|
|||
import "testing" |
|||
|
|||
// 纯本地逻辑测试(不发网络):号码归属判定、阿里云号码格式、模板选取、配置校验。
|
|||
|
|||
func TestIsDomestic(t *testing.T) { |
|||
cases := map[string]bool{ |
|||
"+8615336758045": true, |
|||
"008615336758045": true, |
|||
"15336758045": true, |
|||
"+852 7027 8175": false, |
|||
"+85270278175": false, |
|||
"0085270278175": false, |
|||
"+14155552671": false, |
|||
} |
|||
for mobile, want := range cases { |
|||
if got := isDomestic(mobile); got != want { |
|||
t.Errorf("isDomestic(%q)=%v, want %v", mobile, got, want) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestAliMobile(t *testing.T) { |
|||
cases := map[string]string{ |
|||
"+8615336758045": "15336758045", |
|||
"008615336758045": "15336758045", |
|||
"153-3675-8045": "15336758045", |
|||
"+85270278175": "0085270278175", |
|||
"0085270278175": "0085270278175", |
|||
} |
|||
for in, want := range cases { |
|||
if got := aliMobile(in); got != want { |
|||
t.Errorf("aliMobile(%q)=%q, want %q", in, got, want) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func TestPickTemplate(t *testing.T) { |
|||
o := Options{Template1: "domestic", Template2: "oversea"} |
|||
if got, _ := pickTemplate(o, "+8615336758045"); got != "domestic" { |
|||
t.Errorf("国内号应选 Template1,得到 %q", got) |
|||
} |
|||
if got, _ := pickTemplate(o, "+85270278175"); got != "oversea" { |
|||
t.Errorf("国际号应选 Template2,得到 %q", got) |
|||
} |
|||
// 只配了国内模板时,国际号回退到国内模板而不是发一条没有模板的空请求。
|
|||
only := Options{Template1: "domestic"} |
|||
if got, _ := pickTemplate(only, "+85270278175"); got != "domestic" { |
|||
t.Errorf("缺国际模板应回退 Template1,得到 %q", got) |
|||
} |
|||
if _, err := pickTemplate(Options{}, "15336758045"); err == nil { |
|||
t.Error("两个模板都没配时应报错") |
|||
} |
|||
} |
|||
|
|||
func TestOptionsValidateAndDefaults(t *testing.T) { |
|||
// Provider 留空 = 腾讯云(兼容加入阿里云之前的配置)。
|
|||
o, err := newOptionsChecked(map[string]interface{}{"SecretId": "id", "SecretKey": "key"}) |
|||
if err != nil { |
|||
t.Fatalf("腾讯云旧配置应通过校验: %v", err) |
|||
} |
|||
if o.Provider != ProviderTencent { |
|||
t.Errorf("Provider 留空应归一为 %s,得到 %q", ProviderTencent, o.Provider) |
|||
} |
|||
|
|||
// 阿里云:缺 AccessKey 应报错,而不是等到发送时才失败。
|
|||
if _, err := newOptionsChecked(map[string]interface{}{"Provider": "aliyun", "SignName": "签名"}); err == nil { |
|||
t.Error("阿里云缺 AccessKeyId 应校验失败") |
|||
} |
|||
|
|||
// 阿里云:地域与模板变量名留空时填默认值。
|
|||
o, err = newOptionsChecked(map[string]interface{}{ |
|||
"Provider": "Aliyun", "AccessKeyId": "ak", "AccessKeySecret": "sk", "SignName": "签名", |
|||
}) |
|||
if err != nil { |
|||
t.Fatalf("阿里云配置应通过校验: %v", err) |
|||
} |
|||
if o.Provider != ProviderAliyun { |
|||
t.Errorf("Provider 应忽略大小写归一为 %s,得到 %q", ProviderAliyun, o.Provider) |
|||
} |
|||
if o.RegionId != defaultAliRegionId || o.TemplateParamName != defaultAliTemplateParam { |
|||
t.Errorf("阿里云默认值未填充: RegionId=%q TemplateParamName=%q", o.RegionId, o.TemplateParamName) |
|||
} |
|||
|
|||
if _, err := newOptionsChecked(map[string]interface{}{"Provider": "huawei"}); err == nil { |
|||
t.Error("未知服务商应校验失败") |
|||
} |
|||
} |
|||
@ -1,58 +1,55 @@ |
|||
package sms |
|||
|
|||
import ( |
|||
"fmt" |
|||
"strings" |
|||
|
|||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" |
|||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" |
|||
sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111" // 引入sms
|
|||
) |
|||
|
|||
func newSys(options Options) (sys *SMS, err error) { |
|||
sys = &SMS{options: options} |
|||
// 必要步骤:
|
|||
// 实例化一个认证对象,入参需要传入腾讯云账户密钥对secretId,secretKey。
|
|||
credential := common.NewCredential( |
|||
options.SecretId, // 替换为你的SecretId
|
|||
options.SecretKey, // 替换为你的SecretKey
|
|||
) |
|||
// 实例化一个客户端配置对象,可以指定超时时间等配置
|
|||
cpf := profile.NewClientProfile() |
|||
cpf.HttpProfile.ReqMethod = "POST" |
|||
cpf.HttpProfile.ReqTimeout = 30 |
|||
cpf.SignMethod = "HmacSHA1" |
|||
// 实例化要请求产品的client对象
|
|||
sys.client, err = sms.NewClient(credential, "ap-guangzhou", cpf) |
|||
return |
|||
// newSys 按 Provider 建对应服务商的客户端(腾讯云 / 阿里云)。
|
|||
func newSys(options Options) (sys ISys, err error) { |
|||
switch options.provider() { |
|||
case ProviderTencent: |
|||
return newTencent(options) |
|||
case ProviderAliyun: |
|||
return newAliyun(options) |
|||
default: |
|||
return nil, fmt.Errorf("sms 未知服务商: %s", options.Provider) |
|||
} |
|||
} |
|||
|
|||
type SMS struct { |
|||
options Options |
|||
client *sms.Client |
|||
// isDomestic 判断是否国内号码(决定用国内模板还是国际模板)。
|
|||
// 号码来自客户端,格式不统一:可能是 +8613…、008613…、13… 或国际号 +852…。
|
|||
// 带国家码前缀的看前缀;不带前缀的只有"1 开头的 11 位"按国内算(大陆手机号),
|
|||
// 其余仍按国际处理——与加入阿里云之前的行为保持一致。
|
|||
func isDomestic(mobile string) bool { |
|||
m := normalizeMobile(mobile) |
|||
if strings.HasPrefix(m, "+") { |
|||
return strings.HasPrefix(m, "+86") |
|||
} |
|||
if strings.HasPrefix(m, "00") { |
|||
return strings.HasPrefix(m, "0086") |
|||
} |
|||
return len(m) == 11 && strings.HasPrefix(m, "1") |
|||
} |
|||
|
|||
// 发送短信验证吗
|
|||
func (this *SMS) SendCaptcha(mobile string, captcha string) (err error) { |
|||
var ( |
|||
Template string |
|||
) |
|||
if strings.Contains(mobile, "+86") { |
|||
Template = this.options.Template1 |
|||
} else { |
|||
Template = this.options.Template2 |
|||
// normalizeMobile 去掉号码里的空格、短横等分隔符。
|
|||
func normalizeMobile(mobile string) string { |
|||
return strings.NewReplacer(" ", "", "-", "", "\t", "").Replace(strings.TrimSpace(mobile)) |
|||
} |
|||
|
|||
// 实例化一个请求对象,根据调用的接口和实际情况,可以进一步设置请求参数
|
|||
request := sms.NewSendSmsRequest() |
|||
// 基本类型的设置。
|
|||
request.SmsSdkAppId = common.StringPtr(this.options.AppId) // 替换为你的SDK AppID
|
|||
request.SignName = common.StringPtr(this.options.SignName) // 替换为你的签名
|
|||
request.TemplateId = common.StringPtr(Template) // 替换为你的模板ID
|
|||
request.PhoneNumberSet = common.StringPtrs([]string{mobile}) // 替换为接收短信的手机号
|
|||
request.TemplateParamSet = common.StringPtrs([]string{captcha}) // 替换为模板中的参数
|
|||
// 通过client对象调用想要访问的接口,需要传入请求对象
|
|||
if _, err = this.client.SendSms(request); err != nil { |
|||
return |
|||
} |
|||
return |
|||
// pickTemplate 按号码归属选模板;对应模板没配时回退到另一个,两个都没配则报错
|
|||
// (模板 ID 是发短信的必填项,缺了发出去也是失败,不如早点给出可读的错误)。
|
|||
func pickTemplate(o Options, mobile string) (string, error) { |
|||
domestic := isDomestic(mobile) |
|||
primary, fallback := o.Template1, o.Template2 |
|||
if !domestic { |
|||
primary, fallback = o.Template2, o.Template1 |
|||
} |
|||
if primary != "" { |
|||
return primary, nil |
|||
} |
|||
if fallback != "" { |
|||
return fallback, nil |
|||
} |
|||
return "", fmt.Errorf("sms 配置缺失: 未配置短信模板(Template1/Template2)") |
|||
} |
|||
|
|||
@ -0,0 +1,72 @@ |
|||
package sms |
|||
|
|||
import ( |
|||
"fmt" |
|||
|
|||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" |
|||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" |
|||
sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111" // 引入sms
|
|||
) |
|||
|
|||
func newTencent(options Options) (sys *Tencent, err error) { |
|||
sys = &Tencent{options: options} |
|||
// 必要步骤:
|
|||
// 实例化一个认证对象,入参需要传入腾讯云账户密钥对secretId,secretKey。
|
|||
credential := common.NewCredential( |
|||
options.SecretId, // 替换为你的SecretId
|
|||
options.SecretKey, // 替换为你的SecretKey
|
|||
) |
|||
// 实例化一个客户端配置对象,可以指定超时时间等配置
|
|||
cpf := profile.NewClientProfile() |
|||
cpf.HttpProfile.ReqMethod = "POST" |
|||
cpf.HttpProfile.ReqTimeout = 30 |
|||
cpf.SignMethod = "HmacSHA1" |
|||
// 实例化要请求产品的client对象
|
|||
sys.client, err = sms.NewClient(credential, "ap-guangzhou", cpf) |
|||
return |
|||
} |
|||
|
|||
type Tencent struct { |
|||
options Options |
|||
client *sms.Client |
|||
} |
|||
|
|||
// 发送短信验证吗
|
|||
func (this *Tencent) SendCaptcha(mobile string, captcha string) (err error) { |
|||
template, err := pickTemplate(this.options, mobile) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// 实例化一个请求对象,根据调用的接口和实际情况,可以进一步设置请求参数
|
|||
request := sms.NewSendSmsRequest() |
|||
// 基本类型的设置。
|
|||
request.SmsSdkAppId = common.StringPtr(this.options.AppId) // 替换为你的SDK AppID
|
|||
request.SignName = common.StringPtr(this.options.SignName) // 替换为你的签名
|
|||
request.TemplateId = common.StringPtr(template) // 替换为你的模板ID
|
|||
request.PhoneNumberSet = common.StringPtrs([]string{mobile}) // 替换为接收短信的手机号
|
|||
request.TemplateParamSet = common.StringPtrs([]string{captcha}) // 替换为模板中的参数
|
|||
// 通过client对象调用想要访问的接口,需要传入请求对象
|
|||
resp, err := this.client.SendSms(request) |
|||
if err != nil { |
|||
return |
|||
} |
|||
// 接口调用成功 ≠ 短信发出去了:签名/模板/号码有问题时 err 为 nil,
|
|||
// 真实结果在 SendStatusSet[i].Code(成功为 "Ok")。不看它会把失败当成功返回给客户端。
|
|||
if resp == nil || resp.Response == nil || len(resp.Response.SendStatusSet) == 0 { |
|||
return fmt.Errorf("腾讯云短信发送失败: 返回为空") |
|||
} |
|||
for _, st := range resp.Response.SendStatusSet { |
|||
if st == nil || st.Code == nil { |
|||
continue |
|||
} |
|||
if *st.Code != "Ok" { |
|||
msg := "" |
|||
if st.Message != nil { |
|||
msg = *st.Message |
|||
} |
|||
return fmt.Errorf("腾讯云短信发送失败: %s %s", *st.Code, msg) |
|||
} |
|||
} |
|||
return |
|||
} |
|||
Loading…
Reference in new issue