package warningnotify import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" "unicode/utf8" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/cache" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) const ( warningWechatAccessTokenURL = "https://api.weixin.qq.com/cgi-bin/token" warningWechatSendURL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send" warningWechatDefaultPage = "pages/message/index" warningWechatDefaultTimeout = 5 * time.Second warningWechatSentKeyPrefix = "iotlcm:warning:wechat:sent" warningWechatMinSentTTL = 24 * time.Hour ) var warningWechatTokenMu sync.Mutex type warningWechatConfig struct { Enabled bool `json:"enabled"` AppID string `json:"appId"` AppSecret string `json:"appSecret"` TemplateID string `json:"templateId"` Page string `json:"page"` Timeout string `json:"timeout"` } type warningWechatBinding struct { UserID int64 `orm:"user_id"` OpenID string `orm:"open_id"` } type warningWechatAccessTokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } type warningWechatSendResponse struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } type warningWechatTemplateValue struct { Value string `json:"value"` } type warningWechatPayload struct { ToUser string `json:"touser"` TemplateID string `json:"template_id"` Page string `json:"page,omitempty"` Data map[string]warningWechatTemplateValue `json:"data"` } func sendWarningWechat(ctx context.Context, batch Batch) error { cfg, ok := loadWarningWechatConfig(ctx) if !ok || !cfg.Enabled { g.Log().Infof( ctx, "WarningNotify wechat subscribe skipped, reason:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d", wechatConfigSkipReason(ok, cfg), batch.TenantID, batch.DeptID, batch.Category, batch.WarnStage, len(batch.Items), ) return nil } if strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.AppSecret) == "" { g.Log().Warningf(ctx, "WarningNotify wechat subscribe skipped, reason:app credentials incomplete, tenantId:%s", batch.TenantID) return nil } deliveries := resolveTerminalPlatformDeliveries(ctx, batch) if len(deliveries) == 0 { g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no terminal recipients, tenantId:%s, count:%d", batch.TenantID, len(batch.Items)) return nil } var sendErrs []string for _, delivery := range deliveries { if len(delivery.Group.UserIDs) == 0 || len(delivery.Batch.Items) == 0 { continue } userID := delivery.Group.UserIDs[0] bindings, err := queryWechatBindings(ctx, batch.TenantID, userID) if err != nil { sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query binding: %v", userID, err)) continue } if len(bindings) == 0 { g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no openid, tenantId:%s, userId:%d, count:%d", batch.TenantID, userID, len(delivery.Batch.Items)) continue } acquired, sentKey, err := acquireWechatSendDedupe(ctx, delivery.Batch, userID) if err != nil { g.Log().Warningf(ctx, "WarningNotify wechat subscribe dedupe check failed, tenantId:%s, userId:%d, category:%s, stage:%d, count:%d, err:%+v", batch.TenantID, userID, delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items), err) } else if !acquired { g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:duplicated, tenantId:%s, userId:%d, key:%s, category:%s, stage:%d, count:%d", batch.TenantID, userID, sentKey, delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items)) continue } successCount := 0 for _, binding := range bindings { templateID := wechatTemplateID(delivery.Batch.Items, cfg.TemplateID) if templateID == "" { sendErrs = append(sendErrs, fmt.Sprintf("userId=%d template id empty", userID)) continue } payload := buildWechatPayload(delivery.Batch, binding.OpenID, templateID, cfg.Page) if err := sendWechatSubscription(ctx, cfg, payload); err != nil { sendErrs = append(sendErrs, fmt.Sprintf("userId=%d openid=%s: %v", userID, maskOpenID(binding.OpenID), err)) g.Log().Errorf(ctx, "WarningNotify wechat subscribe failed, tenantId:%s, userId:%d, openid:%s, category:%s, stage:%d, count:%d, err:%+v", batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items), err) continue } g.Log().Infof(ctx, "WarningNotify wechat subscribe sent, tenantId:%s, userId:%d, openid:%s, category:%s, stage:%d, count:%d", batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items)) successCount++ } if acquired && successCount == 0 { releaseWechatSendDedupe(ctx, sentKey) } } if len(sendErrs) > 0 { return fmt.Errorf("wechat subscribe send completed with failures: %s", strings.Join(sendErrs, "; ")) } return nil } func loadWarningWechatConfig(ctx context.Context) (warningWechatConfig, bool) { var cfg warningWechatConfig loaded := false value, err := g.Cfg().Get(ctx, "warning.wechat") if err == nil && !value.IsNil() { _ = value.Scan(&cfg) loaded = true } if envValue := strings.TrimSpace(os.Getenv("IOTLCM_WARNING_WECHAT_ENABLED")); envValue != "" { if enabled, parseErr := strconv.ParseBool(envValue); parseErr == nil { cfg.Enabled = enabled loaded = true } } loaded = applyWechatEnv(&cfg.AppID, "IOTLCM_WARNING_WECHAT_APP_ID") || loaded loaded = applyWechatEnv(&cfg.AppSecret, "IOTLCM_WARNING_WECHAT_APP_SECRET") || loaded loaded = applyWechatEnv(&cfg.TemplateID, "IOTLCM_WARNING_WECHAT_TEMPLATE_ID") || loaded loaded = applyWechatEnv(&cfg.Page, "IOTLCM_WARNING_WECHAT_PAGE") || loaded loaded = applyWechatEnv(&cfg.Timeout, "IOTLCM_WARNING_WECHAT_TIMEOUT") || loaded cfg.AppID = strings.TrimSpace(cfg.AppID) cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) cfg.TemplateID = strings.TrimSpace(cfg.TemplateID) cfg.Page = strings.TrimSpace(cfg.Page) cfg.Timeout = strings.TrimSpace(cfg.Timeout) if cfg.TemplateID == "" { cfg.TemplateID = consts.WarningWechatTemplateIDDefault } if cfg.Page == "" { cfg.Page = warningWechatDefaultPage } return cfg, loaded } func applyWechatEnv(target *string, key string) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return false } *target = value return true } func wechatConfigSkipReason(loaded bool, cfg warningWechatConfig) string { if !loaded { return "warning.wechat config not found" } if !cfg.Enabled { return "warning.wechat disabled" } if cfg.AppID == "" || cfg.AppSecret == "" { return "warning.wechat app credentials incomplete" } return "unknown" } func queryWechatBindings(ctx context.Context, tenantID string, userID int64) ([]warningWechatBinding, error) { if strings.TrimSpace(tenantID) == "" || userID <= 0 { return nil, nil } columns := dao.SysSocial.Columns() rows := make([]warningWechatBinding, 0) err := dao.SysSocial.Ctx(ctx). Fields(columns.UserId, columns.OpenId). Where(columns.TenantId, strings.TrimSpace(tenantID)). Where(columns.UserId, userID). Where(columns.Source, consts.SourceWechatMp). Where("open_id <> ?", ""). OrderAsc(columns.Id). Scan(&rows) if err != nil { return nil, err } seen := make(map[string]struct{}, len(rows)) result := make([]warningWechatBinding, 0, len(rows)) for _, row := range rows { row.OpenID = strings.TrimSpace(row.OpenID) if row.OpenID == "" { continue } if _, exists := seen[row.OpenID]; exists { continue } seen[row.OpenID] = struct{}{} result = append(result, row) } return result, nil } func acquireWechatSendDedupe(ctx context.Context, batch Batch, userID int64) (bool, string, error) { key := wechatSendDedupeKey(batch, userID) if key == "" { return false, "", fmt.Errorf("wechat send dedupe key is empty") } ttl := wechatSendDedupeTTL(batch) ok, err := cache.Instance().SetIfNotExist(ctx, key, "1", ttl) return ok, key, err } func releaseWechatSendDedupe(ctx context.Context, key string) { key = strings.TrimSpace(key) if key == "" { return } _, _ = cache.Instance().Remove(ctx, key) } func wechatSendDedupeKey(batch Batch, userID int64) string { tenantID := strings.TrimSpace(batch.TenantID) if tenantID == "" || userID <= 0 { return "" } business := strings.TrimSpace(batch.Business) if business == "" { business = BusinessSimWarning } return fmt.Sprintf( "%s:%s:%d:%s:%s:%s", warningWechatSentKeyPrefix, tenantID, userID, wechatSafeKeyPart(business), wechatDedupeCategoryKey(batch), wechatDedupePeriodKey(batch), ) } func wechatSendDedupeTTL(batch Batch) time.Duration { now := wechatBatchTime(batch) if wechatBatchHasTrafficKind(batch) { nextMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0) if ttl := nextMonth.Sub(now); ttl > 0 { return ttl } return warningWechatMinSentTTL } if wechatBatchHasExpireKind(batch) { return 36 * time.Hour } return warningWechatMinSentTTL } func wechatDedupePeriodKey(batch Batch) string { now := wechatBatchTime(batch) if wechatBatchHasTrafficKind(batch) { return "m" + now.Format("200601") } if wechatBatchHasExpireKind(batch) { return fmt.Sprintf("d%s:s%d", now.Format("20060102"), batch.WarnStage) } return "d" + now.Format("20060102") } func wechatDedupeCategoryKey(batch Batch) string { switch wechatPrimaryKind(batch) { case "traffic_mb", "traffic_percent": return "card_traffic" case "pool_traffic_percent": return "flow_pool" case "expire_day": return "expire_day" case "expire_month": return "expire_month" } category := strings.TrimSpace(batch.Category) if category == "" { category = "warning" } return "cat_" + sha256Hex(category) } func wechatPrimaryKind(batch Batch) string { for _, item := range batch.Items { kind := strings.TrimSpace(item.Kind) if kind != "" { return kind } } return "" } func wechatBatchHasTrafficKind(batch Batch) bool { for _, item := range batch.Items { switch strings.TrimSpace(item.Kind) { case "traffic_mb", "traffic_percent", "pool_traffic_percent": return true } } return false } func wechatBatchHasExpireKind(batch Batch) bool { for _, item := range batch.Items { switch strings.TrimSpace(item.Kind) { case "expire_day", "expire_month": return true } } return false } func wechatBatchTime(batch Batch) time.Time { latest := latestEvent(batch.Items) if latest.WarnTime.IsZero() { return time.Now() } return latest.WarnTime } func wechatSafeKeyPart(value string) string { value = strings.TrimSpace(value) if value == "" { return "na" } var builder strings.Builder for _, r := range value { if r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_' || r == '-' { builder.WriteRune(r) } } if builder.Len() == 0 { return sha256Hex(value) } return strings.ToLower(builder.String()) } func wechatTemplateID(items []Event, fallback string) string { for _, item := range items { if value := strings.TrimSpace(item.WxTemplateID); value != "" { return value } } return strings.TrimSpace(fallback) } func buildWechatPayload(batch Batch, openID string, templateID string, page string) warningWechatPayload { latest := latestEvent(batch.Items) return warningWechatPayload{ ToUser: strings.TrimSpace(openID), TemplateID: strings.TrimSpace(templateID), Page: strings.TrimSpace(page), Data: map[string]warningWechatTemplateValue{ "character_string6": {Value: wechatCharacterString(wechatDeviceCharacterText(batch), 32)}, "thing7": {Value: truncateWechatText(wechatTypeText(batch), 20)}, "thing9": {Value: truncateWechatText(wechatReasonText(batch), 20)}, "character_string32": {Value: wechatCharacterString(wechatValueText(batch), 32)}, "time8": {Value: wechatTimeText(latest)}, }, } } func latestEvent(items []Event) Event { var result Event for _, item := range items { if result.WarnTime.IsZero() || item.WarnTime.After(result.WarnTime) { result = item } } return result } func wechatDeviceText(batch Batch) string { if len(batch.Items) == 0 { return "--" } first := displayObjectCode(batch.Items[0]) if len(batch.Items) == 1 { return first } return fmt.Sprintf("%s等%d张卡", first, len(batch.Items)) } func wechatDeviceCharacterText(batch Batch) string { if len(batch.Items) == 0 { return "NA" } first := displayObjectCode(batch.Items[0]) if len(batch.Items) == 1 { return first } return fmt.Sprintf("%sx%d", first, len(batch.Items)) } func wechatTypeText(batch Batch) string { title := warningTitle(batch) if title == "" { title = "设备预警" } if len(batch.Items) > 1 { return fmt.Sprintf("%s,共%d张卡", title, len(batch.Items)) } return title } func wechatReasonText(batch Batch) string { if len(batch.Items) == 1 { return emailWarningReason(batch.Items[0]) } reason := fmt.Sprintf("共%d张卡触发预警,最高级别:%s", len(batch.Items), levelText(highestLevel(batch.Items))) if hasAffectOperation(batch.Items) { reason += ",涉及停机或断网" } return reason } func wechatValueText(batch Batch) string { if len(batch.Items) == 0 { return "NA" } item := batch.Items[0] unit := strings.TrimSpace(item.UnitText) switch unit { case "天": return fmt.Sprintf("%.0fd", item.Actual) case "月": return fmt.Sprintf("%.0fm", item.Actual) case "%": return fmt.Sprintf("%.2f%%", item.Actual) case "MB": if item.TotalFlowMB > 0 { return fmt.Sprintf("%.3f/%.3fMB", item.Actual, item.TotalFlowMB) } return fmt.Sprintf("%.3fMB", item.Actual) default: if unit == "" { return fmt.Sprintf("%.3f", item.Actual) } return fmt.Sprintf("%.3f%s", item.Actual, unit) } } func wechatTimeText(item Event) string { if item.WarnTime.IsZero() { return gtime.Now().Format("2006-01-02 15:04:05") } return item.WarnTime.Format("2006-01-02 15:04:05") } func sendWechatSubscription(ctx context.Context, cfg warningWechatConfig, payload warningWechatPayload) error { return sendWechatSubscriptionAttempt(ctx, cfg, payload, true) } type warningWechatAPIError struct { Code int Message string } func (err *warningWechatAPIError) Error() string { return fmt.Sprintf("wechat subscribe code %d: %s", err.Code, err.Message) } func warningWechatTokenInvalid(code int) bool { return code == 40001 || code == 40014 || code == 42001 } func sendWechatSubscriptionAttempt(ctx context.Context, cfg warningWechatConfig, payload warningWechatPayload, refreshToken bool) error { accessToken, err := warningWechatAccessToken(ctx, cfg) if err != nil { return err } query := url.Values{} query.Set("access_token", accessToken) body, err := json.Marshal(payload) if err != nil { return err } timeout := warningWechatTimeout(cfg) reqCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, warningWechatSendURL+"?"+query.Encode(), bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := (&http.Client{Timeout: timeout}).Do(req) if err != nil { return err } defer resp.Body.Close() var result warningWechatSendResponse if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { return err } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("wechat subscribe http status %d: %s", resp.StatusCode, result.ErrMsg) } if result.ErrCode != 0 { if refreshToken && warningWechatTokenInvalid(result.ErrCode) { cacheKey := "iotlcm:warning:wechat:access_token:" + sha256Hex(cfg.AppID) if _, err = g.Redis().Do(ctx, "DEL", cacheKey); err != nil { return fmt.Errorf("invalidate wechat access token: %w", err) } return sendWechatSubscriptionAttempt(ctx, cfg, payload, false) } return &warningWechatAPIError{Code: result.ErrCode, Message: result.ErrMsg} } return nil } func warningWechatAccessToken(ctx context.Context, cfg warningWechatConfig) (string, error) { cacheKey := "iotlcm:warning:wechat:access_token:" + sha256Hex(cfg.AppID) if value, err := g.Redis().Get(ctx, cacheKey); err == nil && strings.TrimSpace(value.String()) != "" { return strings.TrimSpace(value.String()), nil } warningWechatTokenMu.Lock() defer warningWechatTokenMu.Unlock() if value, err := g.Redis().Get(ctx, cacheKey); err == nil && strings.TrimSpace(value.String()) != "" { return strings.TrimSpace(value.String()), nil } query := url.Values{} query.Set("grant_type", "client_credential") query.Set("appid", cfg.AppID) query.Set("secret", cfg.AppSecret) timeout := warningWechatTimeout(cfg) reqCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, warningWechatAccessTokenURL+"?"+query.Encode(), nil) if err != nil { return "", err } resp, err := (&http.Client{Timeout: timeout}).Do(req) if err != nil { return "", err } defer resp.Body.Close() var result warningWechatAccessTokenResponse if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return "", fmt.Errorf("wechat access token http status %d: %s", resp.StatusCode, result.ErrMsg) } if result.ErrCode != 0 || strings.TrimSpace(result.AccessToken) == "" { return "", fmt.Errorf("wechat access token code %d: %s", result.ErrCode, result.ErrMsg) } ttl := result.ExpiresIn - 300 if ttl <= 0 { ttl = 300 } if _, err = g.Redis().Do(ctx, "SETEX", cacheKey, ttl, result.AccessToken); err != nil { g.Log().Warningf(ctx, "WarningNotify cache wechat access token failed, err:%+v", err) } return result.AccessToken, nil } func warningWechatTimeout(cfg warningWechatConfig) time.Duration { if value := strings.TrimSpace(cfg.Timeout); value != "" { if timeout, err := time.ParseDuration(value); err == nil && timeout > 0 { return timeout } } return warningWechatDefaultTimeout } func maskOpenID(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if utf8.RuneCountInString(value) <= 8 { return value } runes := []rune(value) return string(runes[:4]) + "..." + string(runes[len(runes)-4:]) } func sha256Hex(value string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(value))) return hex.EncodeToString(sum[:])[:16] } func truncateWechatText(value string, maxRunes int) string { value = strings.TrimSpace(value) if maxRunes <= 0 { return "" } runes := []rune(value) if len(runes) <= maxRunes { return value } return string(runes[:maxRunes-1]) + "…" } func wechatCharacterString(value string, maxRunes int) string { value = strings.TrimSpace(value) if value == "" || maxRunes <= 0 { return "NA" } var builder strings.Builder for _, r := range value { if r >= 33 && r <= 126 { builder.WriteRune(r) } } result := builder.String() if result == "" { return "NA" } runes := []rune(result) if len(runes) <= maxRunes { return result } return string(runes[:maxRunes]) }