Bladeren bron

feat:SIM卡流量日报增加日用量超过100MB的站点清单(SN、日用量、固件版本)

- 超过500MB整行加粗,最多列30条,其余注明省略条数
- 未绑定站点显示ICCID;一卡多站点优先取在线设备,都离线取最后离线时间最近的
- 站点信息查询失败时照常发送统计部分,清单注明查询失败

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 13 uur geleden
bovenliggende
commit
6a0bd508ea

+ 3 - 2
service/repositories/mongo/simCardStatisticsRepo.go

@@ -98,7 +98,7 @@ func simCardRunComplete(run simCardRun) bool {
 
 // StreamSimCardDailyUsage returns one snapshot per SIM for the UTC date.
 // Missing previous-day snapshots are represented by nil and excluded from increments.
-func StreamSimCardDailyUsage(date time.Time, consume func(total float64, previous *float64) error) error {
+func StreamSimCardDailyUsage(date time.Time, consume func(simId string, total float64, previous *float64) error) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
 	defer cancel()
 	month := date.Format("2006-01")
@@ -136,6 +136,7 @@ func StreamSimCardDailyUsage(date time.Time, consume func(total float64, previou
 	defer cursor.Close(ctx)
 	for cursor.Next(ctx) {
 		var row struct {
+			SimId    string   `bson:"_id"`
 			Total    *float64 `bson:"total"`
 			Previous *float64 `bson:"previous"`
 		}
@@ -143,7 +144,7 @@ func StreamSimCardDailyUsage(date time.Time, consume func(total float64, previou
 			return err
 		}
 		if row.Total != nil {
-			if err := consume(*row.Total, row.Previous); err != nil {
+			if err := consume(row.SimId, *row.Total, row.Previous); err != nil {
 				return err
 			}
 		}

+ 33 - 0
service/repositories/mongo/siteInfoManageRepo.go

@@ -7,6 +7,7 @@ import (
 	"fwh-toolbox-server/database"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
+	"regexp"
 	"time"
 
 	"go.mongodb.org/mongo-driver/bson"
@@ -338,6 +339,38 @@ func GetSiteInfoManageListFromGiccid(giccid string) ([]domain.SiteInfoDomain, er
 	return infoList, nil
 }
 
+// GetSiteInfoListFromGiccids 一次查询多张 SIM 卡关联的站点,只返回 SN(_id)、iccId、version、status、
+// onLineTime、offLineTime。匹配规则与 GetSiteInfoManageListFromGiccid 一致(iccId 包含 GICCID),
+// 调用方用 strings.Contains(site.ICCID, giccid) 把站点归到对应的卡。
+func GetSiteInfoListFromGiccids(giccids []string) ([]domain.SiteInfoDomain, error) {
+	patterns := make(bson.A, 0, len(giccids))
+	for _, id := range giccids {
+		if id != "" {
+			patterns = append(patterns, primitive.Regex{Pattern: regexp.QuoteMeta(id), Options: "i"})
+		}
+	}
+	if len(patterns) == 0 {
+		return nil, nil
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	projection := bson.M{"iccId": 1, "version": 1, "status": 1, "onLineTime": 1, "offLineTime": 1}
+	cursor, err := database.GetCollection(CollectionName_SiteInfoManageData).Find(ctx,
+		bson.M{"iccId": bson.M{"$in": patterns}}, options.Find().SetProjection(projection))
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+
+	var infoList []domain.SiteInfoDomain
+	if err := cursor.All(ctx, &infoList); err != nil {
+		return nil, err
+	}
+	return infoList, nil
+}
+
 // 插入站点信息数据
 func InsertSiteInfoData(data domain.SiteInfoDomain) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

+ 150 - 14
service/task/queue/simCardQueue/simCardStatistics.go

@@ -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 {

+ 110 - 3
service/task/queue/simCardQueue/simCardStatistics_test.go

@@ -3,6 +3,8 @@ package simCardQueue
 import (
 	"context"
 	"encoding/json"
+	"errors"
+	"fmt"
 	"fwh-toolbox-server/domain"
 	"io"
 	"net/http"
@@ -64,8 +66,8 @@ func TestPostSimCardStatistics(t *testing.T) {
 		}
 		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"errcode":0,"errmsg":"ok"}`))}, nil
 	})}
-	stats := domain.SimCardDailyStatistics{Date: "2026-09-24"}
-	if err := postSimCardStatistics(context.Background(), client, "https://oapi.dingtalk.com/robot/send?access_token=test", "secret", stats); err != nil {
+	report := simCardDailyReport{Stats: domain.SimCardDailyStatistics{Date: "2026-09-24"}}
+	if err := postSimCardStatistics(context.Background(), client, "https://oapi.dingtalk.com/robot/send?access_token=test", "secret", report); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -74,7 +76,7 @@ func TestPostSimCardStatisticsRejectsBotError(t *testing.T) {
 	client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
 		return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"errcode":310000,"errmsg":"rejected"}`))}, nil
 	})}
-	if err := postSimCardStatistics(context.Background(), client, "https://oapi.dingtalk.com/robot/send", "", domain.SimCardDailyStatistics{}); err == nil {
+	if err := postSimCardStatistics(context.Background(), client, "https://oapi.dingtalk.com/robot/send", "", simCardDailyReport{}); err == nil {
 		t.Fatal("DingTalk error response must be retried")
 	}
 }
@@ -105,3 +107,108 @@ func TestAccumulateSimCardStatisticsCounterReset(t *testing.T) {
 		t.Fatalf("reset counter should use current accumulated usage: %+v", stats)
 	}
 }
+
+func TestAccumulateSimCardStatisticsReturnsIncrement(t *testing.T) {
+	stats := domain.SimCardDailyStatistics{}
+	previous := 10.0 * 1024
+	if inc, ok := accumulateSimCardStatistics(&stats, 150*1024, &previous); !ok || inc != 140*1024 {
+		t.Fatalf("unexpected increment: %v %v", inc, ok)
+	}
+	if _, ok := accumulateSimCardStatistics(&stats, 150*1024, nil); ok {
+		t.Fatal("missing previous day must not report an increment")
+	}
+}
+
+func strPtr(s string) *string { return &s }
+
+func TestPickSimCardSite(t *testing.T) {
+	if pickSimCardSite(nil) != nil {
+		t.Fatal("no candidates must pick nothing")
+	}
+	sites := []domain.SiteInfoDomain{
+		{Sn: "OFF-OLD", Status: 0, OffLineTime: strPtr("2026-10-01 00:00:00")},
+		{Sn: "ON-OLD", Status: 1, OnLineTime: strPtr("2026-09-01 00:00:00")},
+		{Sn: "OFF-NEW", Status: 0, OffLineTime: strPtr("2026-11-01 00:00:00")},
+		{Sn: "ON-NEW", Status: 1, OnLineTime: strPtr("2026-10-15 00:00:00")},
+	}
+	if got := pickSimCardSite(sites); got.Sn != "ON-NEW" {
+		t.Fatalf("online site with the latest online time expected, got %s", got.Sn)
+	}
+	offline := []domain.SiteInfoDomain{sites[0], {Sn: "OFF-NIL", Status: 0}, sites[2]}
+	if got := pickSimCardSite(offline); got.Sn != "OFF-NEW" {
+		t.Fatalf("offline site closest to now expected, got %s", got.Sn)
+	}
+}
+
+func TestBuildSimCardDailyReport(t *testing.T) {
+	var heavy []simCardHeavyUsage
+	for i := 0; i < simCardHeavyUsageMaxRows+5; i++ {
+		heavy = append(heavy, simCardHeavyUsage{ICCID: fmt.Sprintf("89860000000000%05d", i), IncrementKB: float64(101+i) * 1024})
+	}
+	top := heavy[len(heavy)-1].ICCID
+	unbound := heavy[len(heavy)-2].ICCID
+	var asked []string
+	report := buildSimCardDailyReport(domain.SimCardDailyStatistics{Date: "2026-11-03"}, heavy,
+		func(ids []string) ([]domain.SiteInfoDomain, error) {
+			asked = ids
+			return []domain.SiteInfoDomain{
+				{Sn: "SN-OFF", ICCID: top + "1", Version: "V1", Status: 0, OffLineTime: strPtr("2026-11-01 00:00:00")},
+				{Sn: "SN-ON", ICCID: top + "1", Version: "V2", Status: 1},
+				{Sn: "SN-OTHER", ICCID: "0000000000000000000", Version: "V3", Status: 1},
+			}, nil
+		})
+	if report.HeavyCount != simCardHeavyUsageMaxRows+5 || len(report.Heavy) != simCardHeavyUsageMaxRows || len(asked) != simCardHeavyUsageMaxRows {
+		t.Fatalf("list must be truncated before lookup: count=%d rows=%d asked=%d", report.HeavyCount, len(report.Heavy), len(asked))
+	}
+	if report.Heavy[0].ICCID != top || report.Heavy[0].SN != "SN-ON" || report.Heavy[0].Version != "V2" {
+		t.Fatalf("largest usage must come first with the online site: %+v", report.Heavy[0])
+	}
+	if report.Heavy[1].ICCID != unbound || report.Heavy[1].SN != "" {
+		t.Fatalf("unbound card must keep empty SN: %+v", report.Heavy[1])
+	}
+	text := formatSimCardDailyReport(report)
+	for _, want := range []string{
+		fmt.Sprintf("日用量超过 100 MB 的站点(%d 张卡", simCardHeavyUsageMaxRows+5),
+		"- SN-ON · 135.00 MB · V2",
+		"- ICCID " + unbound + "(未绑定站点) · 134.00 MB · -",
+		"- 其余 5 张省略",
+	} {
+		if !strings.Contains(text, want) {
+			t.Errorf("report missing %q:\n%s", want, text)
+		}
+	}
+	if strings.Contains(text, "**") {
+		t.Error("usage under 500 MB must not be bold")
+	}
+}
+
+func TestFormatSimCardDailyReportBoldAndLookupFailure(t *testing.T) {
+	heavy := []simCardHeavyUsage{
+		{ICCID: "8986000000000000001", IncrementKB: 500 * 1024},
+		{ICCID: "8986000000000000002", IncrementKB: 600 * 1024},
+	}
+	report := buildSimCardDailyReport(domain.SimCardDailyStatistics{}, heavy,
+		func([]string) ([]domain.SiteInfoDomain, error) { return nil, errors.New("timeout") })
+	text := formatSimCardDailyReport(report)
+	for _, want := range []string{
+		"站点信息查询失败",
+		"- **ICCID 8986000000000000002 · 600.00 MB · -**",
+		"- ICCID 8986000000000000001 · 500.00 MB · -",
+		"总流量(",
+	} {
+		if !strings.Contains(text, want) {
+			t.Errorf("report missing %q:\n%s", want, text)
+		}
+	}
+	if strings.Contains(text, "未绑定站点") {
+		t.Error("lookup failure must not mark cards as unbound")
+	}
+}
+
+func TestFormatSimCardDailyReportEmpty(t *testing.T) {
+	text := formatSimCardDailyReport(buildSimCardDailyReport(domain.SimCardDailyStatistics{}, nil,
+		func([]string) ([]domain.SiteInfoDomain, error) { t.Fatal("no lookup expected"); return nil, nil }))
+	if !strings.Contains(text, "(0 张卡") || !strings.HasSuffix(text, "- 无") {
+		t.Fatalf("empty list must say none:\n%s", text)
+	}
+}