package warningnotify 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" ) type warningSMSConfig struct { Enabled bool `json:"enabled"` URL string `json:"url"` GRPCAddr string `json:"grpcAddr"` Timeout string `json:"timeout"` RouterID int64 `json:"routerId"` ServiceType string `json:"serviceType"` RegionID string `json:"regionId"` AccessKeyID string `json:"accessKeyId"` AccessKeySecret string `json:"accessKeySecret"` SDKAppID string `json:"sdkAppId"` SignName string `json:"signName"` TemplateID string `json:"templateId"` MsgContentTpl string `json:"msgContentTemplate"` } type smsSendConfig struct { ServiceType string `json:"serviceType,omitempty"` RegionID string `json:"regionId,omitempty"` AccessKeyID string `json:"accessKeyId,omitempty"` AccessKeySecret string `json:"accessKeySecret,omitempty"` SDKAppID string `json:"sdkAppId,omitempty"` SignName string `json:"signName,omitempty"` TemplateID string `json:"templateId,omitempty"` } type smsSendParams struct { PhoneNumber string `json:"phoneNumber"` MsgContent string `json:"msgContent"` } type smsHTTPResponse struct { Code int `json:"code"` Message string `json:"message"` } func sendWarningSMS(ctx context.Context, batch Batch) error { cfg, ok := loadWarningSMSConfig(ctx) recipients := normalizeSMSRecipients(batch.Recipients) if len(recipients) == 0 { g.Log().Warningf( ctx, "WarningNotify SMS skipped, no valid recipients, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d", batch.Business, batch.TenantID, batch.DeptID, batch.Category, batch.WarnStage, len(batch.Items), ) return nil } if batch.SMSContent == "" { applyContent(&batch) } if !ok || !cfg.Enabled || !smsConfigReady(cfg) { g.Log().Infof( ctx, "WarningNotify SMS payload prepared, send skipped, reason:%s, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, content:%s", smsConfigSkipReason(ok, cfg), batch.Business, batch.TenantID, batch.DeptID, batch.Category, batch.WarnStage, strings.Join(recipients, ","), len(batch.Items), batch.SMSContent, ) return nil } params, err := buildSMSRequest(batch, recipients, cfg) if err != nil { return err } if strings.TrimSpace(cfg.GRPCAddr) != "" { if err = sendWarningSMSGRPC(ctx, cfg, params); err != nil { return err } g.Log().Infof( ctx, "WarningNotify SMS sent by grpc, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d", batch.Business, batch.TenantID, batch.DeptID, batch.Category, batch.WarnStage, strings.Join(recipients, ","), len(batch.Items), ) return nil } if err = sendWarningSMSHTTP(ctx, cfg, params); err != nil { return err } g.Log().Infof( ctx, "WarningNotify SMS sent by http, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d", batch.Business, batch.TenantID, batch.DeptID, batch.Category, batch.WarnStage, strings.Join(recipients, ","), len(batch.Items), ) return nil } func loadWarningSMSConfig(ctx context.Context) (warningSMSConfig, bool) { var cfg warningSMSConfig loaded := false v, err := g.Cfg().Get(ctx, "warning.sms") if err == nil && !v.IsNil() { _ = v.Scan(&cfg) loaded = true } if applyWarningSMSEnv(&cfg) { loaded = true } cfg.URL = strings.TrimSpace(cfg.URL) cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr) cfg.ServiceType = strings.TrimSpace(cfg.ServiceType) cfg.RegionID = strings.TrimSpace(cfg.RegionID) cfg.AccessKeyID = strings.TrimSpace(cfg.AccessKeyID) cfg.AccessKeySecret = strings.TrimSpace(cfg.AccessKeySecret) cfg.SDKAppID = strings.TrimSpace(cfg.SDKAppID) cfg.SignName = strings.TrimSpace(cfg.SignName) cfg.TemplateID = strings.TrimSpace(cfg.TemplateID) cfg.MsgContentTpl = strings.TrimSpace(cfg.MsgContentTpl) return cfg, loaded } func smsConfigReady(cfg warningSMSConfig) bool { return len(missingSMSConfigFields(cfg)) == 0 } func smsConfigSkipReason(loaded bool, cfg warningSMSConfig) string { if !loaded { return "warning.sms config not found" } if !cfg.Enabled { return "warning.sms disabled" } missing := missingSMSConfigFields(cfg) if len(missing) > 0 { return "warning.sms gateway config incomplete, missing: " + strings.Join(missing, ",") } return "unknown" } func missingSMSConfigFields(cfg warningSMSConfig) []string { missing := make([]string, 0, 1) if strings.TrimSpace(cfg.GRPCAddr) == "" && strings.TrimSpace(cfg.URL) == "" { missing = append(missing, "grpcAddr/url") } return missing } func applyWarningSMSEnv(cfg *warningSMSConfig) bool { if cfg == nil { return false } loaded := false if value := strings.TrimSpace(os.Getenv("IOTLCM_WARNING_SMS_ENABLED")); value != "" { if enabled, err := strconv.ParseBool(value); err == nil { cfg.Enabled = enabled loaded = true } } if value := strings.TrimSpace(os.Getenv("IOTLCM_WARNING_SMS_ROUTER_ID")); value != "" { if routerID, err := strconv.ParseInt(value, 10, 64); err == nil { cfg.RouterID = routerID loaded = true } } loaded = applySMSStringEnv(&cfg.URL, "IOTLCM_WARNING_SMS_URL") || loaded loaded = applySMSStringEnv(&cfg.GRPCAddr, "IOTLCM_WARNING_SMS_GRPC_ADDR") || loaded loaded = applySMSStringEnv(&cfg.Timeout, "IOTLCM_WARNING_SMS_TIMEOUT") || loaded loaded = applySMSStringEnv(&cfg.ServiceType, "IOTLCM_WARNING_SMS_SERVICE_TYPE") || loaded loaded = applySMSStringEnv(&cfg.RegionID, "IOTLCM_WARNING_SMS_REGION_ID") || loaded loaded = applySMSStringEnv(&cfg.AccessKeyID, "IOTLCM_WARNING_SMS_ACCESS_KEY_ID") || loaded loaded = applySMSStringEnv(&cfg.AccessKeySecret, "IOTLCM_WARNING_SMS_ACCESS_KEY_SECRET") || loaded loaded = applySMSStringEnv(&cfg.SDKAppID, "IOTLCM_WARNING_SMS_SDK_APP_ID") || loaded loaded = applySMSStringEnv(&cfg.SignName, "IOTLCM_WARNING_SMS_SIGN_NAME") || loaded loaded = applySMSStringEnv(&cfg.TemplateID, "IOTLCM_WARNING_SMS_TEMPLATE_ID") || loaded loaded = applySMSStringEnv(&cfg.MsgContentTpl, "IOTLCM_WARNING_SMS_MSG_CONTENT_TEMPLATE") || loaded return loaded } func applySMSStringEnv(target *string, key string) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return false } *target = value return true } func buildSMSRequest(batch Batch, recipients []string, cfg warningSMSConfig) (smsSendParams, error) { content := strings.TrimSpace(batch.SMSContent) if strings.TrimSpace(content) == "" { return smsSendParams{}, gerror.New("warning SMS content is empty") } msgContent := renderSMSMsgContent(cfg.MsgContentTpl, content, batch) return smsSendParams{ PhoneNumber: strings.Join(recipients, ","), MsgContent: msgContent, }, nil } func renderSMSMsgContent(template string, content string, batch Batch) string { template = strings.TrimSpace(template) if template == "" { return content } contentJSON, _ := json.Marshal(content) replacer := strings.NewReplacer( "{content}", content, "{contentJson}", string(contentJSON), "{category}", batch.Category, "{stage}", strconv.Itoa(batch.WarnStage), "{count}", strconv.Itoa(len(batch.Items)), "{firstItem}", firstSMSItemCode(batch), ) return replacer.Replace(template) } func firstSMSItemCode(batch Batch) string { if len(batch.Items) == 0 { return "" } return strings.TrimSpace(batch.Items[0].ItemCode) } func buildSMSProviderConfig(cfg warningSMSConfig) smsSendConfig { return smsSendConfig{ ServiceType: strings.TrimSpace(cfg.ServiceType), RegionID: strings.TrimSpace(cfg.RegionID), AccessKeyID: strings.TrimSpace(cfg.AccessKeyID), AccessKeySecret: strings.TrimSpace(cfg.AccessKeySecret), SDKAppID: strings.TrimSpace(cfg.SDKAppID), SignName: strings.TrimSpace(cfg.SignName), TemplateID: strings.TrimSpace(cfg.TemplateID), } } func sendWarningSMSGRPC(ctx context.Context, cfg warningSMSConfig, params smsSendParams) error { timeout := warningSMSTimeout(cfg) callCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() conn, err := grpc.DialContext( callCtx, cfg.GRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), ) if err != nil { return gerror.Wrap(err, "connect warning SMS grpc failed") } defer conn.Close() paramsBytes, err := json.Marshal(params) if err != nil { return err } configBytes, err := json.Marshal(buildSMSProviderConfig(cfg)) if err != nil { return err } traceID := "warning-sms-" + uuid.NewString() res, err := smspb.NewNoticeClient(conn).Send(callCtx, &smspb.SendReq{ Head: &smspb.HeadReq{ RouterId: cfg.RouterID, TraceId: traceID, }, Config: configBytes, Params: paramsBytes, }) if err != nil { return gerror.Wrap(err, "send warning SMS grpc failed") } if res == nil || res.GetHead() == nil { return gerror.New("send warning SMS grpc failed, empty response") } if res.GetHead().GetCode() != 0 { return gerror.Newf("send warning SMS grpc failed, code:%d, message:%s", res.GetHead().GetCode(), res.GetHead().GetMessage()) } return nil } func sendWarningSMSHTTP(ctx context.Context, cfg warningSMSConfig, params smsSendParams) error { bodyBytes, err := json.Marshal(g.Map{ "config": buildSMSProviderConfig(cfg), "params": params, }) if err != nil { return err } timeout := warningSMSTimeout(cfg) client := g.Client().Timeout(timeout).SetHeader("Content-Type", "application/json") resp, err := client.Post(ctx, cfg.URL, string(bodyBytes)) if err != nil { return gerror.Wrap(err, "send warning SMS http failed") } defer resp.Close() if resp.StatusCode != http.StatusOK { return gerror.Newf("send warning SMS http failed, status code:%d", resp.StatusCode) } var result smsHTTPResponse if err = json.Unmarshal(resp.ReadAll(), &result); err != nil { return gerror.Wrap(err, "parse warning SMS http response failed") } if result.Code != 0 { return gerror.Newf("send warning SMS http failed, code:%d, message:%s", result.Code, result.Message) } return nil } func normalizeSMSRecipients(values []string) []string { seen := make(map[string]struct{}, len(values)) recipients := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} recipients = append(recipients, value) } return recipients } func warningSMSTimeout(cfg warningSMSConfig) time.Duration { if cfg.Timeout != "" { if duration, err := time.ParseDuration(cfg.Timeout); err == nil && duration > 0 { return duration } } return 5 * time.Second }