|
|
@@ -12,24 +12,120 @@ import (
|
|
|
"fwh-toolbox-server/repositories/mongo"
|
|
|
"net/http"
|
|
|
"net/url"
|
|
|
+ "sort"
|
|
|
"strconv"
|
|
|
+ "strings"
|
|
|
"time"
|
|
|
|
|
|
"github.com/hibiken/asynq"
|
|
|
+ "go.uber.org/zap"
|
|
|
)
|
|
|
|
|
|
const taskTypeSimCardDailyReport = "SimCard:DailyReport"
|
|
|
|
|
|
-func calculateSimCardStatistics(date time.Time) (domain.SimCardDailyStatistics, error) {
|
|
|
+const (
|
|
|
+ simCardHeavyUsageKB = 100 * 1024 // 进入日报清单的日用量阈值(严格大于)
|
|
|
+ simCardHeavyUsageBoldKB = 500 * 1024 // 清单中加粗显示的日用量阈值(严格大于)
|
|
|
+ simCardHeavyUsageMaxRows = 30 // 清单最多列出的行数,避免超过钉钉消息长度上限
|
|
|
+)
|
|
|
+
|
|
|
+// simCardHeavyUsage 是日用量超过阈值的一张卡;SN 为空表示没有关联站点。
|
|
|
+type simCardHeavyUsage struct {
|
|
|
+ ICCID string
|
|
|
+ IncrementKB float64
|
|
|
+ SN string
|
|
|
+ Version string
|
|
|
+}
|
|
|
+
|
|
|
+type simCardDailyReport struct {
|
|
|
+ Stats domain.SimCardDailyStatistics
|
|
|
+ HeavyCount int // 超过阈值的卡总数
|
|
|
+ Heavy []simCardHeavyUsage // 按日用量降序,最多 simCardHeavyUsageMaxRows 条
|
|
|
+ SiteLookupFailed bool
|
|
|
+}
|
|
|
+
|
|
|
+func calculateSimCardStatistics(date time.Time) (domain.SimCardDailyStatistics, []simCardHeavyUsage, error) {
|
|
|
stats := domain.SimCardDailyStatistics{Date: date.Format(time.DateOnly)}
|
|
|
- err := mongo.StreamSimCardDailyUsage(date, func(total float64, previous *float64) error {
|
|
|
- accumulateSimCardStatistics(&stats, total, previous)
|
|
|
+ var heavy []simCardHeavyUsage
|
|
|
+ err := mongo.StreamSimCardDailyUsage(date, func(simId string, total float64, previous *float64) error {
|
|
|
+ if increment, ok := accumulateSimCardStatistics(&stats, total, previous); ok && increment > simCardHeavyUsageKB {
|
|
|
+ heavy = append(heavy, simCardHeavyUsage{ICCID: simId, IncrementKB: increment})
|
|
|
+ }
|
|
|
return nil
|
|
|
})
|
|
|
- return stats, err
|
|
|
+ return stats, heavy, err
|
|
|
+}
|
|
|
+
|
|
|
+// buildSimCardDailyReport 排序截断清单并补充站点信息;站点查询失败不影响统计部分。
|
|
|
+func buildSimCardDailyReport(stats domain.SimCardDailyStatistics, heavy []simCardHeavyUsage,
|
|
|
+ lookup func(giccids []string) ([]domain.SiteInfoDomain, error)) simCardDailyReport {
|
|
|
+ sort.SliceStable(heavy, func(i, j int) bool { return heavy[i].IncrementKB > heavy[j].IncrementKB })
|
|
|
+ report := simCardDailyReport{Stats: stats, HeavyCount: len(heavy)}
|
|
|
+ if len(heavy) > simCardHeavyUsageMaxRows {
|
|
|
+ heavy = heavy[:simCardHeavyUsageMaxRows]
|
|
|
+ }
|
|
|
+ report.Heavy = heavy
|
|
|
+ if len(heavy) == 0 {
|
|
|
+ return report
|
|
|
+ }
|
|
|
+ ids := make([]string, 0, len(heavy))
|
|
|
+ for _, h := range heavy {
|
|
|
+ ids = append(ids, h.ICCID)
|
|
|
+ }
|
|
|
+ sites, err := lookup(ids)
|
|
|
+ if err != nil {
|
|
|
+ zap.L().Error("SIM card daily report: site lookup failed", zap.String("date", stats.Date), zap.Error(err))
|
|
|
+ report.SiteLookupFailed = true
|
|
|
+ return report
|
|
|
+ }
|
|
|
+ for i := range report.Heavy {
|
|
|
+ var candidates []domain.SiteInfoDomain
|
|
|
+ for _, site := range sites {
|
|
|
+ if report.Heavy[i].ICCID != "" && strings.Contains(strings.ToUpper(site.ICCID), strings.ToUpper(report.Heavy[i].ICCID)) {
|
|
|
+ candidates = append(candidates, site)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if site := pickSimCardSite(candidates); site != nil {
|
|
|
+ report.Heavy[i].SN = site.Sn
|
|
|
+ report.Heavy[i].Version = site.Version
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return report
|
|
|
+}
|
|
|
+
|
|
|
+// pickSimCardSite 在一张卡关联的多个站点中选一个:优先在线站点(多个在线取最后上线最晚的),
|
|
|
+// 都不在线时取最后离线时间最接近现在的。时间格式为 YYYY-MM-DD HH:mm:SS,可直接按字符串比较。
|
|
|
+func pickSimCardSite(sites []domain.SiteInfoDomain) *domain.SiteInfoDomain {
|
|
|
+ var best *domain.SiteInfoDomain
|
|
|
+ for i := range sites {
|
|
|
+ site := &sites[i]
|
|
|
+ if best == nil || simCardSiteBetter(site, best) {
|
|
|
+ best = site
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return best
|
|
|
+}
|
|
|
+
|
|
|
+func simCardSiteBetter(a, b *domain.SiteInfoDomain) bool {
|
|
|
+ aOnline, bOnline := a.Status == 1, b.Status == 1
|
|
|
+ if aOnline != bOnline {
|
|
|
+ return aOnline
|
|
|
+ }
|
|
|
+ if aOnline {
|
|
|
+ return derefString(a.OnLineTime) > derefString(b.OnLineTime)
|
|
|
+ }
|
|
|
+ return derefString(a.OffLineTime) > derefString(b.OffLineTime)
|
|
|
}
|
|
|
|
|
|
-func accumulateSimCardStatistics(stats *domain.SimCardDailyStatistics, total float64, previous *float64) {
|
|
|
+func derefString(s *string) string {
|
|
|
+ if s == nil {
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+ return *s
|
|
|
+}
|
|
|
+
|
|
|
+// accumulateSimCardStatistics 返回该卡的日增量;没有前一日数据时 ok 为 false。
|
|
|
+func accumulateSimCardStatistics(stats *domain.SimCardDailyStatistics, total float64, previous *float64) (increment float64, ok bool) {
|
|
|
stats.CardCount++
|
|
|
if total > stats.MaxTotalKB {
|
|
|
stats.MaxTotalKB = total
|
|
|
@@ -44,9 +140,9 @@ func accumulateSimCardStatistics(stats *domain.SimCardDailyStatistics, total flo
|
|
|
stats.TotalOver2GB++
|
|
|
}
|
|
|
if previous == nil {
|
|
|
- return
|
|
|
+ return 0, false
|
|
|
}
|
|
|
- increment := total - *previous
|
|
|
+ increment = total - *previous
|
|
|
// Monthly counters can reset at a cycle boundary. The new counter then
|
|
|
// represents the usage accumulated since the reset.
|
|
|
if increment < 0 {
|
|
|
@@ -65,6 +161,7 @@ func accumulateSimCardStatistics(stats *domain.SimCardDailyStatistics, total flo
|
|
|
if increment > 200*1024 {
|
|
|
stats.IncrementOver200MB++
|
|
|
}
|
|
|
+ return increment, true
|
|
|
}
|
|
|
|
|
|
func (processor *SimCardProcessor) enqueueSimCardReportIfComplete(date string) error {
|
|
|
@@ -97,19 +194,58 @@ func (processor *SimCardProcessor) handleSimCardDailyReport(ctx context.Context,
|
|
|
if err != nil || !complete {
|
|
|
return err
|
|
|
}
|
|
|
- stats, err := calculateSimCardStatistics(date)
|
|
|
+ stats, heavy, err := calculateSimCardStatistics(date)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
if err := mongo.SaveSimCardDailyStatistics(stats); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
- if err := sendSimCardStatistics(ctx, processor.webhook, processor.secret, stats); err != nil {
|
|
|
+ report := buildSimCardDailyReport(stats, heavy, mongo.GetSiteInfoListFromGiccids)
|
|
|
+ if err := sendSimCardStatistics(ctx, processor.webhook, processor.secret, report); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
return mongo.MarkSimCardReportSent(dateStr)
|
|
|
}
|
|
|
|
|
|
+func formatSimCardDailyReport(report simCardDailyReport) string {
|
|
|
+ var b strings.Builder
|
|
|
+ b.WriteString(formatSimCardStatistics(report.Stats))
|
|
|
+ fmt.Fprintf(&b, "\n\n日用量超过 %d MB 的站点(%d 张卡,SN · 日用量 · 固件版本):\n\n",
|
|
|
+ simCardHeavyUsageKB/1024, report.HeavyCount)
|
|
|
+ if report.HeavyCount == 0 {
|
|
|
+ b.WriteString("- 无")
|
|
|
+ return b.String()
|
|
|
+ }
|
|
|
+ if report.SiteLookupFailed {
|
|
|
+ b.WriteString("> 站点信息查询失败,以下仅列出 ICCID\n\n")
|
|
|
+ }
|
|
|
+ for i, h := range report.Heavy {
|
|
|
+ name, version := h.SN, h.Version
|
|
|
+ if name == "" {
|
|
|
+ name = "ICCID " + h.ICCID
|
|
|
+ if !report.SiteLookupFailed {
|
|
|
+ name += "(未绑定站点)"
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if version == "" {
|
|
|
+ version = "-"
|
|
|
+ }
|
|
|
+ line := fmt.Sprintf("%s · %.2f MB · %s", name, h.IncrementKB/1024, version)
|
|
|
+ if h.IncrementKB > simCardHeavyUsageBoldKB {
|
|
|
+ line = "**" + line + "**"
|
|
|
+ }
|
|
|
+ if i > 0 {
|
|
|
+ b.WriteString("\n")
|
|
|
+ }
|
|
|
+ b.WriteString("- " + line)
|
|
|
+ }
|
|
|
+ if rest := report.HeavyCount - len(report.Heavy); rest > 0 {
|
|
|
+ fmt.Fprintf(&b, "\n- 其余 %d 张省略", rest)
|
|
|
+ }
|
|
|
+ return b.String()
|
|
|
+}
|
|
|
+
|
|
|
func formatSimCardStatistics(stats domain.SimCardDailyStatistics) string {
|
|
|
maxIncrement := "无可比数据"
|
|
|
if stats.MaxIncrementKB != nil {
|
|
|
@@ -125,11 +261,11 @@ func formatSimCardStatistics(stats domain.SimCardDailyStatistics) string {
|
|
|
stats.IncrementOver50MB, stats.IncrementOver200MB, maxIncrement)
|
|
|
}
|
|
|
|
|
|
-func sendSimCardStatistics(ctx context.Context, webhook, secret string, stats domain.SimCardDailyStatistics) error {
|
|
|
- return postSimCardStatistics(ctx, &http.Client{Timeout: 10 * time.Second}, webhook, secret, stats)
|
|
|
+func sendSimCardStatistics(ctx context.Context, webhook, secret string, report simCardDailyReport) error {
|
|
|
+ return postSimCardStatistics(ctx, &http.Client{Timeout: 10 * time.Second}, webhook, secret, report)
|
|
|
}
|
|
|
|
|
|
-func postSimCardStatistics(ctx context.Context, client *http.Client, webhook, secret string, stats domain.SimCardDailyStatistics) error {
|
|
|
+func postSimCardStatistics(ctx context.Context, client *http.Client, webhook, secret string, report simCardDailyReport) error {
|
|
|
if webhook == "" {
|
|
|
return fmt.Errorf("SIM_CARD_DINGTALK_WEBHOOK is not configured")
|
|
|
}
|
|
|
@@ -147,8 +283,8 @@ func postSimCardStatistics(ctx context.Context, client *http.Client, webhook, se
|
|
|
u.RawQuery = query.Encode()
|
|
|
}
|
|
|
message := map[string]any{"msgtype": "markdown", "markdown": map[string]string{
|
|
|
- "title": "SIM 卡流量日报 " + stats.Date,
|
|
|
- "text": formatSimCardStatistics(stats),
|
|
|
+ "title": "SIM 卡流量日报 " + report.Stats.Date,
|
|
|
+ "text": formatSimCardDailyReport(report),
|
|
|
}}
|
|
|
body, err := json.Marshal(message)
|
|
|
if err != nil {
|