chore: 初始开源发布

iotlcm-sms:独立短信 HTTP/gRPC 服务,支持阿里云与腾讯云。

- Go module 迁移至 git.5iot.com/iotlcm-public/iotlcm-sms
- 补 Apache-2.0 LICENSE,重写 README(特性/配置/接口/部署/目录/测试)
- 配置改为 config.example.yaml + SMS_* 环境变量注入,仓库不含真实凭证
- 清理内部标识(私有镜像仓库 / 公司名 / 客户签名 / 真实模板ID / 真实手机号)
- 统一 LF 行尾并加 .gitattributes
This commit is contained in:
MiFaZhan
2026-10-22 14:21:47 +08:00
commit 4fb1016977
31 changed files with 2351 additions and 0 deletions
+56
View File
@@ -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
}
+124
View File
@@ -0,0 +1,124 @@
package sms
import (
"fmt"
"os"
"path/filepath"
"strings"
"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) {
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)
}
continue
}
// Docker 在挂载源不存在时会自动创建同名目录;必须显式报错,
// 否则会静默回退到空凭证,表现为短信发送全部失败。
if info.IsDir() {
return Config{}, "", fmt.Errorf(
"配置文件 %s 是目录而非文件:宿主凭证文件可能不存在,Docker 自动创建了目录。"+
"请在服务器创建该文件后重新挂载", path)
}
data, err := os.ReadFile(path)
if err != nil {
if explicit {
return Config{}, "", 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
}
if explicit {
return Config{}, "", fmt.Errorf("指定的配置文件不存在: %s", paths[0])
}
return Config{}, "", 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
}
+94
View File
@@ -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("配置文件是目录时应返回错误,避免静默使用空凭证")
}
}
+151
View File
@@ -0,0 +1,151 @@
package sms
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
const (
ServiceAliyun = "1"
ServiceTencent = "2"
)
var (
ErrProviderNotFound = errors.New("未选择有效的短信发送供应商")
ErrInvalidRequest = errors.New("短信请求参数不正确")
)
// Config contains the provider credentials and template configuration.
// serviceType: 1=Aliyun, 2=Tencent Cloud.
type Config struct {
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
}
// NewRegistry 创建一个无默认配置的注册表。
func NewRegistry() *Registry {
return &Registry{providers: make(map[string]Provider)}
}
// NewRegistryWithDefault 创建一个带默认配置的注册表。调用方请求中未显式提供的字段
// 将回退到 defaultConfig(例如供应商凭证与签名由短信服务自身环境变量注入)。
func NewRegistryWithDefault(dc Config) *Registry {
return &Registry{providers: make(map[string]Provider), defaultConfig: dc}
}
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) {
// 回退默认值:请求中未提供的字段使用短信服务自身的默认配置。
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)
}
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
}
+43
View File
@@ -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: "138****8000"})
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(" 138****8000, ,138****8000 ", ",")
if len(got) != 2 || got[0] != "138****8000" || got[1] != "138****8000" {
t.Fatalf("unexpected split result: %#v", got)
}
}
+67
View File
@@ -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
}