增加短信渠道
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
IntegrationKindSMS = "sms"
|
||||
IntegrationKindPayment = "payment"
|
||||
IntegrationKindMQ = "mq"
|
||||
IntegrationKindWebSocket = "websocket"
|
||||
@@ -255,6 +256,8 @@ func ValidateIntegrationConfig(kind, provider string, values map[string]any) err
|
||||
kind = normalizeIntegrationPart(kind)
|
||||
provider = normalizeIntegrationPart(provider)
|
||||
switch kind {
|
||||
case IntegrationKindSMS:
|
||||
return validateSMSConfig(provider, values)
|
||||
case IntegrationKindPayment:
|
||||
return validatePaymentIntegrationConfig(provider, values)
|
||||
case IntegrationKindMQ, IntegrationKindWebSocket:
|
||||
|
||||
@@ -120,6 +120,22 @@ func genericPaymentDefinition(provider, name, description string) IntegrationCon
|
||||
}
|
||||
|
||||
var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||
IntegrationKindSMS: {
|
||||
{Kind: IntegrationKindSMS, Provider: "aliyun", Name: "阿里云短信", Description: "中国大陆短信,使用已审核的签名和模板。", Defaults: map[string]any{"access_key_id": "", "access_key_secret": "", "sign_name": "", "template_id": ""}, Fields: []IntegrationConfigField{
|
||||
integrationField("access_key_id", "AccessKey ID", true, false, "text"),
|
||||
integrationField("access_key_secret", "AccessKey Secret", true, true, "password"),
|
||||
integrationField("sign_name", "短信签名", true, false, "text"),
|
||||
integrationField("template_id", "模板 CODE", true, false, "text"),
|
||||
}},
|
||||
{Kind: IntegrationKindSMS, Provider: "tencent", Name: "腾讯云短信", Description: "使用短信应用、已审核的签名和模板,模板参数按顺序填写。", Defaults: map[string]any{"secret_id": "", "secret_key": "", "sdk_app_id": "", "region": "ap-guangzhou", "sign_name": "", "template_id": ""}, Fields: []IntegrationConfigField{
|
||||
integrationField("secret_id", "Secret ID", true, true, "password"),
|
||||
integrationField("secret_key", "Secret Key", true, true, "password"),
|
||||
integrationField("sdk_app_id", "短信应用 SDK App ID", true, false, "text"),
|
||||
integrationField("region", "地域", true, false, "text"),
|
||||
integrationField("sign_name", "短信签名", true, false, "text"),
|
||||
integrationField("template_id", "模板 ID", true, false, "text"),
|
||||
}},
|
||||
},
|
||||
IntegrationKindMQ: {
|
||||
{
|
||||
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
|
||||
|
||||
@@ -3,4 +3,4 @@ package integration
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet wires communication and integration-configuration usecases.
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigUsecase)
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigUsecase, NewSMSUsecase)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSMSInvalid = errors.BadRequest("SMS_INVALID", "短信参数无效")
|
||||
ErrSMSDisabled = errors.BadRequest("SMS_DISABLED", "短信渠道未配置或未启用")
|
||||
ErrSMSFailed = errors.BadRequest("SMS_SEND_FAILED", "短信提交失败,请检查服务商配置和账户状态")
|
||||
)
|
||||
|
||||
// SMSMessage carries one recipient and provider-specific template parameters.
|
||||
type SMSMessage struct {
|
||||
Provider string
|
||||
Phone string
|
||||
NamedParams map[string]string
|
||||
Params []string
|
||||
}
|
||||
|
||||
type SMSResult struct {
|
||||
RequestID string
|
||||
MessageID string
|
||||
}
|
||||
|
||||
type SMSSender interface {
|
||||
Send(context.Context, *SMSMessage) (*SMSResult, error)
|
||||
}
|
||||
|
||||
type SMSUsecase struct{ sender SMSSender }
|
||||
|
||||
func NewSMSUsecase(sender SMSSender) *SMSUsecase { return &SMSUsecase{sender: sender} }
|
||||
|
||||
var smsPhonePattern = regexp.MustCompile(`^\+[1-9][0-9]{6,14}$`)
|
||||
|
||||
func ValidateSMSMessage(m *SMSMessage) error {
|
||||
if m == nil || (m.Provider != "aliyun" && m.Provider != "tencent") || !smsPhonePattern.MatchString(m.Phone) {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
// The Aliyun adapter uses the mainland China SendSms API.
|
||||
if m.Provider == "aliyun" && (!strings.HasPrefix(m.Phone, "+861") || len(m.Phone) != 14 || len(m.Params) != 0) {
|
||||
return errors.BadRequest("SMS_INVALID", "阿里云短信请使用 +86 开头的中国大陆手机号及对象格式模板参数")
|
||||
}
|
||||
if m.Provider == "tencent" && len(m.NamedParams) != 0 {
|
||||
return errors.BadRequest("SMS_INVALID", "腾讯云短信模板参数必须是按模板顺序排列的字符串数组")
|
||||
}
|
||||
if len(m.NamedParams) > 20 || len(m.Params) > 20 {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
for key, value := range m.NamedParams {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 64 || len(value) > 500 {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
}
|
||||
for _, value := range m.Params {
|
||||
if len(value) > 500 {
|
||||
return ErrSMSInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *SMSUsecase) Send(ctx context.Context, m *SMSMessage) (*SMSResult, error) {
|
||||
if err := ValidateSMSMessage(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.sender.Send(ctx, m)
|
||||
}
|
||||
|
||||
func validateSMSConfig(provider string, values map[string]any) error {
|
||||
definition, ok := IntegrationDefinition(IntegrationKindSMS, provider)
|
||||
if !ok {
|
||||
return errors.BadRequest("SMS_PROVIDER_INVALID", "不支持的短信服务商")
|
||||
}
|
||||
for _, field := range definition.Fields {
|
||||
value, ok := values[field.Key].(string)
|
||||
if !ok || len(value) > 512 || (field.Required && strings.TrimSpace(value) == "") {
|
||||
return errors.BadRequest("SMS_CONFIG_INVALID", "请填写有效的"+field.Label)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -179,6 +179,21 @@ func openWithDriverConfig(driver, dsn string, config *config.Database, appLogger
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func bootstrapDatabaseDSN(c *config.Database) (string, error) {
|
||||
bootstrap := *c
|
||||
// An empty name passed to databaseDSN falls back to the configured name.
|
||||
// Clear both fields so MySQL can connect before the target database exists.
|
||||
bootstrap.Name = ""
|
||||
bootstrap.Source = ""
|
||||
switch normalizedDriver(c.Driver) {
|
||||
case "pgsql":
|
||||
bootstrap.Name = "postgres"
|
||||
case "mssql":
|
||||
bootstrap.Name = "master"
|
||||
}
|
||||
return databaseDSN(&bootstrap, "")
|
||||
}
|
||||
|
||||
func openDatabase(c *config.Database, create bool, template string, appLogger *slog.Logger) (*gorm.DB, error) {
|
||||
driver := normalizedDriver(c.Driver)
|
||||
if driver == "" {
|
||||
@@ -198,14 +213,7 @@ func openDatabase(c *config.Database, create bool, template string, appLogger *s
|
||||
if !databaseNamePattern.MatchString(c.Name) {
|
||||
return nil, fmt.Errorf("invalid database name %q", c.Name)
|
||||
}
|
||||
bootstrap := ""
|
||||
switch driver {
|
||||
case "pgsql":
|
||||
bootstrap = "postgres"
|
||||
case "mssql":
|
||||
bootstrap = "master"
|
||||
}
|
||||
dsn, err := databaseDSN(c, bootstrap)
|
||||
dsn, err := bootstrapDatabaseDSN(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,9 +4,44 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestMySQLBootstrapConnectsWithoutTargetDatabase(t *testing.T) {
|
||||
for _, host := range []string{"", "mysql"} {
|
||||
t.Run("host="+host, func(t *testing.T) {
|
||||
cfg := &config.Database{Driver: "mysql", Host: host, Name: "kra_new", Source: "root@tcp(localhost:3306)/kra_new"}
|
||||
original := *cfg
|
||||
dsn, err := bootstrapDatabaseDSN(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := mysqldriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.DBName != "" {
|
||||
t.Fatalf("bootstrap selected database %q before creating it", parsed.DBName)
|
||||
}
|
||||
if *cfg != original {
|
||||
t.Fatal("bootstrap changed the target configuration")
|
||||
}
|
||||
target, err := databaseDSN(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err = mysqldriver.ParseDSN(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.DBName != "kra_new" {
|
||||
t.Fatalf("target connection selected database %q", parsed.DBName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLDatabaseDSNIncludesNetworkTimeouts(t *testing.T) {
|
||||
dsn, err := databaseDSN(&config.Database{
|
||||
Driver: "mysql",
|
||||
|
||||
@@ -16,7 +16,7 @@ func ReadRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
|
||||
}
|
||||
var rows []ConfigPO
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind IN ?", []string{"mq", "websocket"}).
|
||||
Where("kind IN ?", []string{"mq", "websocket", "sms"}).
|
||||
Order("kind ASC, provider ASC").
|
||||
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMSSecretsSurviveMaskedSaveAndRuntimeReload(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ConfigPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db)}, store: runtimeconfig.NewStore()}
|
||||
repo := NewIntegrationConfigRepo(p)
|
||||
for _, provider := range []string{"aliyun", "tencent"} {
|
||||
values := biz.DefaultIntegrationConfig("sms", provider)
|
||||
for k, v := range values {
|
||||
if v == "" {
|
||||
values[k] = "test-" + k
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: "sms", Provider: provider, Enabled: true, Values: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
masked, err := repo.FindIntegrationConfig(context.Background(), "sms", provider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var public map[string]string
|
||||
_ = json.Unmarshal(masked.Values, &public)
|
||||
for k := range values {
|
||||
if biz.IsIntegrationSecretKey(k) && public[k] != "******" {
|
||||
t.Fatalf("unmasked key %s", k)
|
||||
}
|
||||
}
|
||||
if err = repo.SaveIntegrationConfig(context.Background(), masked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rows, err := ReadRuntime(db)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("runtime rows=%d error=%v", len(rows), err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
var values map[string]string
|
||||
_ = json.Unmarshal(row.Values, &values)
|
||||
key := "access_key_secret"
|
||||
if row.Provider == "tencent" {
|
||||
key = "secret_key"
|
||||
}
|
||||
if values[key] != "test-"+key {
|
||||
t.Fatal("saved secret did not survive runtime reload")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 13 {
|
||||
t.Fatalf("migration versions = %d, want 13", versions)
|
||||
if versions != 14 {
|
||||
t.Fatalf("migration versions = %d, want 14", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||
}
|
||||
}
|
||||
if got := catalog.MigrationSteps(); len(got) != 13 {
|
||||
t.Fatalf("module migrations = %d, want 13", len(got))
|
||||
if got := catalog.MigrationSteps(); len(got) != 14 {
|
||||
t.Fatalf("module migrations = %d, want 14", len(got))
|
||||
}
|
||||
var menus, apis int
|
||||
for _, definition := range catalog.Definitions {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 3 || apis != 17 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis)
|
||||
if menus != 4 || apis != 18 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 4/18", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -3,18 +3,31 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
dataintegration "kra/internal/data/integration"
|
||||
datasystem "kra/internal/data/system"
|
||||
"kra/internal/modules/surface"
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
func Definition() module.Definition {
|
||||
smsMenu := module.Menu{Name: "sms", Path: "sms", ParentName: "extensions", Component: "view/systemTools/sms/index.vue", Title: "短信服务", Icon: "message", Sort: 9}
|
||||
migrations := append(dataintegration.Migrations(), migration.Step{ID: "202609290001_sms_admin_surface", Migrate: func(db *gorm.DB) error {
|
||||
return datasystem.EnsureAdminSurface(db, module.Surface{
|
||||
Menus: []module.Menu{smsMenu},
|
||||
APIs: []module.API{{Path: "/integration/sms/:provider/send", Method: "POST", Group: "短信服务", Description: "发送单条模板短信"}},
|
||||
}, 888)
|
||||
}})
|
||||
return module.Definition{
|
||||
Name: "integration",
|
||||
Migrations: dataintegration.Migrations(),
|
||||
Migrations: migrations,
|
||||
Surface: module.Surface{
|
||||
Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}},
|
||||
APIs: surface.APIsForPrefix("/integration/configs", 5),
|
||||
Menus: []module.Menu{
|
||||
{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8},
|
||||
smsMenu,
|
||||
},
|
||||
APIs: surface.APIsForPrefix("/integration/", 6),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import "testing"
|
||||
|
||||
func TestDefinitionOwnsCommunicationSurface(t *testing.T) {
|
||||
definition := Definition()
|
||||
if definition.Name != "integration" || len(definition.Migrations) != 5 || len(definition.Surface.Menus) != 1 || len(definition.Surface.APIs) != 5 {
|
||||
if definition.Name != "integration" || len(definition.Migrations) != 6 || len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 6 {
|
||||
t.Fatalf("integration surface = %#v", definition.Surface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type RouteBodyPolicy string
|
||||
|
||||
const (
|
||||
BodyPolicyDefault RouteBodyPolicy = "default"
|
||||
BodyPolicySMS RouteBodyPolicy = "sms"
|
||||
BodyPolicyIntegrationConfig RouteBodyPolicy = "integration_config"
|
||||
BodyPolicyPaymentConfig RouteBodyPolicy = "payment_config"
|
||||
BodyPolicyPaymentCallback RouteBodyPolicy = "payment_callback"
|
||||
@@ -168,6 +169,7 @@ var routes = map[string]routeValue{
|
||||
"POST /init/checkdb": {group: "初始化", description: "检查数据库", public: true},
|
||||
"POST /init/initdb": {group: "初始化", description: "初始化数据库", public: true},
|
||||
"POST /integration/configs/:kind/:provider/test": {group: "集成配置", description: "测试通信集成连接", audit: true, bodyPolicy: BodyPolicyIntegrationConfig},
|
||||
"POST /integration/sms/:provider/send": {group: "短信服务", description: "发送单条模板短信", audit: true, bodyPolicy: BodyPolicySMS},
|
||||
"POST /jwt/jsonInBlacklist": {group: "jwt", description: "jwt加入黑名单(退出,必选)"},
|
||||
"POST /mediaUpload/chunk": {group: "媒体上传", description: "上传分片", bodyPolicy: BodyPolicyUpload},
|
||||
"POST /mediaUpload/complete": {group: "媒体上传", description: "完成大文件上传"},
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":196`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":197`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession,
|
||||
NewIntegrationConfig,
|
||||
NewSMS,
|
||||
wire.Struct(new(Set), "*"),
|
||||
)
|
||||
|
||||
@@ -138,6 +138,8 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc {
|
||||
Write(c, CodeSuccess, gin.H{}, "自动创建数据库成功")
|
||||
return
|
||||
}
|
||||
// Preserve the underlying error for ErrorAudit without exposing it in the response.
|
||||
_ = c.Error(err).SetType(gin.ErrorTypePrivate)
|
||||
Fail(c, "自动创建数据库失败,请查看后台日志,检查后在进行初始化")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
SMS *SMS
|
||||
Authority *Authority
|
||||
Menu *Menu
|
||||
API *API
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/integration"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SMS struct{ service *service.SMSService }
|
||||
|
||||
func NewSMS(s *service.SMSService) *SMS { return &SMS{service: s} }
|
||||
|
||||
func (h *SMS) Send(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var req dto.SMSSendRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, "短信参数无效")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Send(c.Request.Context(), c.Param("provider"), &req)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
@@ -87,7 +87,9 @@ func AccessLog(runtime *config.Store, logger *slog.Logger, version string) gin.H
|
||||
paymentCallback := bodyPolicy == routecatalog.BodyPolicyPaymentCallback
|
||||
paymentConfigWrite := bodyPolicy == routecatalog.BodyPolicyPaymentConfig
|
||||
requestText := ""
|
||||
if paymentCallback {
|
||||
if bodyPolicy == routecatalog.BodyPolicySMS {
|
||||
requestText = "[短信发送参数已隐藏]"
|
||||
} else if paymentCallback {
|
||||
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
|
||||
} else if paymentConfigWrite {
|
||||
requestText = paymentConfigSummary(requestBody)
|
||||
|
||||
@@ -101,6 +101,9 @@ func OperationAudit(runtime *config.Store, recorder *systemservice.AuditRecorder
|
||||
if !capturedBody && routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig {
|
||||
operationBody = paymentConfigSummary(requestBody)
|
||||
}
|
||||
if routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicySMS {
|
||||
operationBody = "[短信发送参数已隐藏]"
|
||||
}
|
||||
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
||||
// Preserve the business response, but expose audit persistence failures
|
||||
// to the global access/error logging pipeline.
|
||||
|
||||
@@ -20,6 +20,7 @@ var sensitivePayloadKeys = map[string]struct{}{
|
||||
"passwd": {}, "pwd": {}, "token": {}, "accesstoken": {}, "refreshtoken": {},
|
||||
"secret": {}, "clientsecret": {}, "apikey": {}, "privatekey": {}, "idcard": {},
|
||||
"key": {}, "signingkey": {}, "secretkey": {},
|
||||
"accesskeysecret": {}, "secretid": {},
|
||||
"appkey": {}, "mchkey": {}, "apiv3key": {}, "clientcert": {}, "clientkey": {},
|
||||
"platformcert": {}, "platformserialno": {}, "publiccert": {}, "credentialcode": {}, "certfile": {},
|
||||
"keyfile": {}, "publickey": {}, "rootcert": {}, "appcert": {}, "webhookid": {},
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gin-gonic/gin"
|
||||
"kra/internal/config"
|
||||
"kra/internal/routecatalog"
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMSAuditHidesPhoneAndTemplateParameters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var output bytes.Buffer
|
||||
engine := gin.New()
|
||||
engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), slog.New(slog.NewJSONHandler(&output, nil)), "test"))
|
||||
engine.POST("/api/integration/sms/:provider/send", func(c *gin.Context) {
|
||||
if body, _ := c.Get(ctxReqBodyKey); body != "[短信发送参数已隐藏]" {
|
||||
t.Fatalf("unsafe audit body: %v", body)
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 0})
|
||||
})
|
||||
path := "/api/integration/sms/aliyun/send"
|
||||
request := httptest.NewRequest("POST", path, strings.NewReader(`{"phone":"+8613800138000","params":{"code":"987654"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(httptest.NewRecorder(), request)
|
||||
for _, value := range []string{"13800138000", "987654"} {
|
||||
if strings.Contains(output.String(), value) {
|
||||
t.Fatal("SMS content leaked into access log")
|
||||
}
|
||||
}
|
||||
if !routecatalog.ShouldAudit("POST", path) {
|
||||
t.Fatal("send route is not audited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSConfigSecretsAreRedacted(t *testing.T) {
|
||||
text := redactJSON([]byte(`{"config":{"access_key_secret":"ali-value","secret_id":"tc-id-value","secret_key":"tc-value"}}`), "application/json", 4096)
|
||||
for _, secret := range []string{"ali-value", "tc-id-value", "tc-value"} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Fatalf("secret leaked: %s", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
func() { RegisterAnnouncement(private, public, r.handlers.Announcement) },
|
||||
func() { RegisterEmail(private, r.handlers.Email) },
|
||||
func() { RegisterIntegrationConfig(private, r.handlers.IntegrationConfig) },
|
||||
func() { private.POST("/integration/sms/:provider/send", r.handlers.SMS.Send) },
|
||||
func() { RegisterPayment(private, public, r.handlers.Payment) },
|
||||
}
|
||||
for _, register := range registrations {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package dto
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type SMSSendRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type SMSSendResponse struct {
|
||||
RequestID string `json:"requestId"`
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
@@ -3,4 +3,4 @@ package integration
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet wires integration transport services.
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigService)
|
||||
var ProviderSet = wire.NewSet(NewIntegrationConfigService, NewSMSService)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type smsUsecase interface {
|
||||
Send(context.Context, *biz.SMSMessage) (*biz.SMSResult, error)
|
||||
}
|
||||
|
||||
type SMSService struct{ uc smsUsecase }
|
||||
|
||||
func NewSMSService(uc *biz.SMSUsecase) *SMSService { return &SMSService{uc: uc} }
|
||||
|
||||
func (s *SMSService) Send(ctx context.Context, provider string, req *dto.SMSSendRequest) (*dto.SMSSendResponse, error) {
|
||||
if req == nil || len(req.Params) > 12000 {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m := &biz.SMSMessage{Provider: provider, Phone: strings.TrimSpace(req.Phone)}
|
||||
params := bytes.TrimSpace(req.Params)
|
||||
var values any
|
||||
if json.Unmarshal(params, &values) != nil {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
switch provider {
|
||||
case "aliyun":
|
||||
object, ok := values.(map[string]any)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.NamedParams = make(map[string]string, len(object))
|
||||
for key, value := range object {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.NamedParams[key] = text
|
||||
}
|
||||
case "tencent":
|
||||
array, ok := values.([]any)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.Params = make([]string, 0, len(array))
|
||||
for _, value := range array {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
m.Params = append(m.Params, text)
|
||||
}
|
||||
default:
|
||||
return nil, biz.ErrSMSInvalid
|
||||
}
|
||||
if err := biz.ValidateSMSMessage(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.uc.Send(ctx, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.SMSSendResponse{RequestID: result.RequestID, MessageID: result.MessageID}, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
biz "kra/internal/biz/integration"
|
||||
"kra/internal/service/dto"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type smsSenderFake struct{ calls int }
|
||||
|
||||
func (f *smsSenderFake) Send(context.Context, *biz.SMSMessage) (*biz.SMSResult, error) {
|
||||
f.calls++
|
||||
return &biz.SMSResult{MessageID: "test"}, nil
|
||||
}
|
||||
|
||||
func TestSMSRejectsInvalidInputBeforeDelivery(t *testing.T) {
|
||||
fake := &smsSenderFake{}
|
||||
s := &SMSService{uc: fake}
|
||||
for _, tt := range []struct{ provider, phone, params string }{
|
||||
{"aliyun", "+8613800138000", `[]`}, {"tencent", "+8613800138000", `{}`},
|
||||
{"tencent", "+8613800138000", `null`}, {"tencent", "+8613800138000", `[123]`},
|
||||
{"tencent", "+8613800138000", `[null]`}, {"aliyun", "+8613800138000", `{"code":null}`},
|
||||
{"aliyun", "13800138000", `{}`}, {"aliyun", "+12025550123", `{}`},
|
||||
{"aliyun", "+8613800138000,+8613800138001", `{}`}, {"other", "+8613800138000", `{}`},
|
||||
} {
|
||||
if _, err := s.Send(context.Background(), tt.provider, &dto.SMSSendRequest{Phone: tt.phone, Params: json.RawMessage(tt.params)}); err == nil {
|
||||
t.Fatalf("accepted invalid input: %+v", tt)
|
||||
}
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Fatal("invalid input reached sender")
|
||||
}
|
||||
for _, provider := range []string{"aliyun", "tencent"} {
|
||||
params := `{}`
|
||||
if provider == "tencent" {
|
||||
params = `[]`
|
||||
}
|
||||
if _, err := s.Send(context.Background(), provider, &dto.SMSSendRequest{Phone: "+8613800138000", Params: json.RawMessage(params)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user