250 lines
7.5 KiB
Go
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
|
|
}
|