sync: 同步短信服务 v1.0.4 并脱敏

This commit is contained in:
MiFaZhan
2026-10-29 14:17:14 +08:00
parent 6c26f7a0e1
commit e6997f51ed
22 changed files with 450 additions and 168 deletions
+68 -11
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
@@ -48,41 +49,54 @@ func configFileCandidates() (paths []string, explicit bool) {
// 返回的第二个值为实际命中的路径;未命中时路径为空。
// 显式指定 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 Config{}, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr)
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, statErr)
}
continue
}
// Docker 在挂载源不存在时会自动创建同名目录;必须显式报错,
// 否则会静默回退到空凭证,表现为短信发送全部失败。
if info.IsDir() {
return Config{}, "", fmt.Errorf(
return nil, "", fmt.Errorf(
"配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+
"请在服务器创建该文件后重新挂载", path)
}
data, err := os.ReadFile(path)
if err != nil {
if explicit {
return Config{}, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
return nil, "", fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
}
continue
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, "", fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
}
return cfg, path, nil
return data, path, nil
}
if explicit {
return Config{}, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0])
return nil, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0])
}
return Config{}, "", nil
return nil, "", nil
}
// envOverrides 定义环境变量到配置字段的映射,环境变量优先于配置文件。
var envOverrides = []struct {
name string
@@ -122,3 +136,46 @@ func LoadDefaultConfig() (Config, ConfigSource, error) {
}
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
}
+6 -6
View File
@@ -9,7 +9,7 @@ import (
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"
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)
}
@@ -38,8 +38,8 @@ func TestLoadDefaultConfig_FileThenEnvOverride(t *testing.T) {
if cfg.RegionID != "cn-qingdao" {
t.Errorf("RegionID = %q, want cn-qingdao", cfg.RegionID)
}
if cfg.SignName != "示例签名" {
t.Errorf("SignName = %q, want 示例签名", cfg.SignName)
if cfg.SignName != "示例企业" {
t.Errorf("SignName = %q, want 示例企业", cfg.SignName)
}
}
@@ -54,7 +54,7 @@ func TestLoadDefaultConfig_NoFile(t *testing.T) {
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 {
if err := os.WriteFile(path, []byte("signName: 示例企业\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("SMS_CONFIG_FILE", path)
@@ -70,12 +70,12 @@ func TestLoadDefaultConfig_RegionDefault(t *testing.T) {
}
func TestMergeConfig_CallerWinsThenDefault(t *testing.T) {
def := Config{ServiceType: "1", RegionID: "cn-hangzhou", AccessKeyID: "ak", SignName: "示例签名"}
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 != "示例签名" {
if got.AccessKeyID != "ak" || got.SignName != "示例企业" {
t.Errorf("凭证/签名未从默认配置回退: %+v", got)
}
}
+119
View File
@@ -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()
}
+69 -4
View File
@@ -2,6 +2,7 @@ package sms
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -14,13 +15,18 @@ const (
)
var (
ErrProviderNotFound = errors.New("未选择有效的短信发送供应商")
ErrInvalidRequest = errors.New("短信请求参数不正确")
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"`
@@ -49,6 +55,7 @@ type Registry struct {
mu sync.RWMutex
providers map[string]Provider
defaultConfig Config
scenes SceneProvider
}
// NewRegistry 创建一个无默认配置的注册表。
@@ -58,8 +65,9 @@ func NewRegistry() *Registry {
// NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段
// 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。
func NewRegistryWithDefault(dc Config) *Registry {
return &Registry{providers: make(map[string]Provider), defaultConfig: dc}
// 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) {
@@ -69,6 +77,19 @@ func (r *Registry) Register(serviceType string, provider 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 {
@@ -84,6 +105,50 @@ func (r *Registry) Send(ctx context.Context, config Config, message Message) (Re
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) == "" {
+3 -3
View File
@@ -21,7 +21,7 @@ func TestRegistrySend(t *testing.T) {
ServiceType: ServiceAliyun, AccessKeyID: "id", AccessKeySecret: "secret",
SignName: "sign", TemplateID: "template",
}
result, err := registry.Send(context.Background(), config, Message{PhoneNumber: "138****8000"})
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)
}
@@ -36,8 +36,8 @@ func TestRegistryValidatesBeforeProvider(t *testing.T) {
}
func TestSplitAndTrim(t *testing.T) {
got := SplitAndTrim(" 138****8000, ,138****8000 ", ",")
if len(got) != 2 || got[0] != "138****8000" || got[1] != "138****8000" {
got := SplitAndTrim(" 13800008000, ,13900009000 ", ",")
if len(got) != 2 || got[0] != "13800008000" || got[1] != "13900009000" {
t.Fatalf("unexpected split result: %#v", got)
}
}