chore: 并入 iotlcm-sms 历史到 sms/(原提交 1d38bc4)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms"
|
||||
)
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func New() *Provider { return &Provider{} }
|
||||
|
||||
func (p *Provider) Send(_ context.Context, config sms.Config, message sms.Message) (sms.Result, error) {
|
||||
regionID := config.RegionID
|
||||
if regionID == "" {
|
||||
regionID = "cn-hangzhou"
|
||||
}
|
||||
client, err := dysmsapi.NewClientWithAccessKey(regionID, config.AccessKeyID, config.AccessKeySecret)
|
||||
if err != nil {
|
||||
return failed(err)
|
||||
}
|
||||
|
||||
request := dysmsapi.CreateSendSmsRequest()
|
||||
request.Scheme = "https"
|
||||
request.PhoneNumbers = strings.Join(sms.SplitAndTrim(message.PhoneNumber, ","), ",")
|
||||
request.SignName = config.SignName
|
||||
request.TemplateCode = config.TemplateID
|
||||
request.TemplateParam = message.MsgContent
|
||||
|
||||
response, err := client.SendSms(request)
|
||||
if err != nil {
|
||||
return failed(err)
|
||||
}
|
||||
data := map[string]any{
|
||||
"provider": "aliyun",
|
||||
"requestId": response.RequestId,
|
||||
"bizId": response.BizId,
|
||||
"providerCode": response.Code,
|
||||
}
|
||||
if response.Code != "OK" {
|
||||
err = fmt.Errorf("阿里云短信发送失败: %s", response.Message)
|
||||
return sms.Result{Code: 502, Message: err.Error(), Data: data}, err
|
||||
}
|
||||
return sms.Result{Code: 0, Message: "发送成功", Data: data}, nil
|
||||
}
|
||||
|
||||
func failed(err error) (sms.Result, error) {
|
||||
var sdkDetail map[string]any
|
||||
_ = json.Unmarshal([]byte(err.Error()), &sdkDetail)
|
||||
return sms.Result{Code: 502, Message: err.Error(), Data: sdkDetail}, err
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// 默认配置文件名。容器内固定挂载到 /app/config/config.yaml。
|
||||
const defaultConfigFileName = "config.yaml"
|
||||
|
||||
// ConfigSource 描述默认配置的实际来源,供启动日志排查。
|
||||
type ConfigSource struct {
|
||||
File string // 实际加载的配置文件路径,空字符串表示未加载到文件
|
||||
Env []string // 生效的环境变量名(覆盖了文件值或单独提供)
|
||||
}
|
||||
|
||||
// Describe 返回可读的来源说明。
|
||||
func (s ConfigSource) Describe() string {
|
||||
file := s.File
|
||||
if file == "" {
|
||||
file = "(无配置文件)"
|
||||
}
|
||||
env := "(无)"
|
||||
if len(s.Env) > 0 {
|
||||
env = strings.Join(s.Env, ",")
|
||||
}
|
||||
return fmt.Sprintf("file=%s env=%s", file, env)
|
||||
}
|
||||
|
||||
// configFileCandidates 返回配置文件候选路径。
|
||||
// SMS_CONFIG_FILE 显式指定时只使用该路径;否则依次尝试工作目录、config/ 子目录、容器固定挂载点。
|
||||
func configFileCandidates() (paths []string, explicit bool) {
|
||||
if p := strings.TrimSpace(os.Getenv("SMS_CONFIG_FILE")); p != "" {
|
||||
return []string{p}, true
|
||||
}
|
||||
return []string{
|
||||
defaultConfigFileName,
|
||||
filepath.Join("config", defaultConfigFileName),
|
||||
filepath.Join("/app/config", defaultConfigFileName),
|
||||
}, false
|
||||
}
|
||||
|
||||
// LoadDefaultConfigFromFile 读取默认配置文件。
|
||||
// 返回的第二个值为实际命中的路径;未命中时路径为空。
|
||||
// 显式指定 SMS_CONFIG_FILE 但读取或解析失败时返回错误,避免静默使用空凭证。
|
||||
func LoadDefaultConfigFromFile() (Config, string, error) {
|
||||
data, path, err := readDefaultConfigFile()
|
||||
if err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
if path == "" {
|
||||
return Config{}, "", nil
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, "", fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
return cfg, path, nil
|
||||
}
|
||||
|
||||
// readDefaultConfigFile 按候选路径查找并读取配置文件,返回文件内容与命中路径。
|
||||
// 未命中任何候选文件时返回空内容与空路径(不视为错误);
|
||||
// 显式指定 SMS_CONFIG_FILE 时任何失败都返回错误,避免静默回退。
|
||||
func readDefaultConfigFile() ([]byte, string, error) {
|
||||
paths, explicit := configFileCandidates()
|
||||
for _, path := range paths {
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
if explicit {
|
||||
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Docker 在挂载源不存在时会自动创建同名目录;必须显式报错,
|
||||
// 否则会静默回退到空凭证,表现为短信发送全部失败。
|
||||
if info.IsDir() {
|
||||
return nil, "", fmt.Errorf(
|
||||
"配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+
|
||||
"请在服务器创建该文件后重新挂载", path)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if explicit {
|
||||
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return data, path, nil
|
||||
}
|
||||
if explicit {
|
||||
return nil, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0])
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
// envOverrides 定义环境变量到配置字段的映射,环境变量优先于配置文件。
|
||||
var envOverrides = []struct {
|
||||
name string
|
||||
apply func(*Config, string)
|
||||
}{
|
||||
{"SMS_SERVICE_TYPE", func(c *Config, v string) { c.ServiceType = v }},
|
||||
{"SMS_REGION_ID", func(c *Config, v string) { c.RegionID = v }},
|
||||
{"SMS_ACCESS_KEY_ID", func(c *Config, v string) { c.AccessKeyID = v }},
|
||||
{"SMS_ACCESS_KEY_SECRET", func(c *Config, v string) { c.AccessKeySecret = v }},
|
||||
{"SMS_SDK_APP_ID", func(c *Config, v string) { c.SDKAppID = v }},
|
||||
{"SMS_SIGN_NAME", func(c *Config, v string) { c.SignName = v }},
|
||||
{"SMS_TEMPLATE_ID", func(c *Config, v string) { c.TemplateID = v }},
|
||||
}
|
||||
|
||||
// applyEnvOverrides 用环境变量覆盖配置文件中的值,返回实际生效的变量名。
|
||||
func applyEnvOverrides(cfg *Config) []string {
|
||||
applied := make([]string, 0, len(envOverrides))
|
||||
for _, item := range envOverrides {
|
||||
if v := strings.TrimSpace(os.Getenv(item.name)); v != "" {
|
||||
item.apply(cfg, v)
|
||||
applied = append(applied, item.name)
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// LoadDefaultConfig 加载默认供应商配置:配置文件提供默认值,环境变量覆盖。
|
||||
// 短信服务自身持有全局凭证与签名,调用方请求中未显式提供的字段回退到这里的值。
|
||||
func LoadDefaultConfig() (Config, ConfigSource, error) {
|
||||
cfg, path, err := LoadDefaultConfigFromFile()
|
||||
if err != nil {
|
||||
return Config{}, ConfigSource{}, err
|
||||
}
|
||||
envApplied := applyEnvOverrides(&cfg)
|
||||
if strings.TrimSpace(cfg.RegionID) == "" {
|
||||
cfg.RegionID = "cn-hangzhou"
|
||||
}
|
||||
return cfg, ConfigSource{File: path, Env: envApplied}, nil
|
||||
}
|
||||
|
||||
// SceneDbConfig 场景模板配置库(表 t_sms_template_config)连接参数。
|
||||
// DSN 为空表示未启用场景库能力:Send 收到带 scene 的请求时返回 ErrSceneNotConfigured。
|
||||
type SceneDbConfig struct {
|
||||
DSN string `yaml:"sceneDbDsn"`
|
||||
CacheTTL string `yaml:"sceneDbCacheTtl"`
|
||||
}
|
||||
|
||||
// DefaultSceneDbCacheTTL 场景配置缓存默认时长。
|
||||
const DefaultSceneDbCacheTTL = 60 * time.Second
|
||||
|
||||
// TTL 返回解析后的缓存时长;非法或非正值回退默认值。
|
||||
func (c SceneDbConfig) TTL() time.Duration {
|
||||
if v, err := time.ParseDuration(strings.TrimSpace(c.CacheTTL)); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return DefaultSceneDbCacheTTL
|
||||
}
|
||||
|
||||
// LoadSceneDbConfig 从默认配置文件读取场景库参数(与凭证同一份 config.yaml),环境变量可覆盖。
|
||||
// 未命中配置文件不报错(场景库能力可选);解析失败或显式指定的文件不存在才返回错误。
|
||||
func LoadSceneDbConfig() (SceneDbConfig, ConfigSource, error) {
|
||||
data, path, err := readDefaultConfigFile()
|
||||
if err != nil {
|
||||
return SceneDbConfig{}, ConfigSource{}, err
|
||||
}
|
||||
sc := SceneDbConfig{}
|
||||
if path != "" {
|
||||
if err := yaml.Unmarshal(data, &sc); err != nil {
|
||||
return SceneDbConfig{}, ConfigSource{}, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
|
||||
}
|
||||
}
|
||||
envs := make([]string, 0, 2)
|
||||
if v := strings.TrimSpace(os.Getenv("SMS_DB_DSN")); v != "" {
|
||||
sc.DSN = v
|
||||
envs = append(envs, "SMS_DB_DSN")
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("SMS_DB_CONFIG_CACHE_TTL")); v != "" {
|
||||
sc.CacheTTL = v
|
||||
envs = append(envs, "SMS_DB_CONFIG_CACHE_TTL")
|
||||
}
|
||||
return sc, ConfigSource{File: path, Env: envs}, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefaultConfig_FileThenEnvOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
content := "serviceType: \"1\"\nregionId: cn-qingdao\naccessKeyId: file-ak\naccessKeySecret: file-sk\nsignName: 示例企业\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("SMS_CONFIG_FILE", path)
|
||||
t.Setenv("SMS_ACCESS_KEY_SECRET", "env-sk")
|
||||
t.Setenv("SMS_ACCESS_KEY_ID", "")
|
||||
t.Setenv("SMS_TEMPLATE_ID", "")
|
||||
|
||||
cfg, source, err := LoadDefaultConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDefaultConfig: %v", err)
|
||||
}
|
||||
if source.File != path {
|
||||
t.Errorf("source.File = %q, want %q", source.File, path)
|
||||
}
|
||||
if len(source.Env) != 1 || source.Env[0] != "SMS_ACCESS_KEY_SECRET" {
|
||||
t.Errorf("source.Env = %v, want [SMS_ACCESS_KEY_SECRET]", source.Env)
|
||||
}
|
||||
if cfg.AccessKeyID != "file-ak" {
|
||||
t.Errorf("AccessKeyID = %q, want file-ak (文件值应保留)", cfg.AccessKeyID)
|
||||
}
|
||||
if cfg.AccessKeySecret != "env-sk" {
|
||||
t.Errorf("AccessKeySecret = %q, want env-sk (环境变量应覆盖文件值)", cfg.AccessKeySecret)
|
||||
}
|
||||
if cfg.RegionID != "cn-qingdao" {
|
||||
t.Errorf("RegionID = %q, want cn-qingdao", cfg.RegionID)
|
||||
}
|
||||
if cfg.SignName != "示例企业" {
|
||||
t.Errorf("SignName = %q, want 示例企业", cfg.SignName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultConfig_NoFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("SMS_CONFIG_FILE", filepath.Join(dir, "missing.yaml"))
|
||||
if _, _, err := LoadDefaultConfig(); err == nil {
|
||||
t.Fatal("显式指定配置文件但缺失时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultConfig_RegionDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("signName: 示例企业\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SMS_CONFIG_FILE", path)
|
||||
t.Setenv("SMS_REGION_ID", "")
|
||||
|
||||
cfg, _, err := LoadDefaultConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.RegionID != "cn-hangzhou" {
|
||||
t.Errorf("RegionID = %q, want cn-hangzhou", cfg.RegionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfig_CallerWinsThenDefault(t *testing.T) {
|
||||
def := Config{ServiceType: "1", RegionID: "cn-hangzhou", AccessKeyID: "ak", SignName: "示例企业"}
|
||||
got := mergeConfig(def, Config{TemplateID: "SMS_298990049"})
|
||||
if got.TemplateID != "SMS_298990049" {
|
||||
t.Errorf("TemplateID = %q, want SMS_298990049", got.TemplateID)
|
||||
}
|
||||
if got.AccessKeyID != "ak" || got.SignName != "示例企业" {
|
||||
t.Errorf("凭证/签名未从默认配置回退: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultConfig_RejectsDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 模拟 Docker 在挂载源缺失时自动创建的目录
|
||||
bad := filepath.Join(dir, "config.yaml")
|
||||
if err := os.Mkdir(bad, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SMS_CONFIG_FILE", bad)
|
||||
if _, _, err := LoadDefaultConfig(); err == nil {
|
||||
t.Fatal("配置文件是目录时应返回错误,避免静默使用空凭证")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 场景配置启用状态。
|
||||
const sceneStatusEnabled = 1
|
||||
|
||||
// SceneConfig 是「业务场景 -> 短信模板」的配置,来源于数据库表 t_sms_template_config。
|
||||
// 运营侧修改该表后,短信服务在缓存过期后即按新配置发送,无需改代码或重启。
|
||||
type SceneConfig struct {
|
||||
Scene string
|
||||
ServiceType string
|
||||
SignName string
|
||||
TemplateID string
|
||||
MsgContentTemplate string
|
||||
Status int
|
||||
}
|
||||
|
||||
// SceneProvider 提供按场景码查询模板配置的能力。
|
||||
type SceneProvider interface {
|
||||
Lookup(ctx context.Context, scene string) (*SceneConfig, error)
|
||||
}
|
||||
|
||||
const sceneQuery = "SELECT `scene`, `service_type`, `sign_name`, `template_id`, `msg_content_template`, `status` " +
|
||||
"FROM `t_sms_template_config` WHERE `scene` = ? LIMIT 1"
|
||||
|
||||
type sceneCacheEntry struct {
|
||||
config *SceneConfig
|
||||
expired time.Time
|
||||
}
|
||||
|
||||
// SceneStore 从数据库读取场景模板配置,并做短 TTL 内存缓存:
|
||||
// 改库后最多 ttl 时间即生效,同时避免每次发送都查库。
|
||||
type SceneStore struct {
|
||||
db *sql.DB
|
||||
ttl time.Duration
|
||||
mu sync.RWMutex
|
||||
cache map[string]sceneCacheEntry
|
||||
}
|
||||
|
||||
// NewSceneStore 创建一个基于数据库的场景配置存储。ttl<=0 时使用默认 60s。
|
||||
func NewSceneStore(db *sql.DB, ttl time.Duration) *SceneStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 60 * time.Second
|
||||
}
|
||||
return &SceneStore{
|
||||
db: db,
|
||||
ttl: ttl,
|
||||
cache: make(map[string]sceneCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭底层数据库连接。
|
||||
func (s *SceneStore) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Lookup 返回指定场景的模板配置;场景未配置时返回 (nil, nil)。
|
||||
// 命中与未命中都会缓存 ttl,避免对不存在的场景反复查库。
|
||||
func (s *SceneStore) Lookup(ctx context.Context, scene string) (*SceneConfig, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
scene = strings.TrimSpace(scene)
|
||||
if scene == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if cfg, ok := s.cacheGet(scene); ok {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
var item SceneConfig
|
||||
row := s.db.QueryRowContext(ctx, sceneQuery, scene)
|
||||
err := row.Scan(
|
||||
&item.Scene,
|
||||
&item.ServiceType,
|
||||
&item.SignName,
|
||||
&item.TemplateID,
|
||||
&item.MsgContentTemplate,
|
||||
&item.Status,
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
result := item
|
||||
s.cacheSet(scene, &result)
|
||||
return &result, nil
|
||||
case err == sql.ErrNoRows:
|
||||
s.cacheSet(scene, nil)
|
||||
return nil, nil
|
||||
default:
|
||||
// 查询失败不缓存,下次请求重试。
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SceneStore) cacheGet(scene string) (*SceneConfig, bool) {
|
||||
s.mu.RLock()
|
||||
entry, ok := s.cache[scene]
|
||||
s.mu.RUnlock()
|
||||
if !ok || time.Now().After(entry.expired) {
|
||||
return nil, false
|
||||
}
|
||||
return entry.config, true
|
||||
}
|
||||
|
||||
func (s *SceneStore) cacheSet(scene string, cfg *SceneConfig) {
|
||||
s.mu.Lock()
|
||||
s.cache[scene] = sceneCacheEntry{config: cfg, expired: time.Now().Add(s.ttl)}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubProvider struct{ called bool }
|
||||
|
||||
func (p *stubProvider) Send(context.Context, Config, Message) (Result, error) {
|
||||
p.called = true
|
||||
return Result{Code: 0, Message: "ok"}, nil
|
||||
}
|
||||
|
||||
func TestRegistrySend(t *testing.T) {
|
||||
provider := &stubProvider{}
|
||||
registry := NewRegistry()
|
||||
registry.Register(ServiceAliyun, provider)
|
||||
config := Config{
|
||||
ServiceType: ServiceAliyun, AccessKeyID: "id", AccessKeySecret: "secret",
|
||||
SignName: "sign", TemplateID: "template",
|
||||
}
|
||||
result, err := registry.Send(context.Background(), config, Message{PhoneNumber: "13800008000"})
|
||||
if err != nil || result.Code != 0 || !provider.called {
|
||||
t.Fatalf("result=%+v err=%v called=%v", result, err, provider.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryValidatesBeforeProvider(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
_, err := registry.Send(context.Background(), Config{ServiceType: ServiceTencent}, Message{})
|
||||
if !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("err = %v, want ErrInvalidRequest", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAndTrim(t *testing.T) {
|
||||
got := SplitAndTrim(" 13800008000, ,13900009000 ", ",")
|
||||
if len(got) != 2 || got[0] != "13800008000" || got[1] != "13900009000" {
|
||||
t.Fatalf("unexpected split result: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package tencent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
tencentsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20190711"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-sms/internal/sms"
|
||||
)
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func New() *Provider { return &Provider{} }
|
||||
|
||||
func (p *Provider) Send(_ context.Context, config sms.Config, message sms.Message) (sms.Result, error) {
|
||||
credential := common.NewCredential(config.AccessKeyID, config.AccessKeySecret)
|
||||
clientProfile := profile.NewClientProfile()
|
||||
clientProfile.HttpProfile.Endpoint = "sms.tencentcloudapi.com"
|
||||
client, err := tencentsdk.NewClient(credential, "ap-beijing", clientProfile)
|
||||
if err != nil {
|
||||
return failed(err)
|
||||
}
|
||||
|
||||
request := tencentsdk.NewSendSmsRequest()
|
||||
request.SmsSdkAppid = common.StringPtr(config.SDKAppID)
|
||||
request.Sign = common.StringPtr(config.SignName)
|
||||
request.TemplateID = common.StringPtr(config.TemplateID)
|
||||
request.TemplateParamSet = common.StringPtrs(sms.SplitAndTrim(message.MsgContent, "|"))
|
||||
request.PhoneNumberSet = common.StringPtrs(sms.SplitAndTrim(message.PhoneNumber, ","))
|
||||
|
||||
response, err := client.SendSms(request)
|
||||
if err != nil {
|
||||
return failed(err)
|
||||
}
|
||||
if response.Response == nil || len(response.Response.SendStatusSet) == 0 {
|
||||
err = fmt.Errorf("腾讯云未返回短信发送状态")
|
||||
return failed(err)
|
||||
}
|
||||
|
||||
status := response.Response.SendStatusSet[0]
|
||||
data := map[string]any{
|
||||
"provider": "tencent",
|
||||
"requestId": stringValue(response.Response.RequestId),
|
||||
"serialNo": stringValue(status.SerialNo),
|
||||
"phoneNumber": stringValue(status.PhoneNumber),
|
||||
"providerCode": stringValue(status.Code),
|
||||
}
|
||||
if stringValue(status.Code) != "Ok" {
|
||||
err = fmt.Errorf("腾讯云短信发送失败: %s", stringValue(status.Message))
|
||||
return sms.Result{Code: 502, Message: err.Error(), Data: data}, err
|
||||
}
|
||||
return sms.Result{Code: 0, Message: "发送成功", Data: data}, nil
|
||||
}
|
||||
|
||||
func failed(err error) (sms.Result, error) {
|
||||
return sms.Result{Code: 502, Message: err.Error()}, err
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
Reference in New Issue
Block a user