增加短信渠道

This commit is contained in:
Yvan
2026-09-29 18:15:35 +08:00
parent a16b8f8e89
commit 89cc7761b5
38 changed files with 1469 additions and 39 deletions
@@ -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 消息服务",
+1 -1
View File
@@ -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)
+86
View File
@@ -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
}
+16 -8
View File
@@ -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
}
+35
View File
@@ -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",
+1 -1
View File
@@ -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
+62
View File
@@ -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")
}
}
}
+2 -2
View File
@@ -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 {
+2
View File
@@ -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,
+172
View File
@@ -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
}
+117
View File
@@ -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)
}
}
+4 -4
View File
@@ -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))
+16 -3
View File
@@ -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)
}
}
+2
View File
@@ -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: "完成大文件上传"},
+1 -1
View File
@@ -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)
}
}
+1
View File
@@ -10,5 +10,6 @@ var ProviderSet = wire.NewSet(
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession,
NewIntegrationConfig,
NewSMS,
wire.Struct(new(Set), "*"),
)
+2
View File
@@ -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
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
SMS *SMS
Authority *Authority
Menu *Menu
API *API
+27
View File
@@ -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)
}
+3 -1
View File
@@ -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)
+3
View File
@@ -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.
+1
View File
@@ -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)
}
}
}
+1
View File
@@ -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 {
+13
View File
@@ -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"`
}
+1 -1
View File
@@ -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)
+69
View File
@@ -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
}
+44
View File
@@ -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)
}
}
}