chore: 并入 iotlcm-admin-svr 历史到 admin-svr/(原提交 49818c6)
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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)
|
||||
}
|
||||
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,448 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const warningEmailHTMLSignature = `data-warning-email="iotlcm"`
|
||||
|
||||
func applyContent(batch *Batch) {
|
||||
if batch == nil {
|
||||
return
|
||||
}
|
||||
switch batch.Method {
|
||||
case MethodSMS:
|
||||
batch.SMSContent = buildSMSContent(*batch)
|
||||
case MethodEmail:
|
||||
batch.EmailSubject = buildEmailSubject(*batch)
|
||||
batch.EmailBody = buildEmailBody(*batch)
|
||||
case MethodPlatform:
|
||||
batch.PlatformTitle = buildPlatformTitle(*batch)
|
||||
batch.PlatformContent = buildPlatformContent(*batch)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSMSContent(batch Batch) string {
|
||||
count := batchItemCount(batch)
|
||||
if count == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
summary := batchObjectSummary(batch)
|
||||
title := warningTitle(batch)
|
||||
action := ""
|
||||
if batchHasAffectOperation(batch) {
|
||||
action = ",涉及停机或断网操作"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"【物联网】%s触发%s%s,请登录控制台查看。",
|
||||
summary,
|
||||
title,
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
func buildEmailSubject(batch Batch) string {
|
||||
count := batchItemCount(batch)
|
||||
if count == 0 {
|
||||
return "物联网预警提醒"
|
||||
}
|
||||
return fmt.Sprintf("%s(%d条)", warningTitle(batch), count)
|
||||
}
|
||||
|
||||
func buildEmailBody(batch Batch) string {
|
||||
count := batchItemCount(batch)
|
||||
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(batchHighestLevel(batch))))
|
||||
if batchHasAffectOperation(batch) {
|
||||
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 := batchItemCount(batch)
|
||||
if count == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := buildPlatformSummaryContent(batch)
|
||||
if batchHasAffectOperation(batch) {
|
||||
content += "本次提醒包含停机或断网类操作,请及时确认。"
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
func buildPlatformSummaryContent(batch Batch) string {
|
||||
if isExpireDayBatch(batch) {
|
||||
return fmt.Sprintf(
|
||||
"%s有%d张卡临近到期,最高档位:%s,请登录控制台查看。",
|
||||
firstObjectCode(batch),
|
||||
batchItemCount(batch),
|
||||
buildPlatformTitle(batch),
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s触发%s,最高级别:%s,请登录控制台查看。",
|
||||
platformObjectSummary(batch),
|
||||
buildPlatformTitle(batch),
|
||||
levelText(batchHighestLevel(batch)),
|
||||
)
|
||||
}
|
||||
|
||||
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 := batchItemCount(batch)
|
||||
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)
|
||||
}
|
||||
|
||||
func platformObjectSummary(batch Batch) string {
|
||||
if len(batch.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
if batchItemCount(batch) > 5 {
|
||||
return fmt.Sprintf("\u5171%d%s", batchItemCount(batch), objectCountUnit(batch.Items[0]))
|
||||
}
|
||||
codes := make([]string, 0, len(batch.Items))
|
||||
for _, item := range batch.Items {
|
||||
codes = append(codes, displayObjectCode(item))
|
||||
}
|
||||
return strings.Join(codes, "\u3001")
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
)
|
||||
|
||||
type failureRecordingSender struct{ calls int }
|
||||
|
||||
func (sender *failureRecordingSender) Send(ctx context.Context, batch Batch) error {
|
||||
sender.calls++
|
||||
return errors.New("send failed")
|
||||
}
|
||||
|
||||
func TestDispatchContinuesAfterDeliveryFailure(t *testing.T) {
|
||||
sender := &failureRecordingSender{}
|
||||
restore := SetSender(sender)
|
||||
defer restore()
|
||||
events := []Event{{TenantID: "tenant", ItemType: 1, ItemCode: "card", Category: "traffic", WarnMethods: MethodEmail, WarnEmails: "xxx@example.com", AppNotifyEnabled: true}}
|
||||
expected := len(BuildBatches(events))
|
||||
if expected < 2 {
|
||||
t.Fatal("expected multiple channels")
|
||||
}
|
||||
if err := Dispatch(context.Background(), events); err == nil {
|
||||
t.Fatal("failure must propagate")
|
||||
}
|
||||
if sender.calls != expected {
|
||||
t.Fatalf("calls=%d expected=%d", sender.calls, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchesOnlyCreatesPlatformAndConfiguredChannels(t *testing.T) {
|
||||
batches := BuildBatches([]Event{{
|
||||
Business: BusinessSimWarning,
|
||||
TenantID: "tenant",
|
||||
ItemCode: "card-1",
|
||||
WarnMethods: MethodEmail,
|
||||
WarnEmails: "xxx@example.com",
|
||||
AppNotifyEnabled: false,
|
||||
}})
|
||||
|
||||
methods := make(map[string]bool, len(batches))
|
||||
for _, batch := range batches {
|
||||
methods[batch.Method] = true
|
||||
}
|
||||
|
||||
for _, method := range []string{MethodPlatform, MethodEmail} {
|
||||
if !methods[method] {
|
||||
t.Errorf("expected %q batch to be generated", method)
|
||||
}
|
||||
}
|
||||
if methods[MethodWechat] {
|
||||
t.Error("wechat subscription batch must not be generated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchesIgnoresLegacyWechatFlag(t *testing.T) {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
for _, business := range []string{BusinessSimWarning, BusinessTrafficCutoff} {
|
||||
batches := BuildBatches([]Event{{
|
||||
Business: business,
|
||||
TenantID: "tenant",
|
||||
ItemCode: "card-1",
|
||||
AppNotifyEnabled: enabled,
|
||||
}})
|
||||
if len(batches) != 1 || batches[0].Method != MethodPlatform {
|
||||
t.Errorf("business=%q enabled=%t: expected platform only, got %+v", business, enabled, batches)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnterpriseWarningRecipientEligibility(t *testing.T) {
|
||||
if !enterpriseWarningRecipientEligible(consts.SysUserTypeSys, 175, 175, "") {
|
||||
t.Fatal("same-organization enterprise creator should be eligible")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
userType string
|
||||
userDeptID int64
|
||||
cardDeptID int64
|
||||
}{
|
||||
{name: "app user", userType: consts.SysUserTypeAPP, userDeptID: 175, cardDeptID: 175},
|
||||
{name: "different organization", userType: consts.SysUserTypeSys, userDeptID: 176, cardDeptID: 175},
|
||||
{name: "missing organization", userType: consts.SysUserTypeSys, userDeptID: 0, cardDeptID: 175},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if enterpriseWarningRecipientEligible(test.userType, test.userDeptID, test.cardDeptID, "") {
|
||||
t.Fatal("recipient should not be eligible")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestEnterpriseWarningAncestorScope(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ancestors string
|
||||
expected bool
|
||||
}{
|
||||
{"direct descendant", "0,100,175,", true},
|
||||
{"deep descendant", "0,100,175,185", true},
|
||||
{"whitespace", "0, 175 ,185", true},
|
||||
{"partial identifier", "0,100,1175,1750", false},
|
||||
{"unrelated branch", "0,100,176", false},
|
||||
{"missing path", "", false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if actual := enterpriseWarningRecipientEligible(consts.SysUserTypeSys, 175, 195, test.ancestors); actual != test.expected {
|
||||
t.Fatalf("eligible = %v, want %v", actual, test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatTokenInvalidCodes(t *testing.T) {
|
||||
for _, code := range []int{40001, 40014, 42001} {
|
||||
if !warningWechatTokenInvalid(code) {
|
||||
t.Fatalf("code %d must refresh token", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{0, 43101, 47003, 40003} {
|
||||
if warningWechatTokenInvalid(code) {
|
||||
t.Fatalf("code %d must not refresh token", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatDefaultTemplateMatchesMiniProgram(t *testing.T) {
|
||||
const expected = "DMDP2PKktdtG1ES_3Ic1JcR0LGELQmWE6LunYyerHCY"
|
||||
if actual := wechatTemplateID(nil, consts.WarningWechatTemplateIDDefault); actual != expected {
|
||||
t.Fatalf("default template = %q, expected %q", actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCutoffPlatformRecipientsExcludeCreator(t *testing.T) {
|
||||
got := platformAgentRecipientIDs(BusinessTrafficCutoff, []int64{130}, 1)
|
||||
if len(got) != 1 || got[0] != 130 {
|
||||
t.Fatalf("traffic cutoff recipients = %v, want [130] without admin creator 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherPlatformRecipientsRetainCreator(t *testing.T) {
|
||||
got := platformAgentRecipientIDs(BusinessSimWarning, []int64{130}, 1)
|
||||
if len(got) != 2 || got[0] != 130 || got[1] != 1 {
|
||||
t.Fatalf("regular warning recipients = %v, want [130 1]", got)
|
||||
}
|
||||
}
|
||||
@@ -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,588 @@
|
||||
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 err
|
||||
}
|
||||
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) {
|
||||
if batch.prepared != nil {
|
||||
return []platformDelivery{{Group: *batch.prepared, Batch: batch}}, nil
|
||||
}
|
||||
deliveries := make([]platformDelivery, 0, 2)
|
||||
|
||||
agentUserIDs, err := queryAgentUserIDs(ctx, batch.TenantID, batch.DeptID, batch.CreatedBy, batch.Business)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(agentUserIDs) > 0 {
|
||||
deliveries = append(deliveries, platformDelivery{
|
||||
Group: platformRecipientGroup{
|
||||
SendUserType: sendUserTypeAgent,
|
||||
UserIDs: agentUserIDs,
|
||||
},
|
||||
Batch: batch,
|
||||
})
|
||||
} else if batch.Business == BusinessTrafficCutoff {
|
||||
g.Log().Warningf(ctx, "WarningNotify traffic cutoff skipped department recipients, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d", batch.TenantID, batch.DeptID, batch.Category, batch.WarnStage, len(batch.Items))
|
||||
}
|
||||
|
||||
if hasCardWarningItems(batch.Items) {
|
||||
deliveries = append(deliveries, resolveTerminalPlatformDeliveries(ctx, batch)...)
|
||||
}
|
||||
|
||||
return deliveries, nil
|
||||
}
|
||||
|
||||
func queryAgentUserIDs(ctx context.Context, tenantID string, deptID int64, createdBy int64, business string) ([]int64, error) {
|
||||
deptUserIDs := make([]int64, 0)
|
||||
creatorUserID := int64(0)
|
||||
if strings.TrimSpace(tenantID) == "" {
|
||||
return deptUserIDs, 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 {
|
||||
deptUserIDs = append(deptUserIDs, user.UserId)
|
||||
}
|
||||
}
|
||||
|
||||
if business != BusinessTrafficCutoff && 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 {
|
||||
creatorUserID = creator.UserId
|
||||
}
|
||||
}
|
||||
|
||||
return platformAgentRecipientIDs(business, deptUserIDs, creatorUserID), nil
|
||||
}
|
||||
|
||||
func platformAgentRecipientIDs(business string, deptUserIDs []int64, createdBy int64) []int64 {
|
||||
userIDs := make([]int64, 0, len(deptUserIDs)+1)
|
||||
seen := make(map[int64]struct{})
|
||||
for _, userID := range deptUserIDs {
|
||||
userIDs = appendUniqueInt64(userIDs, seen, userID)
|
||||
}
|
||||
if business != BusinessTrafficCutoff {
|
||||
userIDs = appendUniqueInt64(userIDs, seen, createdBy)
|
||||
}
|
||||
return userIDs
|
||||
}
|
||||
|
||||
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 {
|
||||
if batch.prepared != nil {
|
||||
return []platformDelivery{{Group: *batch.prepared, Batch: batch}}
|
||||
}
|
||||
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
|
||||
}
|
||||
for offset := 0; offset < len(values); offset += 500 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := min(offset+500, len(values))
|
||||
var page []terminalUserCardRow
|
||||
if 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[offset:end]).
|
||||
Scan(&page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, page...)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
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,83 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSmallAndLargePlatformSummary(t *testing.T) {
|
||||
b := Batch{Method: MethodPlatform, WarnStage: 2}
|
||||
for i := 0; i < 2; i++ {
|
||||
b.Items = append(b.Items, Event{ItemType: 1, ItemCode: fmt.Sprintf("898600000000000000%02d", i), WarnLevel: 3})
|
||||
}
|
||||
applyContent(&b)
|
||||
for _, item := range b.Items {
|
||||
if !strings.Contains(b.PlatformContent, item.ItemCode) {
|
||||
t.Fatal("missing card")
|
||||
}
|
||||
}
|
||||
if b.EmailBody != "" || b.SMSContent != "" {
|
||||
t.Fatal("unrelated content generated")
|
||||
}
|
||||
for i := 2; i < 10000; i++ {
|
||||
b.Items = append(b.Items, Event{ItemType: 1, ItemCode: "89860000000000000000", WarnLevel: 3})
|
||||
}
|
||||
applyContent(&b)
|
||||
if !strings.Contains(b.PlatformContent, "10000") || len([]rune(b.PlatformContent)) > 150 {
|
||||
t.Fatal("unbounded summary")
|
||||
}
|
||||
b.Method = MethodWechat
|
||||
b.EmailBody = ""
|
||||
applyContent(&b)
|
||||
if b.EmailBody != "" {
|
||||
t.Fatal("wechat generated email")
|
||||
}
|
||||
payload := buildWechatPayload(b, "openid", "template", "pages/message/index")
|
||||
if payload.Data["character_string6"].Value != "TOTAL-10000" {
|
||||
t.Fatal("missing total")
|
||||
}
|
||||
if !strings.Contains(payload.Data["thing9"].Value, "10000") || !strings.Contains(payload.Data["thing9"].Value, levelText(3)) {
|
||||
t.Fatal("missing count or severity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectorKeepsFullCountWithBoundedRecipientPreview(t *testing.T) {
|
||||
collector := NewCollector()
|
||||
recipient := &platformRecipientGroup{SendUserType: 2, UserIDs: []int64{11}}
|
||||
other := &platformRecipientGroup{SendUserType: 2, UserIDs: []int64{12}}
|
||||
for page := 0; page < 50; page++ {
|
||||
batch := Batch{Method: MethodPlatform, Business: BusinessSimWarning, TenantID: "tenant-a", Category: "traffic", WarnStage: 2}
|
||||
for index := 0; index < 200; index++ {
|
||||
batch.Items = append(batch.Items, Event{ItemCode: fmt.Sprintf("8986%016d", page*200+index), WarnLevel: 3, ItemType: 1})
|
||||
}
|
||||
collector.merge(batch, recipient)
|
||||
batch.Method = MethodWechat
|
||||
collector.merge(batch, recipient)
|
||||
}
|
||||
collector.merge(Batch{Method: MethodPlatform, TenantID: "tenant-a", Business: BusinessSimWarning, Category: "traffic", Items: []Event{{ItemCode: "private", ItemType: 1}}}, other)
|
||||
if len(collector.batches) != 3 {
|
||||
t.Fatalf("channels or recipients merged: %d", len(collector.batches))
|
||||
}
|
||||
for _, batch := range collector.batches {
|
||||
if len(batch.Items) > 5 {
|
||||
t.Fatal("preview grows with cards")
|
||||
}
|
||||
if batch.prepared.UserIDs[0] == 11 {
|
||||
if batchItemCount(*batch) != 10000 {
|
||||
t.Fatal("count lost across pages")
|
||||
}
|
||||
for _, item := range batch.Items {
|
||||
if item.ItemCode == "private" {
|
||||
t.Fatal("recipient data leak")
|
||||
}
|
||||
}
|
||||
if batch.Method == MethodWechat {
|
||||
payload := buildWechatPayload(*batch, "openid", "template", "pages/message/index")
|
||||
if payload.Data["character_string6"].Value != "TOTAL-10000" {
|
||||
t.Fatal("preview count used as total")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
)
|
||||
|
||||
func warningWechatEnabled(ctx context.Context, tenantID string, userID int64) (bool, error) {
|
||||
var preference *struct {
|
||||
Value string `orm:"config_value"`
|
||||
}
|
||||
err := dao.SysConfig.Ctx(ctx).Fields("config_value").Where("tenant_id", tenantID).
|
||||
Where("config_key", fmt.Sprintf("app.wechat.warning.enabled.%d", userID)).OrderDesc("config_id").Scan(&preference)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return preference == nil || preference.Value == "1", nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWarningWechatPreferenceIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := g.DB()
|
||||
t.Cleanup(func() {
|
||||
_ = db.GetCore().ClearCacheAll(ctx)
|
||||
_ = db.Close(ctx)
|
||||
_ = gdb.SetConfigGroup("default", previous)
|
||||
})
|
||||
if _, err := db.Exec(ctx, "CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, config_key TEXT, config_value TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := warningWechatEnabled(ctx, "tenant", 1)
|
||||
if err != nil || !enabled {
|
||||
t.Fatalf("legacy default: %v %v", enabled, err)
|
||||
}
|
||||
for _, item := range []struct{ tenant, value string }{{"tenant", "0"}, {"other", "1"}} {
|
||||
if _, err := db.Model("sys_config").Data(do.SysConfig{TenantId: item.tenant, ConfigKey: "app.wechat.warning.enabled.1", ConfigValue: item.value}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
enabled, err = warningWechatEnabled(ctx, "tenant", 1)
|
||||
if err != nil || enabled {
|
||||
t.Fatalf("disabled preference ignored: %v %v", enabled, err)
|
||||
}
|
||||
enabled, err = warningWechatEnabled(ctx, "other", 1)
|
||||
if err != nil || !enabled {
|
||||
t.Fatalf("other tenant affected: %v %v", enabled, err)
|
||||
}
|
||||
enabled, err = warningWechatEnabled(ctx, "tenant", 2)
|
||||
if err != nil || !enabled {
|
||||
t.Fatalf("other user affected: %v %v", enabled, err)
|
||||
}
|
||||
if _, err := db.Model("sys_config").Where("tenant_id", "tenant").Data(do.SysConfig{ConfigValue: "1"}).Update(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err = warningWechatEnabled(ctx, "tenant", 1)
|
||||
if err != nil || !enabled {
|
||||
t.Fatalf("stale preference after enable: %v %v", enabled, err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, "DROP TABLE sys_config"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.GetCore().ClearCacheAll(ctx)
|
||||
enabled, err = warningWechatEnabled(ctx, "tenant", 1)
|
||||
if err == nil || enabled {
|
||||
t.Fatal("read errors must fail closed")
|
||||
}
|
||||
}
|
||||
@@ -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,112 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Collector struct{ batches map[string]*Batch }
|
||||
|
||||
func NewCollector() *Collector { return &Collector{batches: make(map[string]*Batch)} }
|
||||
func (c *Collector) Add(ctx context.Context, events []Event) error {
|
||||
for _, batch := range BuildBatches(events) {
|
||||
switch batch.Method {
|
||||
case MethodPlatform:
|
||||
deliveries, err := resolvePlatformDeliveries(ctx, batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, delivery := range deliveries {
|
||||
c.merge(delivery.Batch, &delivery.Group)
|
||||
}
|
||||
case MethodWechat:
|
||||
for _, delivery := range resolveWechatDeliveries(ctx, batch) {
|
||||
c.merge(delivery.Batch, &delivery.Group)
|
||||
}
|
||||
default:
|
||||
c.merge(batch, nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Collector) merge(batch Batch, recipient *platformRecipientGroup) {
|
||||
keyBytes, _ := json.Marshal(struct {
|
||||
Group groupKey
|
||||
Recipient *platformRecipientGroup
|
||||
}{groupKeyForEvent(batch.Method, batch.Recipients, Event{Business: batch.Business, TenantID: batch.TenantID, DeptID: batch.DeptID, CreatedBy: batch.CreatedBy, Category: batch.Category}), recipient})
|
||||
key := string(keyBytes)
|
||||
current := c.batches[key]
|
||||
if current == nil {
|
||||
copy := batch
|
||||
copy.Items = nil
|
||||
copy.TotalCount = 0
|
||||
copy.prepared = recipient
|
||||
current = ©
|
||||
c.batches[key] = current
|
||||
}
|
||||
current.TotalCount += len(batch.Items)
|
||||
current.WarnStage = max(current.WarnStage, batch.WarnStage)
|
||||
current.HighestLevel = max(current.HighestLevel, highestLevel(batch.Items))
|
||||
current.AffectOperation = current.AffectOperation || hasAffectOperation(batch.Items)
|
||||
latest := latestEvent(batch.Items).WarnTime
|
||||
if latest.After(current.LatestTime) {
|
||||
current.LatestTime = latest
|
||||
}
|
||||
current.Items = append(current.Items, batch.Items...)
|
||||
if recipient != nil {
|
||||
sort.SliceStable(current.Items, func(i, j int) bool {
|
||||
if current.Items[i].WarnLevel != current.Items[j].WarnLevel {
|
||||
return current.Items[i].WarnLevel > current.Items[j].WarnLevel
|
||||
}
|
||||
if current.Items[i].WarnStage != current.Items[j].WarnStage {
|
||||
return current.Items[i].WarnStage > current.Items[j].WarnStage
|
||||
}
|
||||
return current.Items[i].ItemCode < current.Items[j].ItemCode
|
||||
})
|
||||
if len(current.Items) > 5 {
|
||||
preview := append([]Event(nil), current.Items[:5]...)
|
||||
current.Items = preview
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *Collector) Flush(ctx context.Context) error {
|
||||
senderMu.RLock()
|
||||
sender := currentSender
|
||||
senderMu.RUnlock()
|
||||
if sender == nil {
|
||||
sender = LogSender{}
|
||||
}
|
||||
keys := make([]string, 0, len(c.batches))
|
||||
for key := range c.batches {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var failures []string
|
||||
for _, key := range keys {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
batch := c.batches[key]
|
||||
applyContent(batch)
|
||||
if err := sender.Send(ctx, *batch); err != nil {
|
||||
failures = append(failures, err.Error())
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return gerror.New(strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func batchItemCount(batch Batch) int {
|
||||
if batch.TotalCount > 0 {
|
||||
return batch.TotalCount
|
||||
}
|
||||
return len(batch.Items)
|
||||
}
|
||||
func batchHighestLevel(batch Batch) int { return max(batch.HighestLevel, highestLevel(batch.Items)) }
|
||||
func batchHasAffectOperation(batch Batch) bool {
|
||||
return batch.AffectOperation || hasAffectOperation(batch.Items)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type batchRecordingSender struct{ batches []Batch }
|
||||
|
||||
func (sender *batchRecordingSender) Send(ctx context.Context, batch Batch) error {
|
||||
sender.batches = append(sender.batches, batch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCollectorSkipsWechatEvenForEligibleEnterpriseCreators(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := g.DB()
|
||||
t.Cleanup(func() {
|
||||
_ = db.GetCore().ClearCacheAll(ctx)
|
||||
_ = db.Close(ctx)
|
||||
_ = gdb.SetConfigGroup("default", previous)
|
||||
})
|
||||
for _, statement := range []string{
|
||||
"CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, ancestors TEXT, deleted_at DATETIME)",
|
||||
"CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, user_type TEXT, dept_id INTEGER, status TEXT, deleted_at DATETIME)",
|
||||
"CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER)",
|
||||
"CREATE TABLE t_sim_terminal (terminal_id INTEGER, tenant_id TEXT, iccid TEXT, msisdn TEXT)",
|
||||
"CREATE TABLE t_terminal (terminal_id INTEGER, tenant_id TEXT, leader INTEGER, deleted_at DATETIME)",
|
||||
} {
|
||||
if _, err := db.Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, dept := range []do.SysDept{
|
||||
{DeptId: 175, TenantId: "tenant", Ancestors: "0,100,"},
|
||||
{DeptId: 176, TenantId: "tenant", Ancestors: "0,100,"},
|
||||
{DeptId: 185, TenantId: "tenant", Ancestors: "0,100,175,"},
|
||||
{DeptId: 195, TenantId: "tenant", Ancestors: "0,100,175,185"},
|
||||
{DeptId: 100, TenantId: "tenant", Ancestors: "0"},
|
||||
} {
|
||||
if _, err := db.Model("sys_dept").Data(dept).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, user := range []do.SysUser{
|
||||
{UserId: 133, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "0"},
|
||||
{UserId: 134, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "0"},
|
||||
{UserId: 135, TenantId: "other", UserType: "sys_user", DeptId: 175, Status: "0"},
|
||||
{UserId: 136, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "1"},
|
||||
{UserId: 137, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "0"},
|
||||
} {
|
||||
if _, err := db.Model("sys_user").Data(user).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Model("sys_user").Where("user_id", 137).Delete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, card := range []do.TSimCardInfo{
|
||||
{Id: 84189, TenantId: "tenant", DeptId: 175},
|
||||
{Id: 84190, TenantId: "tenant", DeptId: 176},
|
||||
{Id: 84191, TenantId: "other", DeptId: 175},
|
||||
{Id: 84199, TenantId: "tenant", DeptId: 185},
|
||||
{Id: 84200, TenantId: "tenant", DeptId: 195},
|
||||
{Id: 84201, TenantId: "tenant", DeptId: 100},
|
||||
{Id: 84202, TenantId: "tenant", DeptId: 999},
|
||||
} {
|
||||
if _, err := db.Model("t_sim_card_info").Data(card).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
base := Event{Business: BusinessSimWarning, TenantID: "tenant", DeptID: 175, CreatedBy: 133, ItemType: 1, ItemID: 84189, ItemCode: "89860401791508277319", WarnMethods: MethodPlatform, WarnStage: 2}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
creator int64
|
||||
card int64
|
||||
expected int
|
||||
}{
|
||||
{"enterprise creator", 133, 84189, 1},
|
||||
{"another creator in same department", 134, 84189, 1},
|
||||
{"sibling organization", 133, 84190, 0},
|
||||
{"direct descendant card", 133, 84199, 1},
|
||||
{"deep descendant card", 133, 84200, 1},
|
||||
{"ancestor organization", 133, 84201, 0},
|
||||
{"missing card organization", 133, 84202, 0},
|
||||
{"different card tenant", 133, 84191, 0},
|
||||
{"different user tenant", 135, 84189, 0},
|
||||
{"disabled creator", 136, 84189, 0},
|
||||
{"deleted creator", 137, 84189, 0},
|
||||
{"missing creator", 999, 84189, 0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
event := base
|
||||
event.CreatedBy = test.creator
|
||||
event.ItemID = test.card
|
||||
collector := NewCollector()
|
||||
for page := 0; page < 2; page++ {
|
||||
event.EventID = int64(page + 1)
|
||||
if err := collector.Add(ctx, []Event{event}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sender := &batchRecordingSender{}
|
||||
restore := SetSender(sender)
|
||||
defer restore()
|
||||
if err := collector.Flush(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
platformCount := 0
|
||||
for _, batch := range sender.batches {
|
||||
if batch.Method == MethodWechat {
|
||||
t.Fatal("wechat subscription batch must not be sent")
|
||||
}
|
||||
if batch.Method == MethodPlatform {
|
||||
platformCount++
|
||||
}
|
||||
}
|
||||
if test.expected > 0 && platformCount == 0 {
|
||||
t.Fatal("eligible creator lost in-app notification")
|
||||
}
|
||||
legacy := Batch{Method: MethodWechat, Business: BusinessSimWarning, TenantID: "tenant", Items: []Event{event}}
|
||||
eligible := 0
|
||||
for _, delivery := range resolveWechatDeliveries(ctx, legacy) {
|
||||
if delivery.Group.SendUserType == sendUserTypeAgent && len(delivery.Group.UserIDs) == 1 && delivery.Group.UserIDs[0] == test.creator {
|
||||
eligible++
|
||||
}
|
||||
}
|
||||
if eligible != test.expected {
|
||||
t.Fatalf("legacy eligibility = %d, want %d", eligible, test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWechatPreparedDeliveryDoesNotResolveAgain(t *testing.T) {
|
||||
batch := Batch{Business: BusinessSimWarning, Method: MethodWechat, prepared: &platformRecipientGroup{SendUserType: sendUserTypeAgent, UserIDs: []int64{133}}, Items: []Event{{CreatedBy: 133, DeptID: 175, ItemType: 1, ItemID: 84189}}}
|
||||
deliveries := resolveWechatDeliveries(context.Background(), batch)
|
||||
if len(deliveries) != 1 || len(deliveries[0].Group.UserIDs) != 1 || deliveries[0].Group.UserIDs[0] != 133 {
|
||||
t.Fatalf("unexpected prepared deliveries: %+v", deliveries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 {
|
||||
TotalCount int
|
||||
HighestLevel int
|
||||
LatestTime time.Time
|
||||
AffectOperation bool
|
||||
prepared *platformRecipientGroup
|
||||
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,798 @@
|
||||
package warningnotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/cache"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
const (
|
||||
warningWechatAccessTokenURL = "https://api.weixin.qq.com/cgi-bin/token"
|
||||
warningWechatSendURL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send"
|
||||
warningWechatDefaultPage = "pages/message/index"
|
||||
warningWechatDefaultTimeout = 5 * time.Second
|
||||
warningWechatSentKeyPrefix = "iotlcm:warning:wechat:sent"
|
||||
warningWechatMinSentTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
var warningWechatTokenMu sync.Mutex
|
||||
|
||||
type warningWechatConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppID string `json:"appId"`
|
||||
AppSecret string `json:"appSecret"`
|
||||
TemplateID string `json:"templateId"`
|
||||
Page string `json:"page"`
|
||||
Timeout string `json:"timeout"`
|
||||
}
|
||||
|
||||
type warningWechatBinding struct {
|
||||
UserID int64 `orm:"user_id"`
|
||||
OpenID string `orm:"open_id"`
|
||||
}
|
||||
|
||||
type warningWechatAccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
type warningWechatSendResponse struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
type warningWechatTemplateValue struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type warningWechatPayload struct {
|
||||
ToUser string `json:"touser"`
|
||||
TemplateID string `json:"template_id"`
|
||||
Page string `json:"page,omitempty"`
|
||||
Data map[string]warningWechatTemplateValue `json:"data"`
|
||||
}
|
||||
|
||||
func sendWarningWechat(ctx context.Context, batch Batch) error {
|
||||
cfg, ok := loadWarningWechatConfig(ctx)
|
||||
if !ok || !cfg.Enabled {
|
||||
g.Log().Infof(
|
||||
ctx,
|
||||
"WarningNotify wechat subscribe skipped, reason:%s, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d",
|
||||
wechatConfigSkipReason(ok, cfg),
|
||||
batch.TenantID,
|
||||
batch.DeptID,
|
||||
batch.Category,
|
||||
batch.WarnStage,
|
||||
batchItemCount(batch),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.AppSecret) == "" {
|
||||
g.Log().Warningf(ctx, "WarningNotify wechat subscribe skipped, reason:app credentials incomplete, tenantId:%s", batch.TenantID)
|
||||
return nil
|
||||
}
|
||||
|
||||
deliveries := resolveWechatDeliveries(ctx, batch)
|
||||
if len(deliveries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sendErrs []string
|
||||
for _, delivery := range deliveries {
|
||||
if len(delivery.Group.UserIDs) == 0 || batchItemCount(delivery.Batch) == 0 {
|
||||
continue
|
||||
}
|
||||
userID := delivery.Group.UserIDs[0]
|
||||
if batch.Business == BusinessSimWarning {
|
||||
enabled, err := warningWechatEnabled(ctx, batch.TenantID, userID)
|
||||
if err != nil {
|
||||
sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query preference: %v", userID, err))
|
||||
continue
|
||||
}
|
||||
if !enabled {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:user preference disabled, tenantId:%s, userId:%d, count:%d", batch.TenantID, userID, batchItemCount(delivery.Batch))
|
||||
continue
|
||||
}
|
||||
}
|
||||
bindings, err := queryWechatBindings(ctx, batch.TenantID, userID)
|
||||
if err != nil {
|
||||
sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query binding: %v", userID, err))
|
||||
continue
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no openid, tenantId:%s, userId:%d, count:%d", batch.TenantID, userID, batchItemCount(delivery.Batch))
|
||||
continue
|
||||
}
|
||||
|
||||
acquired, sentKey, err := acquireWechatSendDedupe(ctx, delivery.Batch, userID)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "WarningNotify wechat subscribe dedupe check failed, tenantId:%s, userId:%d, category:%s, stage:%d, count:%d, err:%+v",
|
||||
batch.TenantID, userID, delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch), err)
|
||||
} else if !acquired {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:duplicated, tenantId:%s, userId:%d, key:%s, category:%s, stage:%d, count:%d",
|
||||
batch.TenantID, userID, sentKey, delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch))
|
||||
continue
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, binding := range bindings {
|
||||
if batch.Business == BusinessSimWarning {
|
||||
enabled, err := warningWechatEnabled(ctx, batch.TenantID, userID)
|
||||
if err != nil {
|
||||
sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query preference: %v", userID, err))
|
||||
continue
|
||||
}
|
||||
if !enabled {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:user preference disabled before send, tenantId:%s, userId:%d", batch.TenantID, userID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
templateID := wechatTemplateID(delivery.Batch.Items, cfg.TemplateID)
|
||||
if templateID == "" {
|
||||
sendErrs = append(sendErrs, fmt.Sprintf("userId=%d template id empty", userID))
|
||||
continue
|
||||
}
|
||||
payload := buildWechatPayload(delivery.Batch, binding.OpenID, templateID, cfg.Page)
|
||||
if err := sendWechatSubscription(ctx, cfg, payload); err != nil {
|
||||
sendErrs = append(sendErrs, fmt.Sprintf("userId=%d openid=%s: %v", userID, maskOpenID(binding.OpenID), err))
|
||||
g.Log().Errorf(ctx, "WarningNotify wechat subscribe failed, tenantId:%s, userId:%d, openid:%s, category:%s, stage:%d, count:%d, err:%+v",
|
||||
batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch), err)
|
||||
continue
|
||||
}
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe sent, tenantId:%s, userId:%d, openid:%s, category:%s, stage:%d, count:%d",
|
||||
batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch))
|
||||
successCount++
|
||||
}
|
||||
if acquired && successCount == 0 {
|
||||
releaseWechatSendDedupe(ctx, sentKey)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sendErrs) > 0 {
|
||||
return fmt.Errorf("wechat subscribe send completed with failures: %s", strings.Join(sendErrs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveWechatDeliveries(ctx context.Context, batch Batch) []platformDelivery {
|
||||
if batch.prepared != nil {
|
||||
return []platformDelivery{{Group: *batch.prepared, Batch: batch}}
|
||||
}
|
||||
deliveries := resolveTerminalPlatformDeliveries(ctx, batch)
|
||||
appendEnterpriseWechatDeliveries(ctx, batch, &deliveries)
|
||||
if len(deliveries) == 0 {
|
||||
g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no eligible recipients, tenantId:%s, count:%d", batch.TenantID, batchItemCount(batch))
|
||||
}
|
||||
return deliveries
|
||||
}
|
||||
|
||||
func appendEnterpriseWechatDeliveries(ctx context.Context, batch Batch, deliveries *[]platformDelivery) {
|
||||
if batch.Business != BusinessSimWarning {
|
||||
return
|
||||
}
|
||||
itemsByUser := make(map[int64][]Event)
|
||||
seenByUser := make(map[int64]map[int]struct{})
|
||||
for index, item := range batch.Items {
|
||||
if item.ItemType != 1 {
|
||||
continue
|
||||
}
|
||||
createdBy := item.CreatedBy
|
||||
if createdBy <= 0 {
|
||||
createdBy = batch.CreatedBy
|
||||
}
|
||||
if createdBy <= 0 || item.ItemID <= 0 {
|
||||
continue
|
||||
}
|
||||
userID, ok, err := queryEnterpriseWarningUser(ctx, batch.TenantID, createdBy, item.ItemID)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "WarningNotify query enterprise user failed, tenantId:%s, userId:%d, cardId:%d, err:%+v", batch.TenantID, createdBy, item.ItemID, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
appendTerminalEvent(itemsByUser, seenByUser, userID, index, item)
|
||||
}
|
||||
}
|
||||
userIDs := make([]int64, 0, len(itemsByUser))
|
||||
for userID := range itemsByUser {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
||||
for _, userID := range userIDs {
|
||||
items := itemsByUser[userID]
|
||||
enterpriseBatch := batch
|
||||
enterpriseBatch.Items = append([]Event(nil), items...)
|
||||
enterpriseBatch.WarnStage = highestStage(enterpriseBatch.Items)
|
||||
*deliveries = append(*deliveries, platformDelivery{
|
||||
Group: platformRecipientGroup{SendUserType: sendUserTypeAgent, UserIDs: []int64{userID}},
|
||||
Batch: enterpriseBatch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func enterpriseWarningRecipientEligible(userType string, userDeptID, cardDeptID int64, ancestors string) bool {
|
||||
if userType != consts.SysUserTypeSys || userDeptID <= 0 || cardDeptID <= 0 {
|
||||
return false
|
||||
}
|
||||
if userDeptID == cardDeptID {
|
||||
return true
|
||||
}
|
||||
target := strconv.FormatInt(userDeptID, 10)
|
||||
for _, ancestor := range strings.Split(ancestors, ",") {
|
||||
if strings.TrimSpace(ancestor) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func queryEnterpriseWarningUser(ctx context.Context, tenantID string, userID, cardID int64) (int64, bool, error) {
|
||||
if strings.TrimSpace(tenantID) == "" || userID <= 0 || cardID <= 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
var result struct {
|
||||
UserID int64 `orm:"user_id"`
|
||||
UserType string `orm:"user_type"`
|
||||
DeptID int64 `orm:"dept_id"`
|
||||
CardDeptID int64 `orm:"card_dept_id"`
|
||||
Ancestors string `orm:"ancestors"`
|
||||
}
|
||||
err := g.DB().Model(dao.TSimCardInfo.Table()+" c").Ctx(ctx).Safe().
|
||||
LeftJoin(dao.SysUser.Table()+" u", "u.user_id = "+strconv.FormatInt(userID, 10)).
|
||||
InnerJoin(dao.SysDept.Table()+" d", "d.dept_id = c.dept_id AND d.tenant_id = c.tenant_id").
|
||||
InnerJoin(dao.SysDept.Table()+" ud", "ud.dept_id = u.dept_id AND ud.tenant_id = u.tenant_id").
|
||||
Fields("u.user_id, u.user_type, u.dept_id, c.dept_id AS card_dept_id, d.ancestors").
|
||||
Where("c.tenant_id", tenantID).
|
||||
Where("c.id", cardID).
|
||||
Where("u.tenant_id", tenantID).
|
||||
Where("u.user_type", consts.SysUserTypeSys).
|
||||
Where("u.status", consts.SysDeptStatusNormal).
|
||||
Where("u.deleted_at IS NULL").
|
||||
Where("d.deleted_at IS NULL").
|
||||
Where("ud.deleted_at IS NULL").
|
||||
Scan(&result)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return result.UserID, result.UserID > 0 && enterpriseWarningRecipientEligible(result.UserType, result.DeptID, result.CardDeptID, result.Ancestors), nil
|
||||
}
|
||||
|
||||
func loadWarningWechatConfig(ctx context.Context) (warningWechatConfig, bool) {
|
||||
var cfg warningWechatConfig
|
||||
loaded := false
|
||||
value, err := g.Cfg().Get(ctx, "warning.wechat")
|
||||
if err == nil && !value.IsNil() {
|
||||
_ = value.Scan(&cfg)
|
||||
loaded = true
|
||||
}
|
||||
|
||||
if envValue := strings.TrimSpace(os.Getenv("IOTLCM_WARNING_WECHAT_ENABLED")); envValue != "" {
|
||||
if enabled, parseErr := strconv.ParseBool(envValue); parseErr == nil {
|
||||
cfg.Enabled = enabled
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
loaded = applyWechatEnv(&cfg.AppID, "IOTLCM_WARNING_WECHAT_APP_ID") || loaded
|
||||
loaded = applyWechatEnv(&cfg.AppSecret, "IOTLCM_WARNING_WECHAT_APP_SECRET") || loaded
|
||||
loaded = applyWechatEnv(&cfg.TemplateID, "IOTLCM_WARNING_WECHAT_TEMPLATE_ID") || loaded
|
||||
loaded = applyWechatEnv(&cfg.Page, "IOTLCM_WARNING_WECHAT_PAGE") || loaded
|
||||
loaded = applyWechatEnv(&cfg.Timeout, "IOTLCM_WARNING_WECHAT_TIMEOUT") || loaded
|
||||
|
||||
cfg.AppID = strings.TrimSpace(cfg.AppID)
|
||||
cfg.AppSecret = strings.TrimSpace(cfg.AppSecret)
|
||||
cfg.TemplateID = strings.TrimSpace(cfg.TemplateID)
|
||||
cfg.Page = strings.TrimSpace(cfg.Page)
|
||||
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
|
||||
if cfg.TemplateID == "" {
|
||||
cfg.TemplateID = consts.WarningWechatTemplateIDDefault
|
||||
}
|
||||
if cfg.Page == "" {
|
||||
cfg.Page = warningWechatDefaultPage
|
||||
}
|
||||
return cfg, loaded
|
||||
}
|
||||
|
||||
func applyWechatEnv(target *string, key string) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
*target = value
|
||||
return true
|
||||
}
|
||||
|
||||
func wechatConfigSkipReason(loaded bool, cfg warningWechatConfig) string {
|
||||
if !loaded {
|
||||
return "warning.wechat config not found"
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return "warning.wechat disabled"
|
||||
}
|
||||
if cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return "warning.wechat app credentials incomplete"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func queryWechatBindings(ctx context.Context, tenantID string, userID int64) ([]warningWechatBinding, error) {
|
||||
if strings.TrimSpace(tenantID) == "" || userID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.SysSocial.Columns()
|
||||
rows := make([]warningWechatBinding, 0)
|
||||
err := dao.SysSocial.Ctx(ctx).
|
||||
Fields(columns.UserId, columns.OpenId).
|
||||
Where(columns.TenantId, strings.TrimSpace(tenantID)).
|
||||
Where(columns.UserId, userID).
|
||||
Where(columns.Source, consts.SourceWechatMp).
|
||||
Where("open_id <> ?", "").
|
||||
OrderAsc(columns.Id).
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
result := make([]warningWechatBinding, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
row.OpenID = strings.TrimSpace(row.OpenID)
|
||||
if row.OpenID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[row.OpenID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[row.OpenID] = struct{}{}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func acquireWechatSendDedupe(ctx context.Context, batch Batch, userID int64) (bool, string, error) {
|
||||
key := wechatSendDedupeKey(batch, userID)
|
||||
if key == "" {
|
||||
return false, "", fmt.Errorf("wechat send dedupe key is empty")
|
||||
}
|
||||
ttl := wechatSendDedupeTTL(batch)
|
||||
ok, err := cache.Instance().SetIfNotExist(ctx, key, "1", ttl)
|
||||
return ok, key, err
|
||||
}
|
||||
|
||||
func releaseWechatSendDedupe(ctx context.Context, key string) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
_, _ = cache.Instance().Remove(ctx, key)
|
||||
}
|
||||
|
||||
func wechatSendDedupeKey(batch Batch, userID int64) string {
|
||||
tenantID := strings.TrimSpace(batch.TenantID)
|
||||
if tenantID == "" || userID <= 0 {
|
||||
return ""
|
||||
}
|
||||
business := strings.TrimSpace(batch.Business)
|
||||
if business == "" {
|
||||
business = BusinessSimWarning
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s:%s:%d:%s:%s:%s",
|
||||
warningWechatSentKeyPrefix,
|
||||
tenantID,
|
||||
userID,
|
||||
wechatSafeKeyPart(business),
|
||||
wechatDedupeCategoryKey(batch),
|
||||
wechatDedupePeriodKey(batch),
|
||||
)
|
||||
}
|
||||
|
||||
func wechatSendDedupeTTL(batch Batch) time.Duration {
|
||||
now := wechatBatchTime(batch)
|
||||
if wechatBatchHasTrafficKind(batch) {
|
||||
nextMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0)
|
||||
if ttl := nextMonth.Sub(now); ttl > 0 {
|
||||
return ttl
|
||||
}
|
||||
return warningWechatMinSentTTL
|
||||
}
|
||||
if wechatBatchHasExpireKind(batch) {
|
||||
return 36 * time.Hour
|
||||
}
|
||||
return warningWechatMinSentTTL
|
||||
}
|
||||
|
||||
func wechatDedupePeriodKey(batch Batch) string {
|
||||
now := wechatBatchTime(batch)
|
||||
if wechatBatchHasTrafficKind(batch) {
|
||||
return "m" + now.Format("200601")
|
||||
}
|
||||
if wechatBatchHasExpireKind(batch) {
|
||||
return fmt.Sprintf("d%s:s%d", now.Format("20060102"), batch.WarnStage)
|
||||
}
|
||||
return "d" + now.Format("20060102")
|
||||
}
|
||||
|
||||
func wechatDedupeCategoryKey(batch Batch) string {
|
||||
switch wechatPrimaryKind(batch) {
|
||||
case "traffic_mb", "traffic_percent":
|
||||
return "card_traffic"
|
||||
case "pool_traffic_percent":
|
||||
return "flow_pool"
|
||||
case "expire_day":
|
||||
return "expire_day"
|
||||
case "expire_month":
|
||||
return "expire_month"
|
||||
}
|
||||
category := strings.TrimSpace(batch.Category)
|
||||
if category == "" {
|
||||
category = "warning"
|
||||
}
|
||||
return "cat_" + sha256Hex(category)
|
||||
}
|
||||
|
||||
func wechatPrimaryKind(batch Batch) string {
|
||||
for _, item := range batch.Items {
|
||||
kind := strings.TrimSpace(item.Kind)
|
||||
if kind != "" {
|
||||
return kind
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wechatBatchHasTrafficKind(batch Batch) bool {
|
||||
for _, item := range batch.Items {
|
||||
switch strings.TrimSpace(item.Kind) {
|
||||
case "traffic_mb", "traffic_percent", "pool_traffic_percent":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wechatBatchHasExpireKind(batch Batch) bool {
|
||||
for _, item := range batch.Items {
|
||||
switch strings.TrimSpace(item.Kind) {
|
||||
case "expire_day", "expire_month":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wechatBatchTime(batch Batch) time.Time {
|
||||
latest := latestEvent(batch.Items)
|
||||
if !batch.LatestTime.IsZero() {
|
||||
latest.WarnTime = batch.LatestTime
|
||||
}
|
||||
if latest.WarnTime.IsZero() {
|
||||
return time.Now()
|
||||
}
|
||||
return latest.WarnTime
|
||||
}
|
||||
|
||||
func wechatSafeKeyPart(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "na"
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, r := range value {
|
||||
if r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_' || r == '-' {
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return sha256Hex(value)
|
||||
}
|
||||
return strings.ToLower(builder.String())
|
||||
}
|
||||
|
||||
func wechatTemplateID(items []Event, fallback string) string {
|
||||
for _, item := range items {
|
||||
if value := strings.TrimSpace(item.WxTemplateID); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func buildWechatPayload(batch Batch, openID string, templateID string, page string) warningWechatPayload {
|
||||
latest := latestEvent(batch.Items)
|
||||
if !batch.LatestTime.IsZero() {
|
||||
latest.WarnTime = batch.LatestTime
|
||||
}
|
||||
return warningWechatPayload{
|
||||
ToUser: strings.TrimSpace(openID),
|
||||
TemplateID: strings.TrimSpace(templateID),
|
||||
Page: strings.TrimSpace(page),
|
||||
Data: map[string]warningWechatTemplateValue{
|
||||
"character_string6": {Value: wechatCharacterString(wechatDeviceCharacterText(batch), 32)},
|
||||
"thing7": {Value: truncateWechatText(wechatTypeText(batch), 20)},
|
||||
"thing9": {Value: truncateWechatText(wechatReasonText(batch), 20)},
|
||||
"character_string32": {Value: wechatCharacterString(wechatValueText(batch), 32)},
|
||||
"time8": {Value: wechatTimeText(latest)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func latestEvent(items []Event) Event {
|
||||
var result Event
|
||||
for _, item := range items {
|
||||
if result.WarnTime.IsZero() || item.WarnTime.After(result.WarnTime) {
|
||||
result = item
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func wechatDeviceText(batch Batch) string {
|
||||
if batchItemCount(batch) == 0 {
|
||||
return "--"
|
||||
}
|
||||
first := displayObjectCode(batch.Items[0])
|
||||
if batchItemCount(batch) == 1 {
|
||||
return first
|
||||
}
|
||||
return fmt.Sprintf("%s等%d张卡", first, batchItemCount(batch))
|
||||
}
|
||||
|
||||
func wechatDeviceCharacterText(batch Batch) string {
|
||||
if batchItemCount(batch) == 0 {
|
||||
return "NA"
|
||||
}
|
||||
first := displayObjectCode(batch.Items[0])
|
||||
if batchItemCount(batch) == 1 {
|
||||
return first
|
||||
}
|
||||
return fmt.Sprintf("TOTAL-%d", batchItemCount(batch))
|
||||
}
|
||||
|
||||
func wechatTypeText(batch Batch) string {
|
||||
title := warningTitle(batch)
|
||||
if title == "" {
|
||||
title = "设备预警"
|
||||
}
|
||||
if batchItemCount(batch) > 1 {
|
||||
return fmt.Sprintf("%s,共%d张卡", title, batchItemCount(batch))
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func wechatReasonText(batch Batch) string {
|
||||
if batchItemCount(batch) == 1 {
|
||||
return emailWarningReason(batch.Items[0])
|
||||
}
|
||||
reason := fmt.Sprintf("%s\uff0c\u5171%d\u5f20\u5361", levelText(batchHighestLevel(batch)), batchItemCount(batch))
|
||||
if batchHasAffectOperation(batch) {
|
||||
reason += ",涉及停机或断网"
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
func wechatValueText(batch Batch) string {
|
||||
if batchItemCount(batch) > 1 {
|
||||
return fmt.Sprintf("TOTAL-%d", batchItemCount(batch))
|
||||
}
|
||||
if batchItemCount(batch) == 0 {
|
||||
return "NA"
|
||||
}
|
||||
item := batch.Items[0]
|
||||
unit := strings.TrimSpace(item.UnitText)
|
||||
switch unit {
|
||||
case "天":
|
||||
return fmt.Sprintf("%.0fd", item.Actual)
|
||||
case "月":
|
||||
return fmt.Sprintf("%.0fm", item.Actual)
|
||||
case "%":
|
||||
return fmt.Sprintf("%.2f%%", item.Actual)
|
||||
case "MB":
|
||||
if item.TotalFlowMB > 0 {
|
||||
return fmt.Sprintf("%.3f/%.3fMB", item.Actual, item.TotalFlowMB)
|
||||
}
|
||||
return fmt.Sprintf("%.3fMB", item.Actual)
|
||||
default:
|
||||
if unit == "" {
|
||||
return fmt.Sprintf("%.3f", item.Actual)
|
||||
}
|
||||
return fmt.Sprintf("%.3f%s", item.Actual, unit)
|
||||
}
|
||||
}
|
||||
|
||||
func wechatTimeText(item Event) string {
|
||||
if item.WarnTime.IsZero() {
|
||||
return gtime.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return item.WarnTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func sendWechatSubscription(ctx context.Context, cfg warningWechatConfig, payload warningWechatPayload) error {
|
||||
return sendWechatSubscriptionAttempt(ctx, cfg, payload, true)
|
||||
}
|
||||
|
||||
type warningWechatAPIError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (err *warningWechatAPIError) Error() string {
|
||||
return fmt.Sprintf("wechat subscribe code %d: %s", err.Code, err.Message)
|
||||
}
|
||||
|
||||
func warningWechatTokenInvalid(code int) bool {
|
||||
return code == 40001 || code == 40014 || code == 42001
|
||||
}
|
||||
|
||||
func sendWechatSubscriptionAttempt(ctx context.Context, cfg warningWechatConfig, payload warningWechatPayload, refreshToken bool) error {
|
||||
accessToken, err := warningWechatAccessToken(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("access_token", accessToken)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timeout := warningWechatTimeout(cfg)
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, warningWechatSendURL+"?"+query.Encode(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: timeout}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result warningWechatSendResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("wechat subscribe http status %d: %s", resp.StatusCode, result.ErrMsg)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
if refreshToken && warningWechatTokenInvalid(result.ErrCode) {
|
||||
cacheKey := "iotlcm:warning:wechat:access_token:" + sha256Hex(cfg.AppID)
|
||||
if _, err = g.Redis().Do(ctx, "DEL", cacheKey); err != nil {
|
||||
return fmt.Errorf("invalidate wechat access token: %w", err)
|
||||
}
|
||||
return sendWechatSubscriptionAttempt(ctx, cfg, payload, false)
|
||||
}
|
||||
return &warningWechatAPIError{Code: result.ErrCode, Message: result.ErrMsg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func warningWechatAccessToken(ctx context.Context, cfg warningWechatConfig) (string, error) {
|
||||
cacheKey := "iotlcm:warning:wechat:access_token:" + sha256Hex(cfg.AppID)
|
||||
if value, err := g.Redis().Get(ctx, cacheKey); err == nil && strings.TrimSpace(value.String()) != "" {
|
||||
return strings.TrimSpace(value.String()), nil
|
||||
}
|
||||
|
||||
warningWechatTokenMu.Lock()
|
||||
defer warningWechatTokenMu.Unlock()
|
||||
if value, err := g.Redis().Get(ctx, cacheKey); err == nil && strings.TrimSpace(value.String()) != "" {
|
||||
return strings.TrimSpace(value.String()), nil
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("grant_type", "client_credential")
|
||||
query.Set("appid", cfg.AppID)
|
||||
query.Set("secret", cfg.AppSecret)
|
||||
timeout := warningWechatTimeout(cfg)
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, warningWechatAccessTokenURL+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: timeout}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result warningWechatAccessTokenResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", fmt.Errorf("wechat access token http status %d: %s", resp.StatusCode, result.ErrMsg)
|
||||
}
|
||||
if result.ErrCode != 0 || strings.TrimSpace(result.AccessToken) == "" {
|
||||
return "", fmt.Errorf("wechat access token code %d: %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
ttl := result.ExpiresIn - 300
|
||||
if ttl <= 0 {
|
||||
ttl = 300
|
||||
}
|
||||
if _, err = g.Redis().Do(ctx, "SETEX", cacheKey, ttl, result.AccessToken); err != nil {
|
||||
g.Log().Warningf(ctx, "WarningNotify cache wechat access token failed, err:%+v", err)
|
||||
}
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
func warningWechatTimeout(cfg warningWechatConfig) time.Duration {
|
||||
if value := strings.TrimSpace(cfg.Timeout); value != "" {
|
||||
if timeout, err := time.ParseDuration(value); err == nil && timeout > 0 {
|
||||
return timeout
|
||||
}
|
||||
}
|
||||
return warningWechatDefaultTimeout
|
||||
}
|
||||
|
||||
func maskOpenID(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(value) <= 8 {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:4]) + "..." + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func sha256Hex(value string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
func truncateWechatText(value string, maxRunes int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxRunes-1]) + "…"
|
||||
}
|
||||
|
||||
func wechatCharacterString(value string, maxRunes int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || maxRunes <= 0 {
|
||||
return "NA"
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, r := range value {
|
||||
if r >= 33 && r <= 126 {
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
result := builder.String()
|
||||
if result == "" {
|
||||
return "NA"
|
||||
}
|
||||
runes := []rune(result)
|
||||
if len(runes) <= maxRunes {
|
||||
return result
|
||||
}
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
Reference in New Issue
Block a user