增加短信渠道
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"kra/internal/integration/email"
|
||||
mqintegration "kra/internal/integration/mq"
|
||||
paymentintegration "kra/internal/integration/payment"
|
||||
"kra/internal/integration/sms"
|
||||
"kra/internal/integration/storage"
|
||||
"kra/internal/integration/systeminfo"
|
||||
websocketintegration "kra/internal/integration/websocket"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
var ProviderSet = wire.NewSet(
|
||||
cache.New,
|
||||
email.NewEmailRepo,
|
||||
sms.NewSender,
|
||||
paymentintegration.NewFactory,
|
||||
wire.Bind(new(bizpayment.PaymentAdapterFactory), new(*paymentintegration.Factory)),
|
||||
storage.NewFileStorage,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package sms implements cloud SMS delivery using saved integration settings.
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
aliyun "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
|
||||
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
tcerrors "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
tencent "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
|
||||
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/config"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
)
|
||||
|
||||
type aliyunClient interface {
|
||||
SendSmsWithOptions(*aliyun.SendSmsRequest, *util.RuntimeOptions) (*aliyun.SendSmsResponse, error)
|
||||
}
|
||||
type tencentClient interface {
|
||||
SendSmsWithContext(context.Context, *tencent.SendSmsRequest) (*tencent.SendSmsResponse, error)
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
store *runtimeconfig.Store
|
||||
newAliyun func(*openapi.Config) (aliyunClient, error)
|
||||
newTencent func(string, string, string) (tencentClient, error)
|
||||
}
|
||||
|
||||
func NewSender(store *runtimeconfig.Store) biz.SMSSender {
|
||||
return &Sender{store: store,
|
||||
newAliyun: func(c *openapi.Config) (aliyunClient, error) { return aliyun.NewClient(c) },
|
||||
newTencent: func(id, secret, region string) (tencentClient, error) {
|
||||
p := profile.NewClientProfile()
|
||||
p.HttpProfile.Endpoint = "sms.tencentcloudapi.com"
|
||||
p.HttpProfile.ReqTimeout = 10
|
||||
p.DisableRegionBreaker = true
|
||||
p.NetworkFailureMaxRetries = 0
|
||||
p.RateLimitExceededMaxRetries = 0
|
||||
p.UnsafeRetryOnConnectionFailure = false
|
||||
return tencent.NewClient(common.NewCredential(id, secret), region, p)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(ctx context.Context, m *biz.SMSMessage) (*biz.SMSResult, error) {
|
||||
if err := biz.ValidateSMSMessage(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, ok := s.store.Get(biz.IntegrationKindSMS, m.Provider)
|
||||
if !ok || !c.Enabled {
|
||||
return nil, biz.ErrSMSDisabled
|
||||
}
|
||||
values := map[string]any{}
|
||||
if json.Unmarshal(c.Values, &values) != nil {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindSMS, m.Provider, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range values {
|
||||
if text, ok := value.(string); ok && biz.IsIntegrationSecretKey(key) && config.IsMaskedSecret(text) {
|
||||
return nil, biz.ErrSMSDisabled
|
||||
}
|
||||
}
|
||||
get := func(key string) string { v, _ := values[key].(string); return v }
|
||||
if m.Provider == "aliyun" {
|
||||
return s.sendAliyun(ctx, m, get)
|
||||
}
|
||||
return s.sendTencent(ctx, m, get)
|
||||
}
|
||||
|
||||
func (s *Sender) sendAliyun(ctx context.Context, m *biz.SMSMessage, get func(string) string) (*biz.SMSResult, error) {
|
||||
// This SDK has no context-aware SendSms; bound both connection and read time.
|
||||
timeout := 10000
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
remaining := int(time.Until(deadline).Milliseconds())
|
||||
if remaining <= 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
if remaining < timeout {
|
||||
timeout = remaining
|
||||
}
|
||||
}
|
||||
client, err := s.newAliyun(&openapi.Config{
|
||||
AccessKeyId: tea.String(get("access_key_id")), AccessKeySecret: tea.String(get("access_key_secret")),
|
||||
Endpoint: tea.String("dysmsapi.aliyuncs.com"), Protocol: tea.String("https"),
|
||||
ConnectTimeout: tea.Int(timeout), ReadTimeout: tea.Int(timeout),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
params := m.NamedParams
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
response, err := client.SendSmsWithOptions(&aliyun.SendSmsRequest{
|
||||
PhoneNumbers: tea.String(strings.TrimPrefix(m.Phone, "+86")), SignName: tea.String(get("sign_name")),
|
||||
TemplateCode: tea.String(get("template_id")), TemplateParam: tea.String(string(raw)),
|
||||
}, &util.RuntimeOptions{Autoretry: tea.Bool(false), MaxAttempts: tea.Int(1), ConnectTimeout: tea.Int(timeout), ReadTimeout: tea.Int(timeout)})
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
if response == nil || response.Body == nil {
|
||||
return nil, biz.ErrSMSFailed
|
||||
}
|
||||
if tea.StringValue(response.Body.Code) != "OK" {
|
||||
return nil, providerError(tea.StringValue(response.Body.Code))
|
||||
}
|
||||
return &biz.SMSResult{RequestID: tea.StringValue(response.Body.RequestId), MessageID: tea.StringValue(response.Body.BizId)}, nil
|
||||
}
|
||||
|
||||
func (s *Sender) sendTencent(ctx context.Context, m *biz.SMSMessage, get func(string) string) (*biz.SMSResult, error) {
|
||||
client, err := s.newTencent(get("secret_id"), get("secret_key"), get("region"))
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
request := tencent.NewSendSmsRequest()
|
||||
request.PhoneNumberSet = common.StringPtrs([]string{m.Phone})
|
||||
request.SmsSdkAppId = common.StringPtr(get("sdk_app_id"))
|
||||
request.SignName = common.StringPtr(get("sign_name"))
|
||||
request.TemplateId = common.StringPtr(get("template_id"))
|
||||
request.TemplateParamSet = common.StringPtrs(m.Params)
|
||||
response, err := client.SendSmsWithContext(ctx, request)
|
||||
if err != nil {
|
||||
return nil, sdkError(err)
|
||||
}
|
||||
if response == nil || response.Response == nil || len(response.Response.SendStatusSet) != 1 || response.Response.SendStatusSet[0] == nil {
|
||||
return nil, biz.ErrSMSFailed
|
||||
}
|
||||
status := response.Response.SendStatusSet[0]
|
||||
if tea.StringValue(status.Code) != "Ok" {
|
||||
return nil, providerError(tea.StringValue(status.Code))
|
||||
}
|
||||
return &biz.SMSResult{RequestID: tea.StringValue(response.Response.RequestId), MessageID: tea.StringValue(status.SerialNo)}, nil
|
||||
}
|
||||
|
||||
var providerCodePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
func providerError(code string) error {
|
||||
if !providerCodePattern.MatchString(code) {
|
||||
return biz.ErrSMSFailed
|
||||
}
|
||||
return kratoserrors.BadRequest("SMS_SEND_FAILED", "短信提交失败,服务商错误码:"+code)
|
||||
}
|
||||
|
||||
// Return only provider codes: raw SDK errors can contain signed requests and PII.
|
||||
func sdkError(err error) error {
|
||||
var tc *tcerrors.TencentCloudSDKError
|
||||
if errors.As(err, &tc) {
|
||||
return providerError(tc.Code)
|
||||
}
|
||||
var ali *tea.SDKError
|
||||
if errors.As(err, &ali) {
|
||||
return providerError(tea.StringValue(ali.Code))
|
||||
}
|
||||
return biz.ErrSMSFailed
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
aliyun "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
|
||||
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
tencent "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
)
|
||||
|
||||
type aliFake struct {
|
||||
calls int
|
||||
request *aliyun.SendSmsRequest
|
||||
code string
|
||||
}
|
||||
|
||||
func (f *aliFake) SendSmsWithOptions(req *aliyun.SendSmsRequest, opts *util.RuntimeOptions) (*aliyun.SendSmsResponse, error) {
|
||||
f.calls++
|
||||
f.request = req
|
||||
if tea.BoolValue(opts.Autoretry) {
|
||||
panic("SMS must not retry automatically")
|
||||
}
|
||||
return &aliyun.SendSmsResponse{Body: &aliyun.SendSmsResponseBody{Code: tea.String(f.code), BizId: tea.String("ali-message"), RequestId: tea.String("ali-request")}}, nil
|
||||
}
|
||||
|
||||
type tcFake struct {
|
||||
calls int
|
||||
request *tencent.SendSmsRequest
|
||||
code string
|
||||
}
|
||||
|
||||
func (f *tcFake) SendSmsWithContext(_ context.Context, req *tencent.SendSmsRequest) (*tencent.SendSmsResponse, error) {
|
||||
f.calls++
|
||||
f.request = req
|
||||
return &tencent.SendSmsResponse{Response: &tencent.SendSmsResponseParams{RequestId: tea.String("tc-request"), SendStatusSet: []*tencent.SendStatus{{Code: tea.String(f.code), SerialNo: tea.String("tc-message")}}}}, nil
|
||||
}
|
||||
|
||||
func testSender(t *testing.T, provider string) *Sender {
|
||||
t.Helper()
|
||||
store := runtimeconfig.NewStore()
|
||||
values := biz.DefaultIntegrationConfig("sms", provider)
|
||||
for k, v := range values {
|
||||
if v == "" {
|
||||
values[k] = "test-" + k
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
store.Set(runtimeconfig.Config{Kind: "sms", Provider: provider, Enabled: true, Values: raw})
|
||||
return NewSender(store).(*Sender)
|
||||
}
|
||||
|
||||
func TestAliyunSendAndRejection(t *testing.T) {
|
||||
s := testSender(t, "aliyun")
|
||||
f := &aliFake{code: "OK"}
|
||||
s.newAliyun = func(c *openapi.Config) (aliyunClient, error) {
|
||||
if tea.StringValue(c.Endpoint) != "dysmsapi.aliyuncs.com" || tea.StringValue(c.AccessKeySecret) != "test-access_key_secret" {
|
||||
t.Fatal("incorrect client configuration")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
m := &biz.SMSMessage{Provider: "aliyun", Phone: "+8613800138000", NamedParams: map[string]string{"code": "123456"}}
|
||||
result, err := s.Send(context.Background(), m)
|
||||
if err != nil || result.MessageID != "ali-message" {
|
||||
t.Fatalf("result=%v error=%v", result, err)
|
||||
}
|
||||
if tea.StringValue(f.request.PhoneNumbers) != "13800138000" || tea.StringValue(f.request.TemplateParam) != `{"code":"123456"}` || tea.StringValue(f.request.TemplateCode) != "test-template_id" {
|
||||
t.Fatal("incorrect Aliyun request")
|
||||
}
|
||||
f.code = "isv.BUSINESS_LIMIT_CONTROL"
|
||||
if _, err = s.Send(context.Background(), m); err == nil || !strings.Contains(err.Error(), f.code) {
|
||||
t.Fatalf("provider rejection not surfaced: %v", err)
|
||||
}
|
||||
if f.calls != 2 {
|
||||
t.Fatal("unexpected retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTencentSendAndRejection(t *testing.T) {
|
||||
s := testSender(t, "tencent")
|
||||
f := &tcFake{code: "Ok"}
|
||||
s.newTencent = func(id, key, region string) (tencentClient, error) { return f, nil }
|
||||
m := &biz.SMSMessage{Provider: "tencent", Phone: "+8613800138000", Params: []string{"123456", "5"}}
|
||||
result, err := s.Send(context.Background(), m)
|
||||
if err != nil || result.MessageID != "tc-message" {
|
||||
t.Fatalf("result=%v error=%v", result, err)
|
||||
}
|
||||
if len(f.request.PhoneNumberSet) != 1 || *f.request.PhoneNumberSet[0] != m.Phone || len(f.request.TemplateParamSet) != 2 || *f.request.TemplateParamSet[1] != "5" || *f.request.SmsSdkAppId != "test-sdk_app_id" {
|
||||
t.Fatal("incorrect Tencent request")
|
||||
}
|
||||
f.code = "FailedOperation.SignatureIncorrectOrUnapproved"
|
||||
if _, err = s.Send(context.Background(), m); err == nil || !strings.Contains(err.Error(), f.code) {
|
||||
t.Fatalf("provider rejection not surfaced: %v", err)
|
||||
}
|
||||
c, _ := s.store.Get("sms", "tencent")
|
||||
c.Enabled = false
|
||||
s.store.Set(c)
|
||||
if _, err = s.Send(context.Background(), m); err == nil {
|
||||
t.Fatal("disabled channel sent SMS")
|
||||
}
|
||||
if f.calls != 2 {
|
||||
t.Fatal("disabled channel reached SDK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKErrorDoesNotExposeRawMessage(t *testing.T) {
|
||||
err := sdkError(&tea.SDKError{Code: tea.String("InvalidAccessKeyId.NotFound"), Message: tea.String("secret +8613800138000 signed request")})
|
||||
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "13800138000") || !strings.Contains(err.Error(), "InvalidAccessKeyId.NotFound") {
|
||||
t.Fatalf("unsafe error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user