Files

250 lines
7.5 KiB
Go

// Package smsgateway 封装对 iotlcm-sms 短信网关的调用。
//
// 设计要点:
// - 供应商凭证(AK/签名)由 iotlcm-sms 服务自身持有,本服务只传「场景码 + 手机号 + 内容」;
// - 模板ID/签名由 iotlcm-sms 按场景从数据库配置表(t_sms_template_config)读取,
// 因此运营侧修改模板无需改代码、无需重启;
// - 网关地址默认复用预警短信的 warning.sms 配置(同一个 iotlcm-sms 服务,避免重复配置);
// 如需独立配置,可新增 sms.gateway 配置段,本包会优先读取。
package smsgateway
import (
"context"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"time"
smspb "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/warningnotify/smspb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// 业务场景码常量。与数据库 t_sms_template_config.scene 对应。
const (
// SceneBindPhone 绑定手机号验证码(web端登录提示绑定手机号、首次登录引导绑定手机号复用)。
SceneBindPhone = "bind_phone"
// SceneLogin web端手机验证码登录。
SceneLogin = "login"
)
// Config 短信网关连接配置。
type Config struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
GRPCAddr string `json:"grpcAddr"`
Timeout string `json:"timeout"`
RouterID int64 `json:"routerId"`
}
// Request 描述一次短信发送请求。
type Request struct {
Scene string // 业务场景码,模板/签名由短信服务按场景读取
PhoneNumber string // 手机号,多个用英文逗号分隔
MsgContent string // 短信内容(用于渲染模板参数,如验证码)
TemplateID string // 可选:显式指定模板,覆盖场景配置
SignName string // 可选:显式指定签名,覆盖场景配置
}
// 发送给 iotlcm-sms 的报文结构,需与短信服务侧字段保持一致。
type sendConfig struct {
Scene string `json:"scene,omitempty"`
SignName string `json:"signName,omitempty"`
TemplateID string `json:"templateId,omitempty"`
}
type sendParams struct {
PhoneNumber string `json:"phoneNumber"`
MsgContent string `json:"msgContent"`
}
type httpResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
// Send 按场景发送短信。返回值 sent 表示是否真正向网关发起了发送:
// 当网关未启用或未配置地址时返回 (false, nil),调用方可据此走本地兜底(如打日志)。
func Send(ctx context.Context, req Request) (sent bool, err error) {
cfg, _ := loadConfig(ctx)
if !cfg.Enabled {
g.Log().Warningf(ctx, "SMS gateway disabled, skip send, scene:%s, phone:%s", req.Scene, req.PhoneNumber)
return false, nil
}
if strings.TrimSpace(req.PhoneNumber) == "" {
return false, gerror.New("发送短信失败:手机号为空")
}
params := sendParams{
PhoneNumber: strings.TrimSpace(req.PhoneNumber),
MsgContent: req.MsgContent,
}
providerConfig := sendConfig{
Scene: strings.TrimSpace(req.Scene),
SignName: strings.TrimSpace(req.SignName),
TemplateID: strings.TrimSpace(req.TemplateID),
}
if strings.TrimSpace(cfg.GRPCAddr) != "" {
return true, sendGRPC(ctx, cfg, providerConfig, params)
}
if strings.TrimSpace(cfg.URL) != "" {
return true, sendHTTP(ctx, cfg, providerConfig, params)
}
g.Log().Warningf(ctx, "SMS gateway address not configured, skip send, scene:%s", req.Scene)
return false, nil
}
// loadConfig 读取短信网关配置:优先 sms.gateway,其次复用 warning.sms(同一网关服务)。
func loadConfig(ctx context.Context) (Config, bool) {
var cfg Config
loaded := false
if v, err := g.Cfg().Get(ctx, "sms.gateway"); err == nil && !v.IsNil() {
_ = v.Scan(&cfg)
loaded = true
}
if strings.TrimSpace(cfg.URL) == "" && strings.TrimSpace(cfg.GRPCAddr) == "" {
if v, err := g.Cfg().Get(ctx, "warning.sms"); err == nil && !v.IsNil() {
var fallback Config
_ = v.Scan(&fallback)
cfg.URL = fallback.URL
cfg.GRPCAddr = fallback.GRPCAddr
if strings.TrimSpace(cfg.Timeout) == "" {
cfg.Timeout = fallback.Timeout
}
if cfg.RouterID == 0 {
cfg.RouterID = fallback.RouterID
}
if !cfg.Enabled {
cfg.Enabled = fallback.Enabled
}
loaded = true
}
}
if applyEnv(&cfg) {
loaded = true
}
cfg.URL = strings.TrimSpace(cfg.URL)
cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr)
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
return cfg, loaded
}
func applyEnv(cfg *Config) bool {
if cfg == nil {
return false
}
loaded := false
if value := strings.TrimSpace(os.Getenv("IOTLCM_SMS_GATEWAY_ENABLED")); value != "" {
if enabled, err := strconv.ParseBool(value); err == nil {
cfg.Enabled = enabled
loaded = true
}
}
if value := strings.TrimSpace(os.Getenv("IOTLCM_SMS_GATEWAY_ROUTER_ID")); value != "" {
if routerID, err := strconv.ParseInt(value, 10, 64); err == nil {
cfg.RouterID = routerID
loaded = true
}
}
loaded = applyStringEnv(&cfg.URL, "IOTLCM_SMS_GATEWAY_URL") || loaded
loaded = applyStringEnv(&cfg.GRPCAddr, "IOTLCM_SMS_GATEWAY_GRPC_ADDR") || loaded
loaded = applyStringEnv(&cfg.Timeout, "IOTLCM_SMS_GATEWAY_TIMEOUT") || loaded
return loaded
}
func applyStringEnv(target *string, key string) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return false
}
*target = value
return true
}
func sendGRPC(ctx context.Context, cfg Config, providerConfig sendConfig, params sendParams) error {
callCtx, cancel := context.WithTimeout(ctx, timeout(cfg))
defer cancel()
conn, err := grpc.DialContext(
callCtx,
cfg.GRPCAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
return gerror.Wrap(err, "连接短信网关失败")
}
defer conn.Close()
configBytes, err := json.Marshal(providerConfig)
if err != nil {
return err
}
paramsBytes, err := json.Marshal(params)
if err != nil {
return err
}
res, err := smspb.NewNoticeClient(conn).Send(callCtx, &smspb.SendReq{
Head: &smspb.HeadReq{
RouterId: cfg.RouterID,
TraceId: "sms-" + uuid.NewString(),
},
Config: configBytes,
Params: paramsBytes,
})
if err != nil {
return gerror.Wrap(err, "发送短信失败")
}
if res == nil || res.GetHead() == nil {
return gerror.New("发送短信失败:网关返回为空")
}
if res.GetHead().GetCode() != 0 {
return gerror.Newf("发送短信失败,code:%d, message:%s", res.GetHead().GetCode(), res.GetHead().GetMessage())
}
return nil
}
func sendHTTP(ctx context.Context, cfg Config, providerConfig sendConfig, params sendParams) error {
bodyBytes, err := json.Marshal(g.Map{
"config": providerConfig,
"params": params,
})
if err != nil {
return err
}
client := g.Client().Timeout(timeout(cfg)).SetHeader("Content-Type", "application/json")
resp, err := client.Post(ctx, cfg.URL, string(bodyBytes))
if err != nil {
return gerror.Wrap(err, "发送短信失败")
}
defer resp.Close()
if resp.StatusCode != http.StatusOK {
return gerror.Newf("发送短信失败,网关响应状态码:%d", resp.StatusCode)
}
var result httpResponse
if err = json.Unmarshal(resp.ReadAll(), &result); err != nil {
return gerror.Wrap(err, "解析短信网关响应失败")
}
if result.Code != 0 {
return gerror.Newf("发送短信失败,code:%d, message:%s", result.Code, result.Message)
}
return nil
}
func timeout(cfg Config) time.Duration {
if cfg.Timeout != "" {
if duration, err := time.ParseDuration(cfg.Timeout); err == nil && duration > 0 {
return duration
}
}
return 5 * time.Second
}