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:
@@ -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 ¬iceClient{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])
|
||||
}
|
||||
Reference in New Issue
Block a user