// 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 }