217 lines
7.0 KiB
Go
217 lines
7.0 KiB
Go
package sms
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
const (
|
||
ServiceAliyun = "1"
|
||
ServiceTencent = "2"
|
||
)
|
||
|
||
var (
|
||
ErrProviderNotFound = errors.New("未选择有效的短信发送供应商")
|
||
ErrInvalidRequest = errors.New("短信请求参数不正确")
|
||
ErrSceneNotConfigured = errors.New("短信场景未配置或已停用")
|
||
)
|
||
|
||
// Config contains the provider credentials and template configuration.
|
||
// serviceType: 1=Aliyun, 2=Tencent Cloud.
|
||
//
|
||
// Scene 为可选业务场景码(如 bind_phone)。当 Scene 非空时,短信服务会按该场景从数据库
|
||
// 配置表读取模板ID/签名等配置并回填到本次请求,调用方无需关心具体模板。
|
||
type Config struct {
|
||
Scene string `json:"scene" yaml:"scene"`
|
||
ServiceType string `json:"serviceType" yaml:"serviceType"`
|
||
RegionID string `json:"regionId" yaml:"regionId"`
|
||
AccessKeyID string `json:"accessKeyId" yaml:"accessKeyId"`
|
||
AccessKeySecret string `json:"accessKeySecret" yaml:"accessKeySecret"`
|
||
SDKAppID string `json:"sdkAppId" yaml:"sdkAppId"`
|
||
SignName string `json:"signName" yaml:"signName"`
|
||
TemplateID string `json:"templateId" yaml:"templateId"`
|
||
}
|
||
|
||
type Message struct {
|
||
PhoneNumber string `json:"phoneNumber"`
|
||
MsgContent string `json:"msgContent"`
|
||
}
|
||
|
||
type Result struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
Data map[string]any `json:"data,omitempty"`
|
||
}
|
||
|
||
type Provider interface {
|
||
Send(context.Context, Config, Message) (Result, error)
|
||
}
|
||
|
||
type Registry struct {
|
||
mu sync.RWMutex
|
||
providers map[string]Provider
|
||
defaultConfig Config
|
||
scenes SceneProvider
|
||
}
|
||
|
||
// NewRegistry 创建一个无默认配置的注册表。
|
||
func NewRegistry() *Registry {
|
||
return &Registry{providers: make(map[string]Provider)}
|
||
}
|
||
|
||
// NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段
|
||
// 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。
|
||
// scenes 为可选的场景配置提供方(按 scene 从数据库读取模板配置),可为 nil。
|
||
func NewRegistryWithDefault(dc Config, scenes SceneProvider) *Registry {
|
||
return &Registry{providers: make(map[string]Provider), defaultConfig: dc, scenes: scenes}
|
||
}
|
||
|
||
func (r *Registry) Register(serviceType string, provider Provider) {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
r.providers[serviceType] = provider
|
||
}
|
||
|
||
func (r *Registry) Send(ctx context.Context, config Config, message Message) (Result, error) {
|
||
// 按业务场景加载模板配置(来自数据库),实现“改配置不改代码、不重启服务”。
|
||
if scene := strings.TrimSpace(config.Scene); scene != "" {
|
||
sceneCfg, err := r.loadSceneConfig(ctx, scene)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSceneNotConfigured) {
|
||
return Result{Code: 400, Message: err.Error()}, err
|
||
}
|
||
return Result{Code: 500, Message: "查询短信场景配置失败: " + err.Error()}, err
|
||
}
|
||
config = mergeSceneConfig(sceneCfg, config)
|
||
message.MsgContent = renderSceneMsgContent(sceneCfg.MsgContentTemplate, message.MsgContent)
|
||
}
|
||
|
||
// 回退默认值:请求中未提供的字段使用短信服务自身的默认配置。
|
||
config = mergeConfig(r.defaultConfig, config)
|
||
if err := Validate(config, message); err != nil {
|
||
return Result{Code: 400, Message: err.Error()}, err
|
||
}
|
||
|
||
r.mu.RLock()
|
||
provider, ok := r.providers[config.ServiceType]
|
||
r.mu.RUnlock()
|
||
if !ok {
|
||
return Result{Code: 400, Message: ErrProviderNotFound.Error()}, ErrProviderNotFound
|
||
}
|
||
return provider.Send(ctx, config, message)
|
||
}
|
||
|
||
// loadSceneConfig 按场景码加载模板配置,并校验场景已启用。
|
||
func (r *Registry) loadSceneConfig(ctx context.Context, scene string) (SceneConfig, error) {
|
||
if r.scenes == nil {
|
||
return SceneConfig{}, fmt.Errorf("%w: %s", ErrSceneNotConfigured, scene)
|
||
}
|
||
cfg, err := r.scenes.Lookup(ctx, scene)
|
||
if err != nil {
|
||
return SceneConfig{}, err
|
||
}
|
||
if cfg == nil || cfg.Status != sceneStatusEnabled {
|
||
return SceneConfig{}, fmt.Errorf("%w: %s", ErrSceneNotConfigured, scene)
|
||
}
|
||
return *cfg, nil
|
||
}
|
||
|
||
// mergeSceneConfig 用场景配置填充请求中未显式提供的字段,调用方显式值优先。
|
||
func mergeSceneConfig(scene SceneConfig, config Config) Config {
|
||
if strings.TrimSpace(config.ServiceType) == "" {
|
||
config.ServiceType = scene.ServiceType
|
||
}
|
||
if strings.TrimSpace(config.SignName) == "" {
|
||
config.SignName = scene.SignName
|
||
}
|
||
if strings.TrimSpace(config.TemplateID) == "" {
|
||
config.TemplateID = scene.TemplateID
|
||
}
|
||
return config
|
||
}
|
||
|
||
// renderSceneMsgContent 按场景内容模板渲染短信参数;模板为空时直接使用调用方内容。
|
||
// 支持 {content}(原样)与 {contentJson}(JSON 转义)两种占位符。
|
||
func renderSceneMsgContent(template string, content string) string {
|
||
template = strings.TrimSpace(template)
|
||
if template == "" {
|
||
return content
|
||
}
|
||
contentJSON, _ := json.Marshal(content)
|
||
replacer := strings.NewReplacer(
|
||
"{content}", content,
|
||
"{contentJson}", string(contentJSON),
|
||
)
|
||
return replacer.Replace(template)
|
||
}
|
||
|
||
func Validate(config Config, message Message) error {
|
||
missing := make([]string, 0, 6)
|
||
if strings.TrimSpace(config.ServiceType) == "" {
|
||
missing = append(missing, "config.serviceType")
|
||
}
|
||
if strings.TrimSpace(config.AccessKeyID) == "" {
|
||
missing = append(missing, "config.accessKeyId")
|
||
}
|
||
if strings.TrimSpace(config.AccessKeySecret) == "" {
|
||
missing = append(missing, "config.accessKeySecret")
|
||
}
|
||
if strings.TrimSpace(config.SignName) == "" {
|
||
missing = append(missing, "config.signName")
|
||
}
|
||
if strings.TrimSpace(config.TemplateID) == "" {
|
||
missing = append(missing, "config.templateId")
|
||
}
|
||
if strings.TrimSpace(message.PhoneNumber) == "" {
|
||
missing = append(missing, "params.phoneNumber")
|
||
}
|
||
if config.ServiceType == ServiceTencent && strings.TrimSpace(config.SDKAppID) == "" {
|
||
missing = append(missing, "config.sdkAppId")
|
||
}
|
||
if len(missing) != 0 {
|
||
return fmt.Errorf("%w: 缺少 %s", ErrInvalidRequest, strings.Join(missing, ", "))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// mergeConfig 用 defaultConfig 填充 config 中的空字段,调用方显式值优先。
|
||
func mergeConfig(defaultConfig, config Config) Config {
|
||
if config.ServiceType == "" {
|
||
config.ServiceType = defaultConfig.ServiceType
|
||
}
|
||
if config.RegionID == "" {
|
||
config.RegionID = defaultConfig.RegionID
|
||
}
|
||
if config.AccessKeyID == "" {
|
||
config.AccessKeyID = defaultConfig.AccessKeyID
|
||
}
|
||
if config.AccessKeySecret == "" {
|
||
config.AccessKeySecret = defaultConfig.AccessKeySecret
|
||
}
|
||
if config.SDKAppID == "" {
|
||
config.SDKAppID = defaultConfig.SDKAppID
|
||
}
|
||
if config.SignName == "" {
|
||
config.SignName = defaultConfig.SignName
|
||
}
|
||
if config.TemplateID == "" {
|
||
config.TemplateID = defaultConfig.TemplateID
|
||
}
|
||
return config
|
||
}
|
||
|
||
func SplitAndTrim(value, separator string) []string {
|
||
parts := strings.Split(value, separator)
|
||
result := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
if item := strings.TrimSpace(part); item != "" {
|
||
result = append(result, item)
|
||
}
|
||
}
|
||
return result
|
||
}
|