From a1b82a18405a5ce96dc016ca1fa77a4fc316917d Mon Sep 17 00:00:00 2001 From: John Mai Date: Tue, 16 May 2023 20:51:04 +0800 Subject: [PATCH] :sparkles: support qcloud sms --- gateways/aliyun/aliyun.go | 32 +++-- gateways/aliyun/aliyun_test.go | 4 +- gateways/aliyun/common.go | 28 ----- gateways/qcloud/qcloud.go | 203 +++++++++++++++++++++++++++++++ gateways/qcloud/qcloud_test.go | 33 +++++ gateways/yunpian/common.go | 21 ---- gateways/yunpian/yunpian.go | 31 ++++- gateways/yunpian/yunpian_test.go | 9 +- phone_number.go | 14 +-- phone_number_test.go | 27 ++-- utils/utils.go | 17 +++ 11 files changed, 334 insertions(+), 85 deletions(-) delete mode 100644 gateways/aliyun/common.go create mode 100644 gateways/qcloud/qcloud.go create mode 100644 gateways/qcloud/qcloud_test.go delete mode 100644 gateways/yunpian/common.go diff --git a/gateways/aliyun/aliyun.go b/gateways/aliyun/aliyun.go index 0eda1ad..f633fb0 100644 --- a/gateways/aliyun/aliyun.go +++ b/gateways/aliyun/aliyun.go @@ -15,6 +15,17 @@ import ( "time" ) +const NAME = "aliyun" + +const EndpointUrl = "http://dysmsapi.aliyuncs.com" +const EndpointMethod = "SendSms" +const EndpointVersion = "2017-05-25" +const EndpointFormat = "JSON" +const EndpointRegionId = "cn-hangzhou" +const EndpointSignatureMethod = "HMAC-SHA1" +const EndpointSignatureVersion = "1.0" +const OK = "OK" + var _ gsms.Gateway = (*Gateway)(nil) type Gateway struct { @@ -23,6 +34,15 @@ type Gateway struct { SignName string } +// SendSmsResponse +// https://help.aliyun.com/document_detail/419273.htm?spm=a2c4g.11186623.0.0.4a0879bebUrJyq#api-detail-40 +type SendSmsResponse struct { + Code string `json:"Code"` + Message string `json:"Message"` + BizId string `json:"BizId"` + RequestId string `json:"RequestId"` +} + func (g *Gateway) Name() string { return NAME } @@ -58,9 +78,9 @@ func (g *Gateway) Send(to *gsms.PhoneNumber, message gsms.Message, config *gsms. } query.Add("TemplateParam", string(marshal)) - query.Add("Signature", generateSign(http.MethodGet, g.AccessKeySecret, query)) + query.Add("Signature", g.generateSign(query)) - var response Response + var response SendSmsResponse d := dove.New(dove.WithTimeout(config.Timeout), dove.WithLogger(config.Logger)) @@ -78,18 +98,16 @@ func (g *Gateway) Send(to *gsms.PhoneNumber, message gsms.Message, config *gsms. // generateSign Generate sign. // https://help.aliyun.com/document_detail/101343.html -func generateSign(httpMethod, accessKeySecret string, query url.Values) string { - httpMethod = strings.ToUpper(httpMethod) - +func (g *Gateway) generateSign(query url.Values) string { encode := url.QueryEscape(query.Encode()) encode = strings.Replace(encode, "+", "%20", -1) encode = strings.Replace(encode, "*", "%2A", -1) encode = strings.Replace(encode, "%7E", "~", -1) - h := hmac.New(sha1.New, []byte(accessKeySecret+"&")) + h := hmac.New(sha1.New, []byte(g.AccessKeySecret+"&")) - h.Write([]byte(fmt.Sprintf("%s&%%2F&%s", httpMethod, encode))) + h.Write([]byte(fmt.Sprintf("%s&%%2F&%s", http.MethodGet, encode))) return base64.StdEncoding.EncodeToString(h.Sum(nil)) } diff --git a/gateways/aliyun/aliyun_test.go b/gateways/aliyun/aliyun_test.go index 2744681..7c36da2 100644 --- a/gateways/aliyun/aliyun_test.go +++ b/gateways/aliyun/aliyun_test.go @@ -58,7 +58,9 @@ func Test_generateSign(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, generateSign(tt.args.httpMethod, tt.args.accessKeySecret, tt.args.query)) + g := &Gateway{AccessKeySecret: tt.args.accessKeySecret} + + assert.Equal(t, tt.want, g.generateSign(tt.args.query)) }) } } diff --git a/gateways/aliyun/common.go b/gateways/aliyun/common.go deleted file mode 100644 index bcece41..0000000 --- a/gateways/aliyun/common.go +++ /dev/null @@ -1,28 +0,0 @@ -package aliyun - -const NAME = "aliyun" - -const EndpointUrl = "http://dysmsapi.aliyuncs.com" - -const EndpointMethod = "SendSms" - -const EndpointVersion = "2017-05-25" - -const EndpointFormat = "JSON" - -const EndpointRegionId = "cn-hangzhou" - -const EndpointSignatureMethod = "HMAC-SHA1" - -const EndpointSignatureVersion = "1.0" - -const OK = "OK" - -// Response -// https://help.aliyun.com/document_detail/419273.htm?spm=a2c4g.11186623.0.0.4a0879bebUrJyq#api-detail-40 -type Response struct { - Code string `json:"Code"` - Message string `json:"Message"` - BizId string `json:"BizId"` - RequestId string `json:"RequestId"` -} diff --git a/gateways/qcloud/qcloud.go b/gateways/qcloud/qcloud.go new file mode 100644 index 0000000..dda3c19 --- /dev/null +++ b/gateways/qcloud/qcloud.go @@ -0,0 +1,203 @@ +package qcloud + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "github.com/maiqingqiang/gsms" + "github.com/maiqingqiang/gsms/utils" + "github.com/maiqingqiang/gsms/utils/dove" + "net/http" + "time" +) + +const NAME = "qcloud" + +const EndpointUrl = "https://sms.tencentcloudapi.com" +const EndpointHost = "sms.tencentcloudapi.com" +const EndpointService = "sms" +const EndpointMethod = "SendSms" +const EndpointVersion = "2021-01-11" +const EndpointRegion = "ap-guangzhou" +const Ok = "Ok" + +// SendSmsRequest 请求参数 https://cloud.tencent.com/document/api/382/55981 +// https://github.com/TencentCloud/signature-process-demo/blob/main/sms/signature-v3/golang/demo.go +type SendSmsRequest struct { + // 下发手机号码,采用 E.164 标准,格式为+[国家或地区码][手机号],单次请求最多支持200个手机号且要求全为境内手机号或全为境外手机号。 + // 例如:+8613711112222, 其中前面有一个+号 ,86为国家码,13711112222为手机号。 + PhoneNumberSet []string `json:"PhoneNumberSet,omitempty"` + + // 短信 SdkAppId,在 [短信控制台](https://console.cloud.tencent.com/smsv2/app-manage) 添加应用后生成的实际 SdkAppId,示例如1400006666。 + SmsSdkAppId string `json:"SmsSdkAppId,omitempty"` + + // 模板 ID,必须填写已审核通过的模板 ID。模板 ID 可登录 [短信控制台](https://console.cloud.tencent.com/smsv2) 查看,若向境外手机号发送短信,仅支持使用国际/港澳台短信模板。 + TemplateId string `json:"TemplateId,omitempty"` + + // 短信签名内容,使用 UTF-8 编码,必须填写已审核通过的签名,例如:腾讯云,签名信息可登录 [短信控制台](https://console.cloud.tencent.com/smsv2) 查看。 + // 注:国内短信为必填参数。 + SignName string `json:"SignName,omitempty"` + + // 模板参数,若无模板参数,则设置为空。 + TemplateParamSet []string `json:"TemplateParamSet,omitempty"` + + // 短信码号扩展号,默认未开通,如需开通请联系 [sms helper](https://cloud.tencent.com/document/product/382/3773#.E6.8A.80.E6.9C.AF.E4.BA.A4.E6.B5.81)。 + ExtendCode string `json:"ExtendCode,omitempty"` + + // 用户的 session 内容,可以携带用户侧 ID 等上下文信息,server 会原样返回。 + SessionContext string `json:"SessionContext,omitempty"` + + // 国内短信无需填写该项;国际/港澳台短信已申请独立 SenderId 需要填写该字段,默认使用公共 SenderId,无需填写该字段。 + // 注:月度使用量达到指定量级可申请独立 SenderId 使用,详情请联系 [sms helper](https://cloud.tencent.com/document/product/382/3773#.E6.8A.80.E6.9C.AF.E4.BA.A4.E6.B5.81)。 + SenderId string `json:"SenderId,omitempty"` +} + +type SendSmsResponse struct { + Response *struct { + SendStatusSet []*struct { + SerialNo string `json:"SerialNo"` + PhoneNumber string `json:"PhoneNumber"` + Fee int `json:"Fee"` + SessionContext string `json:"SessionContext"` + Code string `json:"Code"` + Message string `json:"Message"` + IsoCode string `json:"IsoCode"` + } `json:"SendStatusSet"` + + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + + RequestId string `json:"RequestId"` + } `json:"SendSmsResponse"` +} + +var _ gsms.Gateway = (*Gateway)(nil) + +type Gateway struct { + SdkAppId string + SecretId string + SecretKey string + SignName string +} + +func (g *Gateway) Name() string { + return NAME +} + +func (g *Gateway) Send(to *gsms.PhoneNumber, message gsms.Message, config *gsms.Config) error { + phone := fmt.Sprintf("%d", to.Number()) + if to.IDDCode() != 0 { + phone = to.UniversalNumber() + } + + template, err := message.GetTemplate(g) + if err != nil { + return err + } + + data, err := message.GetData(g) + if err != nil { + return err + } + + templateParamSet := make([]string, 0, len(data)) + + for _, s := range data { + templateParamSet = append(templateParamSet, s) + } + + p := &SendSmsRequest{ + PhoneNumberSet: []string{ + phone, + }, + SmsSdkAppId: g.SdkAppId, + SignName: g.SignName, + TemplateId: template, + TemplateParamSet: templateParamSet, + } + + d := dove.New(dove.WithTimeout(config.Timeout), dove.WithLogger(config.Logger)) + + timestamp := time.Now().Unix() + + payload, _ := json.Marshal(p) + + config.Logger.Infof("SendSmsRequest: %s", payload) + + header := http.Header{} + header.Add("Authorization", g.generateSign(string(payload), timestamp)) + header.Add("Host", EndpointHost) + header.Add("Content-Type", "application/json; charset=utf-8") + header.Add("X-TC-Action", EndpointMethod) + header.Add("X-TC-Region", EndpointRegion) + header.Add("X-TC-Timestamp", fmt.Sprintf("%d", timestamp)) + header.Add("X-TC-Version", EndpointVersion) + + var response SendSmsResponse + + err = d.Request(http.MethodPost, EndpointUrl, header, bytes.NewReader(payload), &response) + + if err != nil { + return err + } + + if response.Response.Error != nil && response.Response.Error.Code != "" { + return fmt.Errorf("send failed: %+v", response.Response.Error.Message) + } + + for _, status := range response.Response.SendStatusSet { + if status.Code != Ok { + return fmt.Errorf("send failed, status message: %s", status.Message) + } + } + + return nil +} + +// https://github.com/TencentCloud/signature-process-demo/blob/main/sms/signature-v3/golang/demo.go +func (g *Gateway) generateSign(params string, timestamp int64) string { + // step 1: build canonical request string + algorithm := "TC3-HMAC-SHA256" + httpRequestMethod := "POST" + canonicalURI := "/" + canonicalQueryString := "" + canonicalHeaders := "content-type:application/json; charset=utf-8\n" + "host:" + EndpointHost + "\n" + signedHeaders := "content-type;host" + hashedRequestPayload := utils.Sha256Hex(params) + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + httpRequestMethod, + canonicalURI, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + hashedRequestPayload) + + // step 2: build string to sign + date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, EndpointService) + hashedCanonicalRequest := utils.Sha256Hex(canonicalRequest) + string2sign := fmt.Sprintf("%s\n%d\n%s\n%s", + algorithm, + timestamp, + credentialScope, + hashedCanonicalRequest) + + // step 3: sign string + secretDate := utils.HmacSha256(date, "TC3"+g.SecretKey) + secretService := utils.HmacSha256(EndpointService, secretDate) + secretSigning := utils.HmacSha256("tc3_request", secretService) + signature := hex.EncodeToString([]byte(utils.HmacSha256(string2sign, secretSigning))) + + // step 4: build authorization + authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + algorithm, + g.SecretId, + credentialScope, + signedHeaders, + signature) + + return authorization +} diff --git a/gateways/qcloud/qcloud_test.go b/gateways/qcloud/qcloud_test.go new file mode 100644 index 0000000..6e87d01 --- /dev/null +++ b/gateways/qcloud/qcloud_test.go @@ -0,0 +1,33 @@ +package qcloud + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGateway_generateSign(t *testing.T) { + g := &Gateway{ + SdkAppId: "SdkAppId", + SecretId: "SecretId", + SecretKey: "SecretKey", + SignName: "gsms", + } + + params := &SendSmsRequest{ + PhoneNumberSet: []string{"18888888888"}, + SmsSdkAppId: g.SdkAppId, + TemplateId: "1111111", + SignName: g.SignName, + TemplateParamSet: []string{"521410", "5"}, + } + payload, _ := json.Marshal(params) + + sign := g.generateSign(string(payload), 1684225049) + + assert.Equal( + t, + "TC3-HMAC-SHA256 Credential=SecretId/2023-05-16/sms/tc3_request, SignedHeaders=content-type;host, Signature=c339e750c3b92ca97783de2c8d00434b12cf8f22c45d421fa8b0609d64e51358", + sign, + ) +} diff --git a/gateways/yunpian/common.go b/gateways/yunpian/common.go deleted file mode 100644 index 88f42e7..0000000 --- a/gateways/yunpian/common.go +++ /dev/null @@ -1,21 +0,0 @@ -package yunpian - -const NAME = "yunpian" -const EndpointTemplate = "https://%s.yunpian.com/%s/%s/%s.%s" -const EndpointVersion = "v2" -const EndpointFormat = "json" -const ProductSms = "sms" -const ResourceSms = "sms" -const SuccessCode = 0 - -// MethodSingleSend https://www.yunpian.com/official/document/sms/zh_cn/domestic_single_send -const MethodSingleSend = "single_send" - -// MethodTplSingleSend https://www.yunpian.com/official/document/sms/zh_CN/domestic_tpl_single_send -const MethodTplSingleSend = "tpl_single_send" - -type Response struct { - Code int `json:"code"` // 系统返回码 - Msg string `json:"msg"` // 例如""发送成功"",或者相应错误信息 - Detail string `json:"detail"` // 具体错误描述或解决方法 -} diff --git a/gateways/yunpian/yunpian.go b/gateways/yunpian/yunpian.go index 84aebac..e932528 100644 --- a/gateways/yunpian/yunpian.go +++ b/gateways/yunpian/yunpian.go @@ -8,6 +8,21 @@ import ( "strings" ) +const NAME = "yunpian" + +const EndpointTemplate = "https://%s.yunpian.com/%s/%s/%s.%s" +const EndpointVersion = "v2" +const EndpointFormat = "json" +const ProductSms = "sms" +const ResourceSms = "sms" +const SuccessCode = 0 + +// MethodSingleSend https://www.yunpian.com/official/document/sms/zh_cn/domestic_single_send +const MethodSingleSend = "single_send" + +// MethodTplSingleSend https://www.yunpian.com/official/document/sms/zh_CN/domestic_tpl_single_send +const MethodTplSingleSend = "tpl_single_send" + var _ gsms.Gateway = (*Gateway)(nil) type Gateway struct { @@ -15,6 +30,12 @@ type Gateway struct { Signature string } +type SendSmsResponse struct { + Code int `json:"code"` // 系统返回码 + Msg string `json:"msg"` // 例如""发送成功"",或者相应错误信息 + Detail string `json:"detail"` // 具体错误描述或解决方法 +} + // Send message. func (g *Gateway) Send(to *gsms.PhoneNumber, message gsms.Message, config *gsms.Config) (err error) { p := url.Values{} @@ -40,7 +61,7 @@ func (g *Gateway) Send(to *gsms.PhoneNumber, message gsms.Message, config *gsms. if template != "" { method = MethodTplSingleSend p.Add("tpl_id", template) - p.Add("tpl_value", buildTplVal(data)) + p.Add("tpl_value", g.buildTplVal(data)) } else { if !strings.HasPrefix(content, "【") { content = g.Signature + content @@ -49,9 +70,9 @@ func (g *Gateway) Send(to *gsms.PhoneNumber, message gsms.Message, config *gsms. p.Add("text", content) } - endpoint := buildEndpoint(ProductSms, ResourceSms, method) + endpoint := g.buildEndpoint(ProductSms, ResourceSms, method) - var response Response + var response SendSmsResponse d := dove.New(dove.WithTimeout(config.Timeout), dove.WithLogger(config.Logger)) @@ -68,12 +89,12 @@ func (g *Gateway) Send(to *gsms.PhoneNumber, message gsms.Message, config *gsms. } // buildEndpoint Build endpoint url. -func buildEndpoint(product, resource, method string) string { +func (g *Gateway) buildEndpoint(product, resource, method string) string { return fmt.Sprintf(EndpointTemplate, product, EndpointVersion, resource, method, EndpointFormat) } // buildTplVal Build template value. -func buildTplVal(data map[string]string) string { +func (g *Gateway) buildTplVal(data map[string]string) string { tplVals := make([]string, 0, len(data)) for k, v := range data { tplVals = append(tplVals, fmt.Sprintf("#%s#=%s", k, v)) diff --git a/gateways/yunpian/yunpian_test.go b/gateways/yunpian/yunpian_test.go index b9ccbf0..3fda700 100644 --- a/gateways/yunpian/yunpian_test.go +++ b/gateways/yunpian/yunpian_test.go @@ -9,9 +9,6 @@ import ( "time" ) -const NotSupportCountry = `{"http_status_code":400,"code":20,"msg":"暂不支持的国家地区","detail":"请确认号码归属地"}` -const Success = `{"code":0,"msg":"发送成功","count":1,"fee":0.05,"unit":"RMB","mobile":"18888888881","sid":74712264988}` - func Test_buildEndpoint(t *testing.T) { type args struct { product string @@ -35,7 +32,8 @@ func Test_buildEndpoint(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, buildEndpoint(tt.args.product, tt.args.resource, tt.args.method), "buildEndpoint(%v, %v, %v)", tt.args.product, tt.args.resource, tt.args.method) + g := &Gateway{} + assert.Equalf(t, tt.want, g.buildEndpoint(tt.args.product, tt.args.resource, tt.args.method), "buildEndpoint(%v, %v, %v)", tt.args.product, tt.args.resource, tt.args.method) }) } } @@ -233,7 +231,8 @@ func Test_buildTplVal(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, buildTplVal(tt.args.data), "buildTplVal(%v)", tt.args.data) + g := &Gateway{} + assert.Equalf(t, tt.want, g.buildTplVal(tt.args.data), "buildTplVal(%v)", tt.args.data) }) } } diff --git a/phone_number.go b/phone_number.go index 22d3853..e55c63f 100644 --- a/phone_number.go +++ b/phone_number.go @@ -11,19 +11,15 @@ type PhoneNumber struct { iddCode int } -func NewPhoneNumber(numberWithoutIDDCode int, iddCodeStr string) (*PhoneNumber, error) { - iddCodeStr = strings.TrimLeft(iddCodeStr, "+0") +func NewPhoneNumber(numberWithoutIDDCode int, iddCode string) *PhoneNumber { + iddCode = strings.TrimLeft(iddCode, "+0") - iddCode, err := strconv.Atoi(iddCodeStr) - - if err != nil { - return nil, ErrInvalidIDDCode - } + iddCodeInt, _ := strconv.Atoi(iddCode) return &PhoneNumber{ number: numberWithoutIDDCode, - iddCode: iddCode, - }, nil + iddCode: iddCodeInt, + } } func NewPhoneNumberWithoutIDDCode(numberWithoutIDDCode int) *PhoneNumber { diff --git a/phone_number_test.go b/phone_number_test.go index 0db0413..8db43a1 100644 --- a/phone_number_test.go +++ b/phone_number_test.go @@ -8,8 +8,7 @@ import ( func TestPhoneNumber(t *testing.T) { as := assert.New(t) - phoneNumber, err := NewPhoneNumber(18888888888, "86") - as.Nil(err) + phoneNumber := NewPhoneNumber(18888888888, "86") as.Equal(86, phoneNumber.IDDCode()) as.EqualValues(18888888888, phoneNumber.Number()) as.Equal("+8618888888888", phoneNumber.UniversalNumber()) @@ -46,17 +45,27 @@ func TestPhoneNumber_IDDCode(t *testing.T) { }, want: 86, }, + { + name: "empty", + fields: fields{ + number: 18888888888, + iddCode: "", + }, + want: 0, + }, + { + name: "other", + fields: fields{ + number: 18888888888, + iddCode: "other", + }, + want: 0, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p, _ := NewPhoneNumber(tt.fields.number, tt.fields.iddCode) + p := NewPhoneNumber(tt.fields.number, tt.fields.iddCode) assert.Equalf(t, tt.want, p.IDDCode(), "IDDCode()") }) } - - p, err := NewPhoneNumber(18888888888, "iddcode86") - assert.Nil(t, p) - assert.NotNil(t, err) - assert.Error(t, err) - assert.ErrorContains(t, err, "invalid IDDCode") } diff --git a/utils/utils.go b/utils/utils.go index d4b585b..0715379 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1 +1,18 @@ package utils + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +func HmacSha256(s, key string) string { + hashed := hmac.New(sha256.New, []byte(key)) + hashed.Write([]byte(s)) + return string(hashed.Sum(nil)) +} + +func Sha256Hex(s string) string { + b := sha256.Sum256([]byte(s)) + return hex.EncodeToString(b[:]) +}