chore: 并入 iotlcm-admin-svr 历史到 admin-svr/(原提交 49818c6)
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"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,
|
||||
batchItemCount(batch),
|
||||
)
|
||||
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 := resolveWechatDeliveries(ctx, batch)
|
||||
if len(deliveries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sendErrs []string
|
||||
for _, delivery := range deliveries {
|
||||
if len(delivery.Group.UserIDs) == 0 || batchItemCount(delivery.Batch) == 0 {
|
||||
continue
|
||||
}
|
||||
userID := delivery.Group.UserIDs[0]
|
||||
if batch.Business == BusinessSimWarning {
|
||||
enabled, err := warningWechatEnabled(ctx, batch.TenantID, userID)
|
||||
if err != nil {
|
||||
sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query preference: %v", userID, err))
|
||||
continue
|
||||
}
|
||||
if !enabled {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:user preference disabled, tenantId:%s, userId:%d, count:%d", batch.TenantID, userID, batchItemCount(delivery.Batch))
|
||||
continue
|
||||
}
|
||||
}
|
||||
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, batchItemCount(delivery.Batch))
|
||||
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, batchItemCount(delivery.Batch), 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, batchItemCount(delivery.Batch))
|
||||
continue
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, binding := range bindings {
|
||||
if batch.Business == BusinessSimWarning {
|
||||
enabled, err := warningWechatEnabled(ctx, batch.TenantID, userID)
|
||||
if err != nil {
|
||||
sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query preference: %v", userID, err))
|
||||
continue
|
||||
}
|
||||
if !enabled {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:user preference disabled before send, tenantId:%s, userId:%d", batch.TenantID, userID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
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, batchItemCount(delivery.Batch), 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, batchItemCount(delivery.Batch))
|
||||
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 resolveWechatDeliveries(ctx context.Context, batch Batch) []platformDelivery {
|
||||
if batch.prepared != nil {
|
||||
return []platformDelivery{{Group: *batch.prepared, Batch: batch}}
|
||||
}
|
||||
deliveries := resolveTerminalPlatformDeliveries(ctx, batch)
|
||||
appendEnterpriseWechatDeliveries(ctx, batch, &deliveries)
|
||||
if len(deliveries) == 0 {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no eligible recipients, tenantId:%s, count:%d", batch.TenantID, batchItemCount(batch))
|
||||
}
|
||||
return deliveries
|
||||
}
|
||||
|
||||
func appendEnterpriseWechatDeliveries(ctx context.Context, batch Batch, deliveries *[]platformDelivery) {
|
||||
if batch.Business != BusinessSimWarning {
|
||||
return
|
||||
}
|
||||
itemsByUser := make(map[int64][]Event)
|
||||
seenByUser := make(map[int64]map[int]struct{})
|
||||
for index, item := range batch.Items {
|
||||
if item.ItemType != 1 {
|
||||
continue
|
||||
}
|
||||
createdBy := item.CreatedBy
|
||||
if createdBy <= 0 {
|
||||
createdBy = batch.CreatedBy
|
||||
}
|
||||
if createdBy <= 0 || item.ItemID <= 0 {
|
||||
continue
|
||||
}
|
||||
userID, ok, err := queryEnterpriseWarningUser(ctx, batch.TenantID, createdBy, item.ItemID)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "WarningNotify query enterprise user failed, tenantId:%s, userId:%d, cardId:%d, err:%+v", batch.TenantID, createdBy, item.ItemID, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
appendTerminalEvent(itemsByUser, seenByUser, userID, index, item)
|
||||
}
|
||||
}
|
||||
userIDs := make([]int64, 0, len(itemsByUser))
|
||||
for userID := range itemsByUser {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
for _, userID := range userIDs {
|
||||
items := itemsByUser[userID]
|
||||
enterpriseBatch := batch
|
||||
enterpriseBatch.Items = append([]Event(nil), items...)
|
||||
enterpriseBatch.WarnStage = highestStage(enterpriseBatch.Items)
|
||||
*deliveries = append(*deliveries, platformDelivery{
|
||||
Group: platformRecipientGroup{SendUserType: sendUserTypeAgent, UserIDs: []int64{userID}},
|
||||
Batch: enterpriseBatch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func enterpriseWarningRecipientEligible(userType string, userDeptID, cardDeptID int64, ancestors string) bool {
|
||||
if userType != consts.SysUserTypeSys || userDeptID <= 0 || cardDeptID <= 0 {
|
||||
return false
|
||||
}
|
||||
if userDeptID == cardDeptID {
|
||||
return true
|
||||
}
|
||||
target := strconv.FormatInt(userDeptID, 10)
|
||||
for _, ancestor := range strings.Split(ancestors, ",") {
|
||||
if strings.TrimSpace(ancestor) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func queryEnterpriseWarningUser(ctx context.Context, tenantID string, userID, cardID int64) (int64, bool, error) {
|
||||
if strings.TrimSpace(tenantID) == "" || userID <= 0 || cardID <= 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
var result struct {
|
||||
UserID int64 `orm:"user_id"`
|
||||
UserType string `orm:"user_type"`
|
||||
DeptID int64 `orm:"dept_id"`
|
||||
CardDeptID int64 `orm:"card_dept_id"`
|
||||
Ancestors string `orm:"ancestors"`
|
||||
}
|
||||
err := g.DB().Model(dao.TSimCardInfo.Table()+" c").Ctx(ctx).Safe().
|
||||
LeftJoin(dao.SysUser.Table()+" u", "u.user_id = "+strconv.FormatInt(userID, 10)).
|
||||
InnerJoin(dao.SysDept.Table()+" d", "d.dept_id = c.dept_id AND d.tenant_id = c.tenant_id").
|
||||
InnerJoin(dao.SysDept.Table()+" ud", "ud.dept_id = u.dept_id AND ud.tenant_id = u.tenant_id").
|
||||
Fields("u.user_id, u.user_type, u.dept_id, c.dept_id AS card_dept_id, d.ancestors").
|
||||
Where("c.tenant_id", tenantID).
|
||||
Where("c.id", cardID).
|
||||
Where("u.tenant_id", tenantID).
|
||||
Where("u.user_type", consts.SysUserTypeSys).
|
||||
Where("u.status", consts.SysDeptStatusNormal).
|
||||
Where("u.deleted_at IS NULL").
|
||||
Where("d.deleted_at IS NULL").
|
||||
Where("ud.deleted_at IS NULL").
|
||||
Scan(&result)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return result.UserID, result.UserID > 0 && enterpriseWarningRecipientEligible(result.UserType, result.DeptID, result.CardDeptID, result.Ancestors), 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 !batch.LatestTime.IsZero() {
|
||||
latest.WarnTime = batch.LatestTime
|
||||
}
|
||||
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)
|
||||
if !batch.LatestTime.IsZero() {
|
||||
latest.WarnTime = batch.LatestTime
|
||||
}
|
||||
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 batchItemCount(batch) == 0 {
|
||||
return "--"
|
||||
}
|
||||
first := displayObjectCode(batch.Items[0])
|
||||
if batchItemCount(batch) == 1 {
|
||||
return first
|
||||
}
|
||||
return fmt.Sprintf("%s等%d张卡", first, batchItemCount(batch))
|
||||
}
|
||||
|
||||
func wechatDeviceCharacterText(batch Batch) string {
|
||||
if batchItemCount(batch) == 0 {
|
||||
return "NA"
|
||||
}
|
||||
first := displayObjectCode(batch.Items[0])
|
||||
if batchItemCount(batch) == 1 {
|
||||
return first
|
||||
}
|
||||
return fmt.Sprintf("TOTAL-%d", batchItemCount(batch))
|
||||
}
|
||||
|
||||
func wechatTypeText(batch Batch) string {
|
||||
title := warningTitle(batch)
|
||||
if title == "" {
|
||||
title = "设备预警"
|
||||
}
|
||||
if batchItemCount(batch) > 1 {
|
||||
return fmt.Sprintf("%s,共%d张卡", title, batchItemCount(batch))
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func wechatReasonText(batch Batch) string {
|
||||
if batchItemCount(batch) == 1 {
|
||||
return emailWarningReason(batch.Items[0])
|
||||
}
|
||||
reason := fmt.Sprintf("%s\uff0c\u5171%d\u5f20\u5361", levelText(batchHighestLevel(batch)), batchItemCount(batch))
|
||||
if batchHasAffectOperation(batch) {
|
||||
reason += ",涉及停机或断网"
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
func wechatValueText(batch Batch) string {
|
||||
if batchItemCount(batch) > 1 {
|
||||
return fmt.Sprintf("TOTAL-%d", batchItemCount(batch))
|
||||
}
|
||||
if batchItemCount(batch) == 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])
|
||||
}
|
||||
Reference in New Issue
Block a user