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 |
package sms |
||||
|
|
||||
import ( |
import ( |
||||
|
"fmt" |
||||
"strings" |
"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) { |
// newSys 按 Provider 建对应服务商的客户端(腾讯云 / 阿里云)。
|
||||
sys = &SMS{options: options} |
func newSys(options Options) (sys ISys, err error) { |
||||
// 必要步骤:
|
switch options.provider() { |
||||
// 实例化一个认证对象,入参需要传入腾讯云账户密钥对secretId,secretKey。
|
case ProviderTencent: |
||||
credential := common.NewCredential( |
return newTencent(options) |
||||
options.SecretId, // 替换为你的SecretId
|
case ProviderAliyun: |
||||
options.SecretKey, // 替换为你的SecretKey
|
return newAliyun(options) |
||||
) |
default: |
||||
// 实例化一个客户端配置对象,可以指定超时时间等配置
|
return nil, fmt.Errorf("sms 未知服务商: %s", options.Provider) |
||||
cpf := profile.NewClientProfile() |
} |
||||
cpf.HttpProfile.ReqMethod = "POST" |
} |
||||
cpf.HttpProfile.ReqTimeout = 30 |
|
||||
cpf.SignMethod = "HmacSHA1" |
// isDomestic 判断是否国内号码(决定用国内模板还是国际模板)。
|
||||
// 实例化要请求产品的client对象
|
// 号码来自客户端,格式不统一:可能是 +8613…、008613…、13… 或国际号 +852…。
|
||||
sys.client, err = sms.NewClient(credential, "ap-guangzhou", cpf) |
// 带国家码前缀的看前缀;不带前缀的只有"1 开头的 11 位"按国内算(大陆手机号),
|
||||
return |
// 其余仍按国际处理——与加入阿里云之前的行为保持一致。
|
||||
|
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") |
||||
} |
} |
||||
|
|
||||
type SMS struct { |
// normalizeMobile 去掉号码里的空格、短横等分隔符。
|
||||
options Options |
func normalizeMobile(mobile string) string { |
||||
client *sms.Client |
return strings.NewReplacer(" ", "", "-", "", "\t", "").Replace(strings.TrimSpace(mobile)) |
||||
} |
} |
||||
|
|
||||
// 发送短信验证吗
|
// pickTemplate 按号码归属选模板;对应模板没配时回退到另一个,两个都没配则报错
|
||||
func (this *SMS) SendCaptcha(mobile string, captcha string) (err error) { |
// (模板 ID 是发短信的必填项,缺了发出去也是失败,不如早点给出可读的错误)。
|
||||
var ( |
func pickTemplate(o Options, mobile string) (string, error) { |
||||
Template string |
domestic := isDomestic(mobile) |
||||
) |
primary, fallback := o.Template1, o.Template2 |
||||
if strings.Contains(mobile, "+86") { |
if !domestic { |
||||
Template = this.options.Template1 |
primary, fallback = o.Template2, o.Template1 |
||||
} else { |
|
||||
Template = this.options.Template2 |
|
||||
} |
} |
||||
|
if primary != "" { |
||||
// 实例化一个请求对象,根据调用的接口和实际情况,可以进一步设置请求参数
|
return primary, nil |
||||
request := sms.NewSendSmsRequest() |
} |
||||
// 基本类型的设置。
|
if fallback != "" { |
||||
request.SmsSdkAppId = common.StringPtr(this.options.AppId) // 替换为你的SDK AppID
|
return fallback, nil |
||||
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 |
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