chore: 初始开源发布

iotlcm-admin-svr:管理后台服务。

- Go module:iotlcm → git.5iot.com/iotlcm-public/iotlcm-admin-svr(729 文件)
- 凭据与地址脱敏:JWT signingKey(含代码里的 MustGet 默认值)、
  MySQL DSN、Redis 口令、告警邮件发件人邮箱全部改为占位;
  私网 IP → 文档地址段;库名 xiujie_admin → iotlcm
- 真实手机号/邮箱 → 合法 11 位编造号 / example.com
- 移除内部资产:CI 触发器、过时 DDL(sql/db_iotlcm.sql)、
  私有依赖 go get 行、明文 registry 登录口令
- 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名
- Dockerfile 改用公共基础镜像
- 拼错的 RADEME.md → README.md,README 重写
- 需要真实数据库的用例改为可用 IOTLCM_TEST_WITH_DB=1 显式启用
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes

数据库 schema 不随本仓发布(生产 DDL 已过时),README 已说明。
This commit is contained in:
MiFaZhan
2026-10-23 14:46:05 +08:00
commit 59eb34976a
1132 changed files with 138197 additions and 0 deletions
@@ -0,0 +1,256 @@
package warningnotify
import (
"sort"
"strings"
)
type groupKey struct {
Business string
TenantID string
DeptID int64
CreatedBy int64
Method string
Recipients string
Category string
}
func BuildBatches(events []Event) []Batch {
if len(events) == 0 {
return nil
}
grouped := make(map[groupKey]*Batch)
for _, event := range events {
event = normalizeEvent(event)
for _, method := range methodsForEvent(event) {
for _, recipients := range recipientGroupsForMethod(method, event) {
if methodRequiresRecipient(method) && len(recipients) == 0 {
continue
}
key := groupKeyForEvent(method, recipients, event)
batch := grouped[key]
if batch == nil {
batch = &Batch{
Business: event.Business,
Method: method,
TenantID: event.TenantID,
DeptID: event.DeptID,
DeptName: event.DeptName,
CreatedBy: event.CreatedBy,
Category: event.Category,
Recipients: recipients,
Items: make([]Event, 0),
}
grouped[key] = batch
} else if strings.TrimSpace(batch.DeptName) == "" {
batch.DeptName = event.DeptName
}
batch.Items = append(batch.Items, event)
}
}
}
keys := make([]groupKey, 0, len(grouped))
for key := range grouped {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
return compareGroupKey(keys[i], keys[j]) < 0
})
batches := make([]Batch, 0, len(keys))
for _, key := range keys {
batch := grouped[key]
sort.SliceStable(batch.Items, func(i, j int) bool {
if batch.Items[i].WarnStage != batch.Items[j].WarnStage {
return batch.Items[i].WarnStage > batch.Items[j].WarnStage
}
if batch.Items[i].ItemCode != batch.Items[j].ItemCode {
return batch.Items[i].ItemCode < batch.Items[j].ItemCode
}
return batch.Items[i].EventID < batch.Items[j].EventID
})
batch.WarnStage = highestStage(batch.Items)
applyContent(batch)
batches = append(batches, *batch)
}
return batches
}
func groupKeyForEvent(method string, recipients []string, event Event) groupKey {
key := groupKey{
Business: event.Business,
TenantID: event.TenantID,
DeptID: event.DeptID,
CreatedBy: event.CreatedBy,
Method: method,
Recipients: strings.Join(recipients, ","),
Category: event.Category,
}
if method == MethodSMS || method == MethodEmail {
key.DeptID = 0
key.CreatedBy = 0
}
if method == MethodWechat {
key.DeptID = 0
key.CreatedBy = 0
key.Recipients = ""
}
return key
}
func compareGroupKey(left, right groupKey) int {
leftParts := []string{left.TenantID, left.Business, left.Method, left.Recipients, left.Category}
rightParts := []string{right.TenantID, right.Business, right.Method, right.Recipients, right.Category}
for i := range leftParts {
if leftParts[i] < rightParts[i] {
return -1
}
if leftParts[i] > rightParts[i] {
return 1
}
}
if left.DeptID != right.DeptID {
if left.DeptID < right.DeptID {
return -1
}
return 1
}
if left.CreatedBy != right.CreatedBy {
if left.CreatedBy < right.CreatedBy {
return -1
}
return 1
}
return 0
}
func normalizeEvent(event Event) Event {
event.Business = strings.TrimSpace(event.Business)
if event.Business == "" {
event.Business = BusinessSimWarning
}
event.TenantID = strings.TrimSpace(event.TenantID)
event.DeptName = strings.TrimSpace(event.DeptName)
event.Category = strings.TrimSpace(event.Category)
if event.Category == "" {
event.Category = "预警"
}
event.Kind = strings.TrimSpace(event.Kind)
event.ItemCode = strings.TrimSpace(event.ItemCode)
event.MSISDN = strings.TrimSpace(event.MSISDN)
event.WarnMethods = strings.TrimSpace(event.WarnMethods)
event.WarnPhones = strings.TrimSpace(event.WarnPhones)
event.WarnEmails = strings.TrimSpace(event.WarnEmails)
event.UnitText = strings.TrimSpace(event.UnitText)
event.Remark = strings.TrimSpace(event.Remark)
event.OperationText = strings.TrimSpace(event.OperationText)
event.BatchNo = strings.TrimSpace(event.BatchNo)
return event
}
func methodsForEvent(event Event) []string {
values := splitList(event.WarnMethods)
seen := map[string]struct{}{MethodPlatform: {}}
result := []string{MethodPlatform}
for _, value := range values {
switch value {
case MethodSMS, MethodEmail, MethodPhone, MethodPlatform:
default:
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
if event.AppNotifyEnabled {
result = append(result, MethodWechat)
}
sort.SliceStable(result, func(i, j int) bool {
return methodOrder(result[i]) < methodOrder(result[j])
})
return result
}
func methodOrder(method string) int {
switch method {
case MethodPlatform:
return 1
case MethodEmail:
return 2
case MethodSMS:
return 3
case MethodPhone:
return 4
case MethodWechat:
return 5
default:
return 99
}
}
func recipientGroupsForMethod(method string, event Event) [][]string {
switch method {
case MethodSMS:
return singleRecipientGroups(splitList(event.WarnPhones))
case MethodEmail:
return singleRecipientGroups(splitList(event.WarnEmails))
default:
return [][]string{{}}
}
}
func methodRequiresRecipient(method string) bool {
return method == MethodSMS || method == MethodEmail
}
func singleRecipientGroups(recipients []string) [][]string {
groups := make([][]string, 0, len(recipients))
for _, recipient := range recipients {
recipient = strings.TrimSpace(recipient)
if recipient == "" {
continue
}
groups = append(groups, []string{recipient})
}
return groups
}
func splitList(value string) []string {
parts := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case ',', ';', ',', ';', '|', '\n', '\r', '\t', ' ':
return true
default:
return false
}
})
seen := make(map[string]struct{}, len(parts))
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, exists := seen[part]; exists {
continue
}
seen[part] = struct{}{}
result = append(result, part)
}
sort.Strings(result)
return result
}
func highestStage(items []Event) int {
stage := 0
for _, item := range items {
if item.WarnStage > stage {
stage = item.WarnStage
}
}
return stage
}
@@ -0,0 +1,429 @@
package warningnotify
import (
"fmt"
"html"
"strings"
"time"
)
const warningEmailHTMLSignature = `data-warning-email="iotlcm"`
func applyContent(batch *Batch) {
if batch == nil {
return
}
batch.SMSContent = buildSMSContent(*batch)
batch.EmailSubject = buildEmailSubject(*batch)
batch.EmailBody = buildEmailBody(*batch)
batch.PlatformTitle = buildPlatformTitle(*batch)
batch.PlatformContent = buildPlatformContent(*batch)
}
func buildSMSContent(batch Batch) string {
count := len(batch.Items)
if count == 0 {
return ""
}
summary := batchObjectSummary(batch)
title := warningTitle(batch)
action := ""
if hasAffectOperation(batch.Items) {
action = ",涉及停机或断网操作"
}
return fmt.Sprintf(
"【物联网】%s触发%s%s,请登录控制台查看。",
summary,
title,
action,
)
}
func buildEmailSubject(batch Batch) string {
count := len(batch.Items)
if count == 0 {
return "物联网预警提醒"
}
return fmt.Sprintf("%s(%d条)", warningTitle(batch), count)
}
func buildEmailBody(batch Batch) string {
count := len(batch.Items)
if count == 0 {
return ""
}
var builder strings.Builder
builder.WriteString(`<div ` + warningEmailHTMLSignature + ` style="font-family:Arial,'Microsoft YaHei',sans-serif;font-size:14px;line-height:1.6;color:#1f2937;">`)
fmt.Fprintf(&builder, `<p style="margin:0 0 12px;">%s,共 %d 条,最高级别:%s。</p>`, htmlEscape(warningTitle(batch)), count, htmlEscape(levelText(highestLevel(batch.Items))))
if hasAffectOperation(batch.Items) {
builder.WriteString(`<p style="margin:0 0 12px;color:#b42318;">本次预警包含停机或断网类操作,请重点确认。</p>`)
}
builder.WriteString(`<table style="width:100%;border-collapse:collapse;border:1px solid #d0d5dd;">`)
builder.WriteString(`<thead><tr style="background:#f2f4f7;">`)
builder.WriteString(emailHeaderCell(emailObjectHeader(batch)))
builder.WriteString(emailHeaderCell("预警类型"))
builder.WriteString(emailHeaderCell("预警原因"))
builder.WriteString(emailHeaderCell("预警时间"))
builder.WriteString(`</tr></thead><tbody>`)
for _, item := range batch.Items {
builder.WriteString(`<tr>`)
builder.WriteString(emailBodyCell(displayObjectCode(item)))
builder.WriteString(emailBodyCell(emailWarningType(item, batch)))
builder.WriteString(emailBodyCell(emailWarningReason(item)))
builder.WriteString(emailBodyCell(emailWarnTime(item)))
builder.WriteString(`</tr>`)
}
builder.WriteString(`</tbody></table></div>`)
return strings.TrimSpace(builder.String())
}
func buildPlatformTitle(batch Batch) string {
if len(batch.Items) == 0 {
return "预警提醒"
}
if title := expireDayPlatformTitle(batch); title != "" {
return title
}
return warningTitle(batch)
}
func buildPlatformContent(batch Batch) string {
count := len(batch.Items)
if count == 0 {
return ""
}
content := buildPlatformSummaryContent(batch)
if hasAffectOperation(batch.Items) {
content += "本次提醒包含停机或断网类操作,请及时确认。"
}
return content
}
func buildPlatformSummaryContent(batch Batch) string {
if isExpireDayBatch(batch) {
return fmt.Sprintf(
"%s有%d张卡临近到期,最高档位:%s,请登录控制台查看。",
firstObjectCode(batch),
len(batch.Items),
buildPlatformTitle(batch),
)
}
return fmt.Sprintf(
"%s触发%s,最高级别:%s,请登录控制台查看。",
batchObjectSummary(batch),
buildPlatformTitle(batch),
levelText(highestLevel(batch.Items)),
)
}
func warningTitle(batch Batch) string {
category := warningCategory(batch)
return categoryStageTitle(category, batch.WarnStage)
}
func warningCategory(batch Batch) string {
category := strings.TrimSpace(batch.Category)
if category == "" {
category = "预警"
}
return category
}
func categoryStageTitle(category string, stage int) string {
category = strings.TrimSpace(category)
if category == "" {
category = "预警"
}
if stage > 0 {
return category + stageText(stage)
}
if strings.HasSuffix(category, "提醒") {
return category
}
return category + "提醒"
}
func expireDayPlatformTitle(batch Batch) string {
if !isExpireDayBatch(batch) {
return ""
}
threshold := expireDayPlatformThreshold(batch)
if threshold <= 0 {
return "距离到期时间预警"
}
return fmt.Sprintf("距离到期时间小于%.0f天", threshold)
}
func isExpireDayBatch(batch Batch) bool {
category := strings.TrimSpace(batch.Category)
for _, item := range batch.Items {
if strings.TrimSpace(item.Kind) == "expire_day" {
return true
}
if strings.Contains(category, "到期") && strings.TrimSpace(item.UnitText) == "天" {
return true
}
}
return false
}
func expireDayPlatformThreshold(batch Batch) float64 {
for _, item := range batch.Items {
if item.WarnStage == batch.WarnStage && item.Threshold > 0 {
return item.Threshold
}
}
for _, item := range batch.Items {
if item.Threshold > 0 {
return item.Threshold
}
}
switch batch.WarnStage {
case 1:
return 30
case 2:
return 15
case 3:
return 7
default:
return 0
}
}
func itemDetailSummary(item Event) string {
return itemSummary(item)
}
func batchObjectSummary(batch Batch) string {
count := len(batch.Items)
if count == 0 {
return ""
}
code := firstObjectCode(batch)
if count == 1 {
return code
}
return fmt.Sprintf("%s等%d%s", code, count, objectCountUnit(batch.Items[0]))
}
func firstObjectCode(batch Batch) string {
if len(batch.Items) == 0 {
return "--"
}
return displayObjectCode(batch.Items[0])
}
func objectCountUnit(item Event) string {
switch item.ItemType {
case 2:
return "个流量池"
default:
return "张卡"
}
}
func metricSummary(item Event) string {
unit := strings.TrimSpace(item.UnitText)
switch unit {
case "天", "月":
parts := []string{
"剩余" + formatMetric(item.Actual, unit),
}
if item.Threshold > 0 {
parts = append(parts, "阈值"+formatMetric(item.Threshold, unit))
}
return strings.Join(parts, ",")
case "%":
parts := []string{
"阈值" + formatMetric(item.Threshold, unit),
"实际" + formatMetric(item.Actual, unit),
}
if shouldShowRemain(item) {
parts = append(parts, "剩余"+remainMetric(item))
}
if item.TotalFlowMB > 0 {
parts = append(parts, "总量"+formatMetric(item.TotalFlowMB, "MB"))
}
return strings.Join(parts, ",")
default:
parts := []string{
"阈值" + formatMetric(item.Threshold, item.UnitText),
"实际" + formatMetric(item.Actual, item.UnitText),
}
if shouldShowRemain(item) {
parts = append(parts, "剩余"+remainMetric(item))
}
if item.TotalFlowMB > 0 {
parts = append(parts, "总量"+formatMetric(item.TotalFlowMB, "MB"))
}
return strings.Join(parts, ",")
}
}
func itemSummary(item Event) string {
parts := make([]string, 0, 1)
parts = append(parts, fmt.Sprintf("%s:%s", itemLabel(item), displayObjectCode(item)))
return strings.Join(parts, ",")
}
func itemLabel(item Event) string {
switch item.ItemType {
case 2:
return "流量池编号"
default:
return "ICCID"
}
}
func displayObjectCode(item Event) string {
itemCode := strings.TrimSpace(item.ItemCode)
if itemCode == "" {
return "--"
}
return itemCode
}
func stageText(stage int) string {
if stage <= 0 {
return ""
}
return fmt.Sprintf("第%d次提醒", stage)
}
func levelText(level int) string {
switch level {
case 1:
return "提示"
case 2:
return "注意"
case 3:
return "警告"
case 4:
return "危险"
case 5:
return "紧急"
default:
return "提醒"
}
}
func highestLevel(items []Event) int {
level := 0
for _, item := range items {
if item.WarnLevel > level {
level = item.WarnLevel
}
}
return level
}
func hasAffectOperation(items []Event) bool {
for _, item := range items {
if item.AffectOperation {
return true
}
}
return false
}
func shouldShowRemain(item Event) bool {
if item.TotalFlowMB > 0 {
return true
}
unit := strings.TrimSpace(item.UnitText)
return unit == "MB" || unit == "%"
}
func remainMetric(item Event) string {
if !shouldShowRemain(item) {
return "--"
}
return formatMetric(item.RemainFlowMB, "MB")
}
func formatMetric(value float64, unit string) string {
unit = strings.TrimSpace(unit)
switch unit {
case "%":
return fmt.Sprintf("%.2f%%", value)
case "天", "月":
return fmt.Sprintf("%.0f%s", value, unit)
case "MB":
return fmt.Sprintf("%.3f MB", value)
default:
if unit == "" {
return fmt.Sprintf("%.3f", value)
}
return fmt.Sprintf("%.3f %s", value, unit)
}
}
func formatTime(value time.Time) string {
return value.Format("2006-01-02 15:04:05")
}
func emailObjectHeader(batch Batch) string {
if len(batch.Items) == 0 {
return "ICCID"
}
for _, item := range batch.Items {
if item.ItemType != 2 {
return "ICCID"
}
}
return "流量池编号"
}
func emailWarningType(item Event, batch Batch) string {
category := strings.TrimSpace(item.Category)
if category != "" {
return category
}
return warningCategory(batch)
}
func emailWarningReason(item Event) string {
parts := make([]string, 0, 3)
if item.WarnStage > 0 {
parts = append(parts, stageText(item.WarnStage))
}
if summary := metricSummary(item); summary != "" {
parts = append(parts, summary)
}
if item.OperationText != "" {
parts = append(parts, item.OperationText)
}
if len(parts) == 0 && item.Remark != "" {
parts = append(parts, item.Remark)
}
return strings.Join(parts, ",")
}
func emailWarnTime(item Event) string {
if item.WarnTime.IsZero() {
return "--"
}
return formatTime(item.WarnTime)
}
func emailHeaderCell(value string) string {
return `<th style="padding:8px 10px;border:1px solid #d0d5dd;text-align:left;font-weight:600;color:#344054;">` + htmlEscape(value) + `</th>`
}
func emailBodyCell(value string) string {
return `<td style="padding:8px 10px;border:1px solid #d0d5dd;vertical-align:top;color:#1f2937;">` + htmlEscape(value) + `</td>`
}
func htmlEscape(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "--"
}
return html.EscapeString(value)
}
@@ -0,0 +1,269 @@
package warningnotify
import (
"context"
"encoding/json"
"html"
"mime"
"net"
"net/mail"
"net/smtp"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
func sendWarningEmail(ctx context.Context, batch Batch) error {
recipients := normalizeEmailRecipients(batch.Recipients)
if len(recipients) == 0 {
g.Log().Warningf(
ctx,
"WarningNotify email 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
}
cfg, ok := loadWarningEmailConfig(ctx)
if !ok || strings.TrimSpace(cfg.Host) == "" && strings.TrimSpace(cfg.Url) == "" {
g.Log().Infof(
ctx,
"WarningNotify email payload prepared, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, subject:%s, body:%s",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
strings.Join(recipients, ","),
len(batch.Items),
batch.EmailSubject,
batch.EmailBody,
)
return nil
}
if strings.TrimSpace(cfg.Url) != "" {
if err := callWarningEmailHTTP(ctx, cfg.UserCodeHttpSenderConfig, batch, recipients); err != nil {
return gerror.Wrap(err, "send warning email failed")
}
g.Log().Infof(
ctx,
"WarningNotify email sent by http, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, subject:%s",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
strings.Join(recipients, ","),
len(batch.Items),
batch.EmailSubject,
)
return nil
}
if cfg.Port <= 0 {
cfg.Port = 25
}
if strings.TrimSpace(cfg.From) == "" {
cfg.From = cfg.Username
}
if strings.TrimSpace(cfg.From) == "" {
return gerror.New("warning email sender is empty")
}
addr := net.JoinHostPort(cfg.Host, gconv.String(cfg.Port))
auth := smtp.Auth(nil)
if strings.TrimSpace(cfg.Username) != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
msg := buildWarningEmailMessage(cfg, batch, recipients)
err := utility.SendSMTP(ctx, utility.SMTPOptions{
Address: addr,
Host: cfg.Host,
Auth: auth,
From: cfg.From,
To: recipients,
Data: msg,
SSL: cfg.SSL,
Timeout: utility.ExternalRequestTimeout(cfg.Timeout),
})
if err != nil {
return gerror.Wrap(err, "send warning email failed")
}
g.Log().Infof(
ctx,
"WarningNotify email sent, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, subject:%s",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
strings.Join(recipients, ","),
len(batch.Items),
batch.EmailSubject,
)
return nil
}
func loadWarningEmailConfig(ctx context.Context) (model.UserCodeEmailSenderConfig, bool) {
var cfg model.UserCodeEmailSenderConfig
for _, key := range []string{"warning.email", "register.email"} {
v, err := g.Cfg().Get(ctx, key)
if err != nil || v.IsNil() {
continue
}
_ = v.Scan(&cfg)
if strings.TrimSpace(cfg.Host) != "" || strings.TrimSpace(cfg.Url) != "" {
return cfg, true
}
}
return cfg, false
}
func normalizeEmailRecipients(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
}
address, err := mail.ParseAddress(value)
if err != nil || address == nil || strings.TrimSpace(address.Address) == "" {
continue
}
email := strings.ToLower(strings.TrimSpace(address.Address))
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
recipients = append(recipients, email)
}
return recipients
}
func buildWarningEmailMessage(cfg model.UserCodeEmailSenderConfig, batch Batch, recipients []string) []byte {
subject := strings.TrimSpace(batch.EmailSubject)
if subject == "" {
subject = buildEmailSubject(batch)
}
from := strings.TrimSpace(cfg.From)
if cfg.FromName != "" {
from = mime.QEncoding.Encode("UTF-8", cfg.FromName) + " <" + from + ">"
}
message := strings.Builder{}
message.WriteString("From: " + from + "\r\n")
message.WriteString("To: " + strings.Join(recipients, ", ") + "\r\n")
message.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n")
message.WriteString("MIME-Version: 1.0\r\n")
message.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
message.WriteString("\r\n")
message.WriteString(warningEmailHTMLBody(batch.EmailBody))
return []byte(message.String())
}
func warningEmailHTMLBody(body string) string {
body = strings.TrimSpace(body)
if body == "" {
body = defaultPlatformContent
}
if strings.Contains(body, warningEmailHTMLSignature) {
return warningEmailHTMLDocument(body)
}
escaped := html.EscapeString(body)
escaped = strings.ReplaceAll(escaped, "\r\n", "\n")
escaped = strings.ReplaceAll(escaped, "\n", "<br/>\n")
return warningEmailHTMLDocument(`<div style="font-family:Arial,'Microsoft YaHei',sans-serif;font-size:14px;line-height:1.8;color:#1f2937;">` +
escaped +
`</div>`)
}
func warningEmailHTMLDocument(body string) string {
return `<!doctype html><html><body>` + body + `</body></html>`
}
func callWarningEmailHTTP(ctx context.Context, cfg model.UserCodeHttpSenderConfig, batch Batch, recipients []string) error {
method := strings.ToUpper(strings.TrimSpace(cfg.Method))
if method == "" {
method = "POST"
}
successStatus := cfg.SuccessStatus
if successStatus <= 0 {
successStatus = 200
}
body := renderWarningEmailTemplate(cfg.BodyTemplate, batch, recipients)
if strings.TrimSpace(body) == "" {
bodyBytes, _ := json.Marshal(g.Map{
"subject": strings.TrimSpace(batch.EmailSubject),
"title": strings.TrimSpace(batch.EmailSubject),
"content": strings.TrimSpace(batch.EmailBody),
"body": strings.TrimSpace(batch.EmailBody),
"recipients": recipients,
"business": batch.Business,
"category": batch.Category,
"stage": batch.WarnStage,
})
body = string(bodyBytes)
}
headers := cfg.Headers
if headers == nil {
headers = map[string]string{}
}
if _, ok := headers["Content-Type"]; !ok {
headers["Content-Type"] = "application/json"
}
resp, err := g.Client().Timeout(utility.ExternalRequestTimeout(cfg.Timeout)).SetHeaderMap(headers).DoRequest(ctx, method, cfg.Url, body)
if err != nil {
return gerror.Wrap(err, "send warning email http request failed")
}
defer resp.Close()
if resp.StatusCode != successStatus {
return gerror.Newf("send warning email http request failed, status code: %d", resp.StatusCode)
}
return nil
}
func renderWarningEmailTemplate(template string, batch Batch, recipients []string) string {
template = strings.TrimSpace(template)
if template == "" {
return ""
}
replacer := strings.NewReplacer(
"{subject}", batch.EmailSubject,
"{{subject}}", batch.EmailSubject,
"{title}", batch.EmailSubject,
"{{title}}", batch.EmailSubject,
"{content}", batch.EmailBody,
"{{content}}", batch.EmailBody,
"{body}", batch.EmailBody,
"{{body}}", batch.EmailBody,
"{recipients}", strings.Join(recipients, ","),
"{{recipients}}", strings.Join(recipients, ","),
"{deptName}", "",
"{{deptName}}", "",
"{category}", batch.Category,
"{{category}}", batch.Category,
"{stage}", stageText(batch.WarnStage),
"{{stage}}", stageText(batch.WarnStage),
"{business}", batch.Business,
"{{business}}", batch.Business,
)
return replacer.Replace(template)
}
@@ -0,0 +1,558 @@
package warningnotify
import (
"context"
"sort"
"strings"
"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/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
)
const (
messageTypeSystem = 1
sendUserTypeAgent = 1
sendUserTypeTerminal = 2
simTerminalTable = "t_sim_terminal"
terminalTable = "t_terminal"
defaultPlatformTitle = "预警提醒"
defaultPlatformContent = "您有新的预警消息,请登录平台查看。"
)
type platformRecipientGroup struct {
SendUserType int
UserIDs []int64
}
type platformDelivery struct {
Group platformRecipientGroup
Batch Batch
}
type terminalUserCardRow struct {
UserID int64 `orm:"user_id"`
ICCID string `orm:"iccid"`
MSISDN string `orm:"msisdn"`
}
// MessageCenterSender writes platform warnings through the message center service.
// Email and SMS warnings are sent through their configured external services.
type MessageCenterSender struct {
Fallback Sender
}
func init() {
UseMessageCenterSender()
}
func UseMessageCenterSender() {
SetSender(MessageCenterSender{Fallback: LogSender{}})
}
func (s MessageCenterSender) Send(ctx context.Context, batch Batch) error {
if ctx == nil {
ctx = context.Background()
}
if len(batch.Items) == 0 {
return nil
}
switch batch.Method {
case MethodPlatform:
return s.sendPlatform(ctx, batch)
case MethodEmail:
return s.sendEmail(ctx, batch)
case MethodSMS:
return s.sendSMS(ctx, batch)
case MethodWechat:
return s.sendWechat(ctx, batch)
default:
return s.fallback().Send(ctx, batch)
}
}
func (s MessageCenterSender) sendPlatform(ctx context.Context, batch Batch) error {
if err := s.writePlatform(ctx, batch); err != nil {
g.Log().Errorf(
ctx,
"WarningNotify platform message write failed, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d, err:%+v",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
len(batch.Items),
err,
)
return nil
}
return nil
}
func (s MessageCenterSender) sendEmail(ctx context.Context, batch Batch) error {
if err := sendWarningEmail(ctx, batch); err != nil {
g.Log().Errorf(
ctx,
"WarningNotify email send failed, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, err:%+v",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
strings.Join(batch.Recipients, ","),
len(batch.Items),
err,
)
return nil
}
return nil
}
func (s MessageCenterSender) sendSMS(ctx context.Context, batch Batch) error {
if err := sendWarningSMS(ctx, batch); err != nil {
g.Log().Errorf(
ctx,
"WarningNotify SMS send failed, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, err:%+v",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
strings.Join(batch.Recipients, ","),
len(batch.Items),
err,
)
return nil
}
return nil
}
func (s MessageCenterSender) sendWechat(ctx context.Context, batch Batch) error {
if err := sendWarningWechat(ctx, batch); err != nil {
g.Log().Errorf(
ctx,
"WarningNotify wechat subscribe send failed, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d, err:%+v",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
len(batch.Items),
err,
)
return err
}
return nil
}
func (s MessageCenterSender) fallback() Sender {
if s.Fallback != nil {
return s.Fallback
}
return LogSender{}
}
func (s MessageCenterSender) writePlatform(ctx context.Context, batch Batch) error {
deliveries, err := resolvePlatformDeliveries(ctx, batch)
if err != nil {
return err
}
if len(deliveries) == 0 {
g.Log().Warningf(
ctx,
"WarningNotify platform message skipped, no 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
}
messageCtx := platformMessageContext(ctx, batch)
for _, delivery := range deliveries {
if len(delivery.Group.UserIDs) == 0 || len(delivery.Batch.Items) == 0 {
continue
}
messageBatch := delivery.Batch
applyContent(&messageBatch)
if err := service.CommonTMessageLog().Edit(messageCtx, platformMessageParam(messageBatch, delivery.Group)); err != nil {
return err
}
}
g.Log().Infof(
ctx,
"WarningNotify platform message submitted, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, groups:%d, count:%d",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
len(deliveries),
len(batch.Items),
)
return nil
}
func resolvePlatformDeliveries(ctx context.Context, batch Batch) ([]platformDelivery, error) {
deliveries := make([]platformDelivery, 0, 2)
agentUserIDs, err := queryAgentUserIDs(ctx, batch.TenantID, batch.DeptID, batch.CreatedBy)
if err != nil {
return nil, err
}
if len(agentUserIDs) > 0 {
deliveries = append(deliveries, platformDelivery{
Group: platformRecipientGroup{
SendUserType: sendUserTypeAgent,
UserIDs: agentUserIDs,
},
Batch: batch,
})
}
if hasCardWarningItems(batch.Items) {
deliveries = append(deliveries, resolveTerminalPlatformDeliveries(ctx, batch)...)
}
return deliveries, nil
}
func queryAgentUserIDs(ctx context.Context, tenantID string, deptID int64, createdBy int64) ([]int64, error) {
userIDs := make([]int64, 0)
seen := make(map[int64]struct{})
if strings.TrimSpace(tenantID) == "" {
return userIDs, nil
}
columns := dao.SysUser.Columns()
if deptID > 0 {
var users []entity.SysUser
if err := dao.SysUser.Ctx(ctx).
Fields(columns.UserId).
Where(columns.TenantId, tenantID).
Where(columns.DeptId, deptID).
Where(columns.UserType, consts.SysUserTypeSys).
Where(columns.Status, consts.SysDeptStatusNormal).
Scan(&users); err != nil {
return nil, err
}
for _, user := range users {
userIDs = appendUniqueInt64(userIDs, seen, user.UserId)
}
}
if createdBy > 0 {
var creator *entity.SysUser
if err := dao.SysUser.Ctx(ctx).
Fields(columns.UserId).
Where(columns.TenantId, tenantID).
Where(columns.UserId, createdBy).
Where(columns.UserType, consts.SysUserTypeSys).
Where(columns.Status, consts.SysDeptStatusNormal).
Scan(&creator); err != nil {
return nil, err
}
if creator != nil {
userIDs = appendUniqueInt64(userIDs, seen, creator.UserId)
}
}
return userIDs, nil
}
func queryCreatedTerminalUserIDs(ctx context.Context, tenantID string, createdBy int64) ([]int64, error) {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" || createdBy <= 0 {
return nil, nil
}
columns := dao.SysUser.Columns()
var creator *entity.SysUser
if err := dao.SysUser.Ctx(ctx).
Fields(columns.UserId).
Where(columns.TenantId, tenantID).
Where(columns.UserId, createdBy).
Where(columns.UserType, consts.SysUserTypeAPP).
Where(columns.Status, consts.SysDeptStatusNormal).
Scan(&creator); err != nil {
return nil, err
}
if creator == nil || creator.UserId <= 0 {
return nil, nil
}
return []int64{creator.UserId}, nil
}
func resolveTerminalPlatformDeliveries(ctx context.Context, batch Batch) []platformDelivery {
itemsByUser := make(map[int64][]Event)
seenByUser := make(map[int64]map[int]struct{})
appendCreatedTerminalEvents(ctx, batch, itemsByUser, seenByUser)
if err := appendRelatedTerminalEvents(ctx, batch, itemsByUser, seenByUser); err != nil {
g.Log().Warningf(ctx, "WarningNotify query terminal users failed, tenantId:%s, deptId:%d, err:%+v", batch.TenantID, batch.DeptID, err)
}
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]
})
deliveries := make([]platformDelivery, 0, len(userIDs))
for _, userID := range userIDs {
items := itemsByUser[userID]
if userID <= 0 || len(items) == 0 {
continue
}
terminalBatch := batch
terminalBatch.Items = append([]Event(nil), items...)
terminalBatch.WarnStage = highestStage(terminalBatch.Items)
deliveries = append(deliveries, platformDelivery{
Group: platformRecipientGroup{
SendUserType: sendUserTypeTerminal,
UserIDs: []int64{userID},
},
Batch: terminalBatch,
})
}
return deliveries
}
type indexedWarningEvent struct {
Index int
Event Event
}
func appendCreatedTerminalEvents(ctx context.Context, batch Batch, itemsByUser map[int64][]Event, seenByUser map[int64]map[int]struct{}) {
itemsByCreator := make(map[int64][]indexedWarningEvent)
for index, item := range batch.Items {
if item.ItemType != 1 {
continue
}
createdBy := item.CreatedBy
if createdBy <= 0 {
createdBy = batch.CreatedBy
}
if createdBy <= 0 {
continue
}
itemsByCreator[createdBy] = append(itemsByCreator[createdBy], indexedWarningEvent{Index: index, Event: item})
}
creatorIDs := make([]int64, 0, len(itemsByCreator))
for createdBy := range itemsByCreator {
creatorIDs = append(creatorIDs, createdBy)
}
sort.Slice(creatorIDs, func(i, j int) bool {
return creatorIDs[i] < creatorIDs[j]
})
for _, createdBy := range creatorIDs {
userIDs, err := queryCreatedTerminalUserIDs(ctx, batch.TenantID, createdBy)
if err != nil {
g.Log().Warningf(ctx, "WarningNotify query created terminal user failed, tenantId:%s, userId:%d, err:%+v", batch.TenantID, createdBy, err)
continue
}
for _, userID := range userIDs {
for _, item := range itemsByCreator[createdBy] {
appendTerminalEvent(itemsByUser, seenByUser, userID, item.Index, item.Event)
}
}
}
}
func appendRelatedTerminalEvents(ctx context.Context, batch Batch, itemsByUser map[int64][]Event, seenByUser map[int64]map[int]struct{}) error {
tenantID := strings.TrimSpace(batch.TenantID)
if tenantID == "" {
return nil
}
iccidIndexes, msisdnIndexes := terminalCardEventIndexes(batch.Items)
if len(iccidIndexes) == 0 && len(msisdnIndexes) == 0 {
return nil
}
if len(iccidIndexes) > 0 {
rows, err := queryTerminalUserCardRowsByColumn(ctx, tenantID, "st.iccid", mapKeys(iccidIndexes))
if err != nil {
return err
}
appendTerminalRows(itemsByUser, seenByUser, batch.Items, rows, iccidIndexes, msisdnIndexes)
}
if len(msisdnIndexes) > 0 {
rows, err := queryTerminalUserCardRowsByColumn(ctx, tenantID, "st.msisdn", mapKeys(msisdnIndexes))
if err != nil {
return err
}
appendTerminalRows(itemsByUser, seenByUser, batch.Items, rows, iccidIndexes, msisdnIndexes)
}
return nil
}
func queryTerminalUserCardRowsByColumn(ctx context.Context, tenantID string, column string, values []string) ([]terminalUserCardRow, error) {
rows := make([]terminalUserCardRow, 0)
if strings.TrimSpace(tenantID) == "" || len(values) == 0 {
return rows, nil
}
err := g.DB().Model(simTerminalTable+" st").Ctx(ctx).Safe().
LeftJoin(terminalTable+" t", "t.terminal_id = st.terminal_id AND t.tenant_id = st.tenant_id").
LeftJoin(dao.SysUser.Table()+" u", "(u.user_id = t.leader OR u.user_id = st.terminal_id) AND u.tenant_id = st.tenant_id").
Fields("DISTINCT u.user_id, st.iccid, st.msisdn").
Where("st.tenant_id", tenantID).
Where("u.user_type", consts.SysUserTypeAPP).
Where("u.status", consts.SysDeptStatusNormal).
WhereIn(column, values).
Scan(&rows)
return rows, err
}
func hasCardWarningItems(items []Event) bool {
for _, item := range items {
if item.ItemType == 1 {
return true
}
}
return false
}
func terminalCardEventIndexes(items []Event) (map[string][]int, map[string][]int) {
iccidIndexes := make(map[string][]int)
msisdnIndexes := make(map[string][]int)
for index, item := range items {
if item.ItemType != 1 {
continue
}
itemCode := strings.TrimSpace(item.ItemCode)
msisdn := strings.TrimSpace(item.MSISDN)
if itemCode != "" {
iccidIndexes[itemCode] = append(iccidIndexes[itemCode], index)
}
if msisdn != "" {
msisdnIndexes[msisdn] = append(msisdnIndexes[msisdn], index)
}
}
return iccidIndexes, msisdnIndexes
}
func appendTerminalRows(itemsByUser map[int64][]Event, seenByUser map[int64]map[int]struct{}, items []Event, rows []terminalUserCardRow, iccidIndexes map[string][]int, msisdnIndexes map[string][]int) {
for _, row := range rows {
if row.UserID <= 0 {
continue
}
for _, index := range iccidIndexes[strings.TrimSpace(row.ICCID)] {
appendTerminalEvent(itemsByUser, seenByUser, row.UserID, index, items[index])
}
for _, index := range msisdnIndexes[strings.TrimSpace(row.MSISDN)] {
appendTerminalEvent(itemsByUser, seenByUser, row.UserID, index, items[index])
}
}
}
func appendAllCardEvents(itemsByUser map[int64][]Event, seenByUser map[int64]map[int]struct{}, userID int64, items []Event) {
for index, item := range items {
if item.ItemType != 1 {
continue
}
appendTerminalEvent(itemsByUser, seenByUser, userID, index, item)
}
}
func appendTerminalEvent(itemsByUser map[int64][]Event, seenByUser map[int64]map[int]struct{}, userID int64, index int, item Event) {
if userID <= 0 {
return
}
seen := seenByUser[userID]
if seen == nil {
seen = make(map[int]struct{})
seenByUser[userID] = seen
}
if _, exists := seen[index]; exists {
return
}
seen[index] = struct{}{}
itemsByUser[userID] = append(itemsByUser[userID], item)
}
func mapKeys(values map[string][]int) []string {
keys := make([]string, 0, len(values))
for key := range values {
key = strings.TrimSpace(key)
if key != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
return keys
}
func platformMessageContext(ctx context.Context, batch Batch) context.Context {
return contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{
ID: batch.CreatedBy,
DeptId: batch.DeptID,
TenantId: strings.TrimSpace(batch.TenantID),
}}})
}
func platformMessageParam(batch Batch, group platformRecipientGroup) *model.TMessageLogEditParam {
messageType := uint(messageTypeSystem)
sendUserType := uint(group.SendUserType)
title := platformTitle(batch)
content := platformContent(batch)
link := platformLink(batch)
isRecall := uint(0)
return &model.TMessageLogEditParam{
MessageType: &messageType,
SendUserType: &sendUserType,
UserIds: gjson.New(group.UserIDs),
MessageTitle: &title,
MessageContent: &content,
MessageLink: &link,
IsRecall: &isRecall,
}
}
func platformTitle(batch Batch) string {
title := strings.TrimSpace(batch.PlatformTitle)
if title == "" {
return defaultPlatformTitle
}
return title
}
func platformContent(batch Batch) string {
content := strings.TrimSpace(batch.PlatformContent)
if content == "" {
return defaultPlatformContent
}
return content
}
func platformLink(batch Batch) string {
return ""
}
func appendUniqueInt64(values []int64, seen map[int64]struct{}, value int64) []int64 {
if value <= 0 {
return values
}
if _, ok := seen[value]; ok {
return values
}
seen[value] = struct{}{}
return append(values, value)
}
@@ -0,0 +1,113 @@
package warningnotify
import (
"context"
"errors"
"strings"
"sync"
"github.com/gogf/gf/v2/frame/g"
)
type Sender interface {
Send(ctx context.Context, batch Batch) error
}
var (
currentSender Sender = LogSender{}
senderMu sync.RWMutex
)
func SetSender(sender Sender) func() {
senderMu.Lock()
old := currentSender
if sender == nil {
currentSender = LogSender{}
} else {
currentSender = sender
}
senderMu.Unlock()
return func() {
senderMu.Lock()
currentSender = old
senderMu.Unlock()
}
}
func Dispatch(ctx context.Context, events []Event) error {
batches := BuildBatches(events)
if len(batches) == 0 {
return nil
}
senderMu.RLock()
sender := currentSender
senderMu.RUnlock()
if sender == nil {
sender = LogSender{}
}
var sendErrors []error
for _, batch := range batches {
if err := sender.Send(ctx, batch); err != nil {
sendErrors = append(sendErrors, err)
}
}
return errors.Join(sendErrors...)
}
type LogSender struct{}
func (LogSender) Send(ctx context.Context, batch Batch) error {
switch batch.Method {
case MethodSMS:
g.Log().Infof(
ctx,
"WarningNotify SMS payload prepared, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, content:%s",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
strings.Join(batch.Recipients, ","),
len(batch.Items),
batch.SMSContent,
)
case MethodEmail:
g.Log().Infof(
ctx,
"WarningNotify email payload prepared, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, recipients:%s, count:%d, subject:%s, body:%s",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
strings.Join(batch.Recipients, ","),
len(batch.Items),
batch.EmailSubject,
batch.EmailBody,
)
case MethodPlatform:
g.Log().Infof(
ctx,
"WarningNotify platform payload prepared, business:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d, title:%s, content:%s",
batch.Business,
batch.TenantID,
batch.DeptID,
batch.Category,
batch.WarnStage,
len(batch.Items),
batch.PlatformTitle,
batch.PlatformContent,
)
default:
g.Log().Infof(
ctx,
"WarningNotify payload skipped by unsupported method, business:%s, method:%s, tenantId:%s, deptId:%d, count:%d",
batch.Business,
batch.Method,
batch.TenantID,
batch.DeptID,
len(batch.Items),
)
}
return nil
}
@@ -0,0 +1,371 @@
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
}
@@ -0,0 +1,205 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.3
// source: common.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type HeadReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
RouterId int64 `protobuf:"varint,1,opt,name=router_id,json=routerId,proto3" json:"router_id,omitempty"`
TraceId string `protobuf:"bytes,2,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HeadReq) Reset() {
*x = HeadReq{}
mi := &file_common_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HeadReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeadReq) ProtoMessage() {}
func (x *HeadReq) ProtoReflect() protoreflect.Message {
mi := &file_common_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HeadReq.ProtoReflect.Descriptor instead.
func (*HeadReq) Descriptor() ([]byte, []int) {
return file_common_proto_rawDescGZIP(), []int{0}
}
func (x *HeadReq) GetRouterId() int64 {
if x != nil {
return x.RouterId
}
return 0
}
func (x *HeadReq) GetTraceId() string {
if x != nil {
return x.TraceId
}
return ""
}
type HeadRes struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
RouterId int64 `protobuf:"varint,3,opt,name=router_id,json=routerId,proto3" json:"router_id,omitempty"`
TraceId string `protobuf:"bytes,4,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HeadRes) Reset() {
*x = HeadRes{}
mi := &file_common_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HeadRes) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeadRes) ProtoMessage() {}
func (x *HeadRes) ProtoReflect() protoreflect.Message {
mi := &file_common_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HeadRes.ProtoReflect.Descriptor instead.
func (*HeadRes) Descriptor() ([]byte, []int) {
return file_common_proto_rawDescGZIP(), []int{1}
}
func (x *HeadRes) GetCode() int32 {
if x != nil {
return x.Code
}
return 0
}
func (x *HeadRes) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *HeadRes) GetRouterId() int64 {
if x != nil {
return x.RouterId
}
return 0
}
func (x *HeadRes) GetTraceId() string {
if x != nil {
return x.TraceId
}
return ""
}
var File_common_proto protoreflect.FileDescriptor
const file_common_proto_rawDesc = "" +
"\n" +
"\fcommon.proto\x12\bPbCommon\"A\n" +
"\aHeadReq\x12\x1b\n" +
"\trouter_id\x18\x01 \x01(\x03R\brouterId\x12\x19\n" +
"\btrace_id\x18\x02 \x01(\tR\atraceId\"o\n" +
"\aHeadRes\x12\x12\n" +
"\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12\x1b\n" +
"\trouter_id\x18\x03 \x01(\x03R\brouterId\x12\x19\n" +
"\btrace_id\x18\x04 \x01(\tR\atraceIdB\x16Z\x14iotlcm-sms/api/pb;pbb\x06proto3"
var (
file_common_proto_rawDescOnce sync.Once
file_common_proto_rawDescData []byte
)
func file_common_proto_rawDescGZIP() []byte {
file_common_proto_rawDescOnce.Do(func() {
file_common_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)))
})
return file_common_proto_rawDescData
}
var file_common_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_common_proto_goTypes = []any{
(*HeadReq)(nil), // 0: PbCommon.HeadReq
(*HeadRes)(nil), // 1: PbCommon.HeadRes
}
var file_common_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_common_proto_init() }
func file_common_proto_init() {
if File_common_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_common_proto_rawDesc), len(file_common_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_common_proto_goTypes,
DependencyIndexes: file_common_proto_depIdxs,
MessageInfos: file_common_proto_msgTypes,
}.Build()
File_common_proto = out.File
file_common_proto_goTypes = nil
file_common_proto_depIdxs = nil
}
@@ -0,0 +1,196 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.3
// source: notice.proto
package pb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type SendReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Head *HeadReq `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"`
Config []byte `protobuf:"bytes,2,opt,name=config,proto3" json:"config,omitempty"`
Params []byte `protobuf:"bytes,3,opt,name=params,proto3" json:"params,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SendReq) Reset() {
*x = SendReq{}
mi := &file_notice_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SendReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SendReq) ProtoMessage() {}
func (x *SendReq) ProtoReflect() protoreflect.Message {
mi := &file_notice_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SendReq.ProtoReflect.Descriptor instead.
func (*SendReq) Descriptor() ([]byte, []int) {
return file_notice_proto_rawDescGZIP(), []int{0}
}
func (x *SendReq) GetHead() *HeadReq {
if x != nil {
return x.Head
}
return nil
}
func (x *SendReq) GetConfig() []byte {
if x != nil {
return x.Config
}
return nil
}
func (x *SendReq) GetParams() []byte {
if x != nil {
return x.Params
}
return nil
}
type SendRes struct {
state protoimpl.MessageState `protogen:"open.v1"`
Head *HeadRes `protobuf:"bytes,1,opt,name=head,proto3" json:"head,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SendRes) Reset() {
*x = SendRes{}
mi := &file_notice_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SendRes) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SendRes) ProtoMessage() {}
func (x *SendRes) ProtoReflect() protoreflect.Message {
mi := &file_notice_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SendRes.ProtoReflect.Descriptor instead.
func (*SendRes) Descriptor() ([]byte, []int) {
return file_notice_proto_rawDescGZIP(), []int{1}
}
func (x *SendRes) GetHead() *HeadRes {
if x != nil {
return x.Head
}
return nil
}
var File_notice_proto protoreflect.FileDescriptor
const file_notice_proto_rawDesc = "" +
"\n" +
"\fnotice.proto\x12\bPbNotice\x1a\fcommon.proto\"`\n" +
"\aSendReq\x12%\n" +
"\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadReqR\x04head\x12\x16\n" +
"\x06config\x18\x02 \x01(\fR\x06config\x12\x16\n" +
"\x06params\x18\x03 \x01(\fR\x06params\"0\n" +
"\aSendRes\x12%\n" +
"\x04head\x18\x01 \x01(\v2\x11.PbCommon.HeadResR\x04head28\n" +
"\x06Notice\x12.\n" +
"\x04Send\x12\x11.PbNotice.SendReq\x1a\x11.PbNotice.SendRes\"\x00B\x16Z\x14iotlcm-sms/api/pb;pbb\x06proto3"
var (
file_notice_proto_rawDescOnce sync.Once
file_notice_proto_rawDescData []byte
)
func file_notice_proto_rawDescGZIP() []byte {
file_notice_proto_rawDescOnce.Do(func() {
file_notice_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_notice_proto_rawDesc), len(file_notice_proto_rawDesc)))
})
return file_notice_proto_rawDescData
}
var file_notice_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_notice_proto_goTypes = []any{
(*SendReq)(nil), // 0: PbNotice.SendReq
(*SendRes)(nil), // 1: PbNotice.SendRes
(*HeadReq)(nil), // 2: PbCommon.HeadReq
(*HeadRes)(nil), // 3: PbCommon.HeadRes
}
var file_notice_proto_depIdxs = []int32{
2, // 0: PbNotice.SendReq.head:type_name -> PbCommon.HeadReq
3, // 1: PbNotice.SendRes.head:type_name -> PbCommon.HeadRes
0, // 2: PbNotice.Notice.Send:input_type -> PbNotice.SendReq
1, // 3: PbNotice.Notice.Send:output_type -> PbNotice.SendRes
3, // [3:4] is the sub-list for method output_type
2, // [2:3] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_notice_proto_init() }
func file_notice_proto_init() {
if File_notice_proto != nil {
return
}
file_common_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_notice_proto_rawDesc), len(file_notice_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_notice_proto_goTypes,
DependencyIndexes: file_notice_proto_depIdxs,
MessageInfos: file_notice_proto_msgTypes,
}.Build()
File_notice_proto = out.File
file_notice_proto_goTypes = nil
file_notice_proto_depIdxs = nil
}
@@ -0,0 +1,121 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v5.29.3
// source: notice.proto
package pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
Notice_Send_FullMethodName = "/PbNotice.Notice/Send"
)
// NoticeClient is the client API for Notice service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type NoticeClient interface {
Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error)
}
type noticeClient struct {
cc grpc.ClientConnInterface
}
func NewNoticeClient(cc grpc.ClientConnInterface) NoticeClient {
return &noticeClient{cc}
}
func (c *noticeClient) Send(ctx context.Context, in *SendReq, opts ...grpc.CallOption) (*SendRes, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SendRes)
err := c.cc.Invoke(ctx, Notice_Send_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// NoticeServer is the server API for Notice service.
// All implementations must embed UnimplementedNoticeServer
// for forward compatibility.
type NoticeServer interface {
Send(context.Context, *SendReq) (*SendRes, error)
mustEmbedUnimplementedNoticeServer()
}
// UnimplementedNoticeServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedNoticeServer struct{}
func (UnimplementedNoticeServer) Send(context.Context, *SendReq) (*SendRes, error) {
return nil, status.Error(codes.Unimplemented, "method Send not implemented")
}
func (UnimplementedNoticeServer) mustEmbedUnimplementedNoticeServer() {}
func (UnimplementedNoticeServer) testEmbeddedByValue() {}
// UnsafeNoticeServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to NoticeServer will
// result in compilation errors.
type UnsafeNoticeServer interface {
mustEmbedUnimplementedNoticeServer()
}
func RegisterNoticeServer(s grpc.ServiceRegistrar, srv NoticeServer) {
// If the following call panics, it indicates UnimplementedNoticeServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&Notice_ServiceDesc, srv)
}
func _Notice_Send_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SendReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NoticeServer).Send(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Notice_Send_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NoticeServer).Send(ctx, req.(*SendReq))
}
return interceptor(ctx, in, info, handler)
}
// Notice_ServiceDesc is the grpc.ServiceDesc for Notice service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var Notice_ServiceDesc = grpc.ServiceDesc{
ServiceName: "PbNotice.Notice",
HandlerType: (*NoticeServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Send",
Handler: _Notice_Send_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "notice.proto",
}
@@ -0,0 +1,66 @@
package warningnotify
import "time"
const (
BusinessSimWarning = "sim_warning"
BusinessTrafficCutoff = "traffic_cutoff"
MethodSMS = "0"
MethodEmail = "1"
MethodPhone = "2"
MethodPlatform = "3"
MethodWechat = "4"
)
type Event struct {
Business string
EventID int64
TenantID string
DeptID int64
DeptName string
CreatedBy int64
Category string
Kind string
ItemType int
ItemID int64
ItemCode string
MSISDN string
WarnStage int
WarnLevel int
WarnMethods string
WarnPhones string
WarnEmails string
AppNotifyEnabled bool
WxTemplateID string
UnitText string
Threshold float64
Actual float64
TotalFlowMB float64
RemainFlowMB float64
Remark string
WarnTime time.Time
PeriodBegin time.Time
PeriodEnd time.Time
AffectOperation bool
OperationText string
BatchNo string
}
type Batch struct {
Business string
Method string
TenantID string
DeptID int64
DeptName string
CreatedBy int64
Category string
WarnStage int
Recipients []string
Items []Event
SMSContent string
EmailSubject string
EmailBody string
PlatformTitle string
PlatformContent string
}
@@ -0,0 +1,666 @@
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])
}