Преглед изворни кода

feat:添加每日发送SIM卡统计数据到钉钉的功能

Jerry Liang пре 1 месец
родитељ
комит
bc44195c26

+ 7 - 0
deploy/config.env.example

@@ -102,3 +102,10 @@ PLANE_WEB_LABEL_FEEDBACK="CHANGE_ME"
 # TODO: [确认] 拉起 kiro-cli 子进程时降权到的系统用户及其 HOME(改成目标机的实际用户)
 KIRO_RUN_AS_USER=jerry-remote
 KIRO_HOME=/home/jerry-remote
+
+# ---------- SIM 卡每日流量日报(可选)----------
+# TODO: [可选] 钉钉自定义机器人 Webhook;留空则不统计、不发送日报。
+# 不要把真实 access_token 或加签密钥提交到仓库。
+SIM_CARD_DINGTALK_WEBHOOK=""
+# TODO: [可选] 机器人启用“加签”安全设置时填写 SEC 开头的密钥。
+SIM_CARD_DINGTALK_SECRET=""

+ 4 - 0
service/README.md

@@ -96,6 +96,10 @@ Database / Cache (Redis) / 外部服务 (FWH-API)
 | POSTGRES_DSN | PGSQL数据库连接配置,DSN格式,本地保存数据信息。 |
 | MONGO_DSN | MONGO数据库连接配置,DSN格式,本地保存数据信息。 |
 | REDIS_ADDR | REDIS本地缓存服务器的域名或IP地址,默认"localhost" |
+| SIM_CARD_DINGTALK_WEBHOOK | SIM 卡每日流量统计的钉钉自定义机器人 HTTPS Webhook 地址;未配置时跳过日报统计和发送。 |
+| SIM_CARD_DINGTALK_SECRET | 可选;机器人开启“加签”安全设置时填写对应密钥。 |
+
+SIM 卡采集任务在每天 23:00 UTC 启动。两平台的全部页面写入 MongoDB 后,服务会生成一份 UTC 日期对应的流量日报,保存在 `SimCardDailyStatistics` 集合,并通过钉钉机器人发送 Markdown 消息。总量为当日记录的本月累计用量;增量为当日累计用量减前一日累计用量,若月累计量重置,则以重置后的当日累计量计。缺少前一日记录的卡不参与增量统计。阈值均为严格大于,换算采用 1 MB = 1024 KB、1 GB = 1024 MB。钉钉机器人若使用“自定义关键词”安全设置,应包含“SIM 卡流量日报”或改用加签。
 
 ## FWH 生产账号登录
 

+ 34 - 30
service/config/config.go

@@ -9,21 +9,23 @@ import (
 )
 
 type AppConfig struct {
-	AppEnv           string
-	AppPort          string
-	LogLevel         string
-	RedisAddr        string
-	MySQLDSN         string
-	PostgresDSN      string
-	MongoDSN         string
-	MinIoEndpoint    string
-	MinioAccessKey   string
-	MinioSecretKey   string
-	MinioBucketName  string
-	AsynqmonPort     string
-	TotpViewPassword string
-	TotpViewHost     string
-	TotpViewPort     string
+	AppEnv                 string
+	AppPort                string
+	LogLevel               string
+	RedisAddr              string
+	MySQLDSN               string
+	PostgresDSN            string
+	MongoDSN               string
+	MinIoEndpoint          string
+	MinioAccessKey         string
+	MinioSecretKey         string
+	MinioBucketName        string
+	AsynqmonPort           string
+	TotpViewPassword       string
+	TotpViewHost           string
+	TotpViewPort           string
+	SimCardDingTalkWebhook string
+	SimCardDingTalkSecret  string
 }
 
 type FwhApiConfig struct {
@@ -180,21 +182,23 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 
 	return &Config{
 		App: AppConfig{
-			AppEnv:           v.GetString("APP_ENV"),
-			AppPort:          v.GetString("APP_PORT"),
-			LogLevel:         v.GetString("LOG_LEVEL"),
-			RedisAddr:        v.GetString("REDIS_ADDR"),
-			MySQLDSN:         v.GetString("MYSQL_DSN"),
-			PostgresDSN:      v.GetString("POSTGRES_DSN"),
-			MongoDSN:         v.GetString("MONGO_DSN"),
-			MinIoEndpoint:    v.GetString("MINIO_ENDPOINT"),
-			MinioAccessKey:   v.GetString("MINIO_ACCESS_KEY"),
-			MinioSecretKey:   v.GetString("MINIO_SECRET_KEY"),
-			MinioBucketName:  v.GetString("MINIO_BUCKET_NAME"),
-			AsynqmonPort:     v.GetString("ASYNQMON_PORT"),
-			TotpViewPassword: v.GetString("FWH_TOTP_VIEW_PASSWORD"),
-			TotpViewHost:     v.GetString("FWH_TOTP_VIEW_HOST"),
-			TotpViewPort:     v.GetString("FWH_TOTP_VIEW_PORT"),
+			AppEnv:                 v.GetString("APP_ENV"),
+			AppPort:                v.GetString("APP_PORT"),
+			LogLevel:               v.GetString("LOG_LEVEL"),
+			RedisAddr:              v.GetString("REDIS_ADDR"),
+			MySQLDSN:               v.GetString("MYSQL_DSN"),
+			PostgresDSN:            v.GetString("POSTGRES_DSN"),
+			MongoDSN:               v.GetString("MONGO_DSN"),
+			MinIoEndpoint:          v.GetString("MINIO_ENDPOINT"),
+			MinioAccessKey:         v.GetString("MINIO_ACCESS_KEY"),
+			MinioSecretKey:         v.GetString("MINIO_SECRET_KEY"),
+			MinioBucketName:        v.GetString("MINIO_BUCKET_NAME"),
+			AsynqmonPort:           v.GetString("ASYNQMON_PORT"),
+			TotpViewPassword:       v.GetString("FWH_TOTP_VIEW_PASSWORD"),
+			TotpViewHost:           v.GetString("FWH_TOTP_VIEW_HOST"),
+			TotpViewPort:           v.GetString("FWH_TOTP_VIEW_PORT"),
+			SimCardDingTalkWebhook: v.GetString("SIM_CARD_DINGTALK_WEBHOOK"),
+			SimCardDingTalkSecret:  v.GetString("SIM_CARD_DINGTALK_SECRET"),
 		},
 		FwhApi: FwhApiConfig{
 			FwhEnergy:     v.GetString("FWH_ENERGY"),

+ 22 - 0
service/domain/simCardStatisticsDomain.go

@@ -0,0 +1,22 @@
+package domain
+
+// SimCardDailyStatistics records the UTC collection date. Usage values are in KB.
+type SimCardDailyStatistics struct {
+	Date               string   `bson:"date" json:"date"`
+	CardCount          int64    `bson:"cardCount" json:"cardCount"`
+	TotalOver200MB     int64    `bson:"totalOver200MB" json:"totalOver200MB"`
+	TotalOver1GB       int64    `bson:"totalOver1GB" json:"totalOver1GB"`
+	TotalOver2GB       int64    `bson:"totalOver2GB" json:"totalOver2GB"`
+	MaxTotalKB         float64  `bson:"maxTotalKB" json:"maxTotalKB"`
+	IncrementCardCount int64    `bson:"incrementCardCount" json:"incrementCardCount"`
+	IncrementOver20MB  int64    `bson:"incrementOver20MB" json:"incrementOver20MB"`
+	IncrementOver50MB  int64    `bson:"incrementOver50MB" json:"incrementOver50MB"`
+	IncrementOver200MB int64    `bson:"incrementOver200MB" json:"incrementOver200MB"`
+	MaxIncrementKB     *float64 `bson:"maxIncrementKB" json:"maxIncrementKB"`
+}
+
+// SimCardRunPage identifies one persisted source page.
+type SimCardRunPage struct {
+	Platform SimCardPlatform `json:"platform"`
+	Page     uint64          `json:"page"`
+}

+ 1 - 1
service/main.go

@@ -89,7 +89,7 @@ func main() {
 	task.InitTask(task.TaskRedisOpt{
 		Addr: cfg.App.RedisAddr,
 		DB:   12,
-	})
+	}, &cfg.App)
 
 	// 初始化Casdoor
 	casdoor.InitAuthConfig()

+ 4 - 1
service/repositories/mongo/init.go

@@ -4,5 +4,8 @@ import "context"
 
 // Init is the explicit registry for MongoDB repository indexes.
 func Init(ctx context.Context) error {
-	return InitDataDownloadIndexes(ctx)
+	if err := InitDataDownloadIndexes(ctx); err != nil {
+		return err
+	}
+	return InitSimCardStatisticsIndexes(ctx)
 }

+ 166 - 0
service/repositories/mongo/simCardStatisticsRepo.go

@@ -0,0 +1,166 @@
+package mongo
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"time"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.mongodb.org/mongo-driver/mongo/options"
+)
+
+const (
+	collectionSimCardRun        = "SimCardDailyRun"
+	collectionSimCardStatistics = "SimCardDailyStatistics"
+)
+
+func InitSimCardStatisticsIndexes(ctx context.Context) error {
+	_, err := database.GetCollection(CollectionName_SimCardMonthlyData).Indexes().CreateMany(ctx, []mongo.IndexModel{
+		{Keys: bson.D{{Key: "month", Value: 1}, {Key: "simId", Value: 1}}},
+		{Keys: bson.D{{Key: "simId", Value: 1}, {Key: "month", Value: 1}}},
+	})
+	return err
+}
+
+type simCardRun struct {
+	SmartHubLastPage    *uint64  `bson:"smartHubLastPage"`
+	AcceleronixLastPage *uint64  `bson:"acceleronixLastPage"`
+	SavedPages          []string `bson:"savedPages"`
+	Sent                bool     `bson:"sent"`
+}
+
+func simCardPageKey(page domain.SimCardRunPage) string {
+	return fmt.Sprintf("%s:%d", page.Platform, page.Page)
+}
+
+func MarkSimCardLastPage(date string, platform domain.SimCardPlatform, page uint64) error {
+	field := "smartHubLastPage"
+	if platform == domain.SimPlatformAcceleronix {
+		field = "acceleronixLastPage"
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	_, err := database.GetCollection(collectionSimCardRun).UpdateOne(ctx, bson.M{"_id": date}, bson.M{"$set": bson.M{field: page}}, options.Update().SetUpsert(true))
+	return err
+}
+
+func MarkSimCardPagesSaved(date string, pages []domain.SimCardRunPage) error {
+	if len(pages) == 0 {
+		return nil
+	}
+	keys := make([]string, 0, len(pages))
+	for _, page := range pages {
+		keys = append(keys, simCardPageKey(page))
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	_, err := database.GetCollection(collectionSimCardRun).UpdateOne(ctx, bson.M{"_id": date}, bson.M{"$addToSet": bson.M{"savedPages": bson.M{"$each": keys}}}, options.Update().SetUpsert(true))
+	return err
+}
+
+func IsSimCardRunComplete(date string) (bool, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	var run simCardRun
+	err := database.GetCollection(collectionSimCardRun).FindOne(ctx, bson.M{"_id": date}).Decode(&run)
+	if err == mongo.ErrNoDocuments {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	return simCardRunComplete(run), nil
+}
+
+func simCardRunComplete(run simCardRun) bool {
+	if run.Sent || run.SmartHubLastPage == nil || run.AcceleronixLastPage == nil {
+		return false
+	}
+	saved := make(map[string]bool, len(run.SavedPages))
+	for _, key := range run.SavedPages {
+		saved[key] = true
+	}
+	for page := uint64(0); page <= *run.SmartHubLastPage; page++ {
+		if !saved[simCardPageKey(domain.SimCardRunPage{Platform: domain.SimPlatformSmartHub, Page: page})] {
+			return false
+		}
+	}
+	for page := uint64(1); page <= *run.AcceleronixLastPage; page++ {
+		if !saved[simCardPageKey(domain.SimCardRunPage{Platform: domain.SimPlatformAcceleronix, Page: page})] {
+			return false
+		}
+	}
+	return true
+}
+
+// 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 {
+	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+	defer cancel()
+	month := date.Format("2006-01")
+	day := date.Day()
+	currentPath := fmt.Sprintf("$dailyUsage.%d.dataUsage", day)
+	match := bson.D{{Key: "$match", Value: bson.M{
+		"month":                           bson.M{"$in": []string{month, date.Format("200601")}},
+		fmt.Sprintf("dailyUsage.%d", day): bson.M{"$exists": true},
+	}}}
+	pipeline := mongo.Pipeline{
+		match,
+		{{Key: "$sort", Value: bson.D{{Key: "updateTime", Value: -1}}}},
+		{{Key: "$group", Value: bson.M{"_id": "$simId", "total": bson.M{"$first": currentPath}, "dailyUsage": bson.M{"$first": "$dailyUsage"}}}},
+	}
+	previousExpression := any(fmt.Sprintf("$dailyUsage.%d.dataUsage", day-1))
+	if day == 1 {
+		prev := time.Date(date.Year(), date.Month()-1, 1, 0, 0, 0, 0, time.UTC)
+		prevDay := time.Date(date.Year(), date.Month(), 0, 0, 0, 0, 0, time.UTC).Day()
+		pipeline = append(pipeline, bson.D{{Key: "$lookup", Value: bson.M{
+			"from": CollectionName_SimCardMonthlyData,
+			"let":  bson.M{"id": "$_id"},
+			"pipeline": mongo.Pipeline{
+				{{Key: "$match", Value: bson.M{"month": bson.M{"$in": []string{prev.Format("2006-01"), prev.Format("200601")}}, "$expr": bson.M{"$eq": bson.A{"$simId", "$$id"}}}}},
+				{{Key: "$limit", Value: 1}},
+			},
+			"as": "previousMonth",
+		}}})
+		previousExpression = bson.M{"$arrayElemAt": bson.A{fmt.Sprintf("$previousMonth.dailyUsage.%d.dataUsage", prevDay), 0}}
+	}
+	pipeline = append(pipeline, bson.D{{Key: "$project", Value: bson.M{"total": 1, "previous": previousExpression}}})
+	cursor, err := database.GetCollection(CollectionName_SimCardMonthlyData).Aggregate(ctx, pipeline, options.Aggregate().SetAllowDiskUse(true))
+	if err != nil {
+		return err
+	}
+	defer cursor.Close(ctx)
+	for cursor.Next(ctx) {
+		var row struct {
+			Total    *float64 `bson:"total"`
+			Previous *float64 `bson:"previous"`
+		}
+		if err := cursor.Decode(&row); err != nil {
+			return err
+		}
+		if row.Total != nil {
+			if err := consume(*row.Total, row.Previous); err != nil {
+				return err
+			}
+		}
+	}
+	return cursor.Err()
+}
+
+func SaveSimCardDailyStatistics(stats domain.SimCardDailyStatistics) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	_, err := database.GetCollection(collectionSimCardStatistics).ReplaceOne(ctx, bson.M{"_id": stats.Date}, bson.M{"_id": stats.Date, "date": stats.Date, "cardCount": stats.CardCount, "totalOver200MB": stats.TotalOver200MB, "totalOver1GB": stats.TotalOver1GB, "totalOver2GB": stats.TotalOver2GB, "maxTotalKB": stats.MaxTotalKB, "incrementCardCount": stats.IncrementCardCount, "incrementOver20MB": stats.IncrementOver20MB, "incrementOver50MB": stats.IncrementOver50MB, "incrementOver200MB": stats.IncrementOver200MB, "maxIncrementKB": stats.MaxIncrementKB}, options.Replace().SetUpsert(true))
+	return err
+}
+
+func MarkSimCardReportSent(date string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	_, err := database.GetCollection(collectionSimCardRun).UpdateOne(ctx, bson.M{"_id": date}, bson.M{"$set": bson.M{"sent": true}})
+	return err
+}

+ 26 - 0
service/repositories/mongo/simCardStatisticsRepo_test.go

@@ -0,0 +1,26 @@
+package mongo
+
+import "testing"
+
+func TestSimCardRunComplete(t *testing.T) {
+	zero, one := uint64(0), uint64(1)
+	run := simCardRun{
+		SmartHubLastPage: &one, AcceleronixLastPage: &one,
+		SavedPages: []string{"SmartHub:0", "SmartHub:1", "Acceleronix:1"},
+	}
+	if !simCardRunComplete(run) {
+		t.Fatal("all pages saved should complete the run")
+	}
+	run.SavedPages = []string{"SmartHub:0", "Acceleronix:1"}
+	if simCardRunComplete(run) {
+		t.Fatal("missing page must keep run pending")
+	}
+	run.SmartHubLastPage = &zero
+	if !simCardRunComplete(run) {
+		t.Fatal("SmartHub page zero is a valid final page")
+	}
+	run.Sent = true
+	if simCardRunComplete(run) {
+		t.Fatal("sent run must not enqueue another report")
+	}
+}

+ 2 - 0
service/repositories/repositories.go

@@ -437,6 +437,8 @@ var UpdateSimCardInfo = mongo.UpdateSimCardInfo
 var UpdateSimCardBatchInfo = mongo.UpdateSimCardBatchInfo
 var UpdateSimCardDayData = mongo.UpdateSimCardDayData
 var UpdateSimCardBatchDayData = mongo.UpdateSimCardBatchDayData
+var MarkSimCardLastPage = mongo.MarkSimCardLastPage
+var MarkSimCardPagesSaved = mongo.MarkSimCardPagesSaved
 
 /*************** PGSQL ***************/
 

+ 178 - 0
service/task/queue/simCardQueue/simCardStatistics.go

@@ -0,0 +1,178 @@
+package simCardQueue
+
+import (
+	"bytes"
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories/mongo"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/hibiken/asynq"
+)
+
+const taskTypeSimCardDailyReport = "SimCard:DailyReport"
+
+func calculateSimCardStatistics(date time.Time) (domain.SimCardDailyStatistics, error) {
+	stats := domain.SimCardDailyStatistics{Date: date.Format(time.DateOnly)}
+	err := mongo.StreamSimCardDailyUsage(date, func(total float64, previous *float64) error {
+		accumulateSimCardStatistics(&stats, total, previous)
+		return nil
+	})
+	return stats, err
+}
+
+func accumulateSimCardStatistics(stats *domain.SimCardDailyStatistics, total float64, previous *float64) {
+	stats.CardCount++
+	if total > stats.MaxTotalKB {
+		stats.MaxTotalKB = total
+	}
+	if total > 200*1024 {
+		stats.TotalOver200MB++
+	}
+	if total > 1024*1024 {
+		stats.TotalOver1GB++
+	}
+	if total > 2*1024*1024 {
+		stats.TotalOver2GB++
+	}
+	if previous == nil {
+		return
+	}
+	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 {
+		increment = total
+	}
+	stats.IncrementCardCount++
+	if stats.MaxIncrementKB == nil || increment > *stats.MaxIncrementKB {
+		stats.MaxIncrementKB = &increment
+	}
+	if increment > 20*1024 {
+		stats.IncrementOver20MB++
+	}
+	if increment > 50*1024 {
+		stats.IncrementOver50MB++
+	}
+	if increment > 200*1024 {
+		stats.IncrementOver200MB++
+	}
+}
+
+func (processor *SimCardProcessor) enqueueSimCardReportIfComplete(date string) error {
+	if processor.webhook == "" {
+		return nil
+	}
+	complete, err := mongo.IsSimCardRunComplete(date)
+	if err != nil || !complete {
+		return err
+	}
+	_, err = TaskClient.Enqueue(asynq.NewTask(taskTypeSimCardDailyReport, []byte(date),
+		asynq.Queue("default"), asynq.MaxRetry(10), asynq.Timeout(3*time.Minute),
+		asynq.TaskID("sim-card-daily-report-"+date), asynq.Retention(7*24*time.Hour)))
+	if err == asynq.ErrTaskIDConflict {
+		return nil
+	}
+	return err
+}
+
+func (processor *SimCardProcessor) handleSimCardDailyReport(ctx context.Context, task *asynq.Task) error {
+	if processor.webhook == "" {
+		return nil
+	}
+	dateStr := string(task.Payload())
+	date, err := time.Parse(time.DateOnly, dateStr)
+	if err != nil {
+		return err
+	}
+	complete, err := mongo.IsSimCardRunComplete(dateStr)
+	if err != nil || !complete {
+		return err
+	}
+	stats, 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 {
+		return err
+	}
+	return mongo.MarkSimCardReportSent(dateStr)
+}
+
+func formatSimCardStatistics(stats domain.SimCardDailyStatistics) string {
+	maxIncrement := "无可比数据"
+	if stats.MaxIncrementKB != nil {
+		maxIncrement = fmt.Sprintf("%.2f MB", *stats.MaxIncrementKB/1024)
+	}
+	return fmt.Sprintf("### SIM 卡流量日报(%s UTC)\n\n"+
+		"总流量(%d 张卡,月累计):\n\n"+
+		"- 超过 200 MB:%d 张\n- 超过 1 GB:%d 张\n- 超过 2 GB:%d 张\n- 最大总量:%.2f MB\n\n"+
+		"增量流量(%d 张卡有前一日数据):\n\n"+
+		"- 超过 20 MB:%d 张\n- 超过 50 MB:%d 张\n- 超过 200 MB:%d 张\n- 最大增量:%s",
+		stats.Date, stats.CardCount, stats.TotalOver200MB, stats.TotalOver1GB, stats.TotalOver2GB,
+		stats.MaxTotalKB/1024, stats.IncrementCardCount, stats.IncrementOver20MB,
+		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 postSimCardStatistics(ctx context.Context, client *http.Client, webhook, secret string, stats domain.SimCardDailyStatistics) error {
+	if webhook == "" {
+		return fmt.Errorf("SIM_CARD_DINGTALK_WEBHOOK is not configured")
+	}
+	u, err := url.Parse(webhook)
+	if err != nil || u.Scheme != "https" || u.Host == "" {
+		return fmt.Errorf("SIM_CARD_DINGTALK_WEBHOOK must be an HTTPS URL")
+	}
+	if secret != "" {
+		timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
+		mac := hmac.New(sha256.New, []byte(secret))
+		_, _ = mac.Write([]byte(timestamp + "\n" + secret))
+		query := u.Query()
+		query.Set("timestamp", timestamp)
+		query.Set("sign", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
+		u.RawQuery = query.Encode()
+	}
+	message := map[string]any{"msgtype": "markdown", "markdown": map[string]string{
+		"title": "SIM 卡流量日报 " + stats.Date,
+		"text":  formatSimCardStatistics(stats),
+	}}
+	body, err := json.Marshal(message)
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := client.Do(req)
+	if err != nil {
+		return fmt.Errorf("send SIM card statistics to DingTalk: %w", err)
+	}
+	defer resp.Body.Close()
+	var result struct {
+		ErrCode *int   `json:"errcode"`
+		ErrMsg  string `json:"errmsg"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK || result.ErrCode == nil || *result.ErrCode != 0 {
+		return fmt.Errorf("DingTalk rejected SIM card statistics: HTTP %d, code %v, %s", resp.StatusCode, result.ErrCode, result.ErrMsg)
+	}
+	return nil
+}

+ 107 - 0
service/task/queue/simCardQueue/simCardStatistics_test.go

@@ -0,0 +1,107 @@
+package simCardQueue
+
+import (
+	"context"
+	"encoding/json"
+	"fwh-toolbox-server/domain"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/hibiken/asynq"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) }
+
+func TestAccumulateSimCardStatistics(t *testing.T) {
+	stats := domain.SimCardDailyStatistics{Date: "2026-09-24"}
+	zero := 0.0
+	previous := 200.0 * 1024
+	// Thresholds are strict; cards without a previous snapshot count only toward totals.
+	accumulateSimCardStatistics(&stats, 200*1024, &zero)
+	accumulateSimCardStatistics(&stats, 1024*1024, &previous)
+	accumulateSimCardStatistics(&stats, 2*1024*1024, nil)
+	accumulateSimCardStatistics(&stats, 3*1024*1024, &zero)
+	if stats.CardCount != 4 || stats.TotalOver200MB != 3 || stats.TotalOver1GB != 2 || stats.TotalOver2GB != 1 {
+		t.Fatalf("unexpected total statistics: %+v", stats)
+	}
+	if stats.MaxTotalKB != 3*1024*1024 || stats.IncrementCardCount != 3 {
+		t.Fatalf("unexpected maxima or comparison count: %+v", stats)
+	}
+	if stats.IncrementOver20MB != 3 || stats.IncrementOver50MB != 3 || stats.IncrementOver200MB != 2 {
+		t.Fatalf("unexpected increment statistics: %+v", stats)
+	}
+	if stats.MaxIncrementKB == nil || *stats.MaxIncrementKB != 3*1024*1024 {
+		t.Fatalf("unexpected maximum increment: %+v", stats.MaxIncrementKB)
+	}
+	if !strings.Contains(formatSimCardStatistics(stats), "最大增量:3072.00 MB") {
+		t.Fatal("report does not show maximum increment")
+	}
+}
+
+func TestPostSimCardStatistics(t *testing.T) {
+	client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+		if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" {
+			t.Errorf("unexpected request: %s %s", r.Method, r.Header.Get("Content-Type"))
+		}
+		if r.URL.Query().Get("timestamp") == "" || r.URL.Query().Get("sign") == "" || r.URL.Query().Get("access_token") != "test" {
+			t.Errorf("signature or access token missing: %s", r.URL.RawQuery)
+		}
+		var message struct {
+			MsgType  string `json:"msgtype"`
+			Markdown struct {
+				Text string `json:"text"`
+			} `json:"markdown"`
+		}
+		if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
+			t.Error(err)
+		}
+		if message.MsgType != "markdown" || !strings.Contains(message.Markdown.Text, "2026-09-24") {
+			t.Errorf("unexpected message: %+v", message)
+		}
+		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 {
+		t.Fatal(err)
+	}
+}
+
+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 {
+		t.Fatal("DingTalk error response must be retried")
+	}
+}
+
+func TestUnconfiguredWebhookSkipsStatistics(t *testing.T) {
+	processor := &SimCardProcessor{}
+	if err := processor.enqueueSimCardReportIfComplete("2026-09-24"); err != nil {
+		t.Fatal(err)
+	}
+	if err := processor.handleSimCardDailyReport(context.Background(), asynq.NewTask(taskTypeSimCardDailyReport, []byte("2026-09-24"))); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestAccumulateSimCardStatisticsMissingPrevious(t *testing.T) {
+	stats := domain.SimCardDailyStatistics{}
+	accumulateSimCardStatistics(&stats, 300*1024, nil)
+	if stats.IncrementCardCount != 0 || stats.MaxIncrementKB != nil {
+		t.Fatalf("missing previous day must not create an increment: %+v", stats)
+	}
+}
+
+func TestAccumulateSimCardStatisticsCounterReset(t *testing.T) {
+	stats := domain.SimCardDailyStatistics{}
+	previous := 2.0 * 1024 * 1024
+	accumulateSimCardStatistics(&stats, 60*1024, &previous)
+	if stats.MaxIncrementKB == nil || *stats.MaxIncrementKB != 60*1024 || stats.IncrementOver50MB != 1 {
+		t.Fatalf("reset counter should use current accumulated usage: %+v", stats)
+	}
+}

+ 11 - 3
service/task/queue/simCardQueue/simCardTaskAcceleronix.go

@@ -65,7 +65,7 @@ func (processor *SimCardProcessor) acceleronixInfoToSimCardInfo(infoList []domai
 			break
 		}
 
-		simCardInfo.ExpirationTime = info.ExpirationTime // 到期时间(后付费卡没有到期时间) 格式:yyyy-MM-dd
+		simCardInfo.ExpirationTime = info.ExpirationTime     // 到期时间(后付费卡没有到期时间) 格式:yyyy-MM-dd
 		simCardInfo.Platform = domain.SimPlatformAcceleronix // 数据来源平台
 
 		usageInfo := dataUsage[info.Iccid]
@@ -100,6 +100,9 @@ func (processor *SimCardProcessor) handleSimCardAcceleronixSyncOnePageTask(ctx c
 		zap.L().Error("handleSimCardAcceleronixSyncOnePageTask payload error.", zap.String("err", err.Error()), zap.String("payload", string(payloadStr)))
 		return err
 	}
+	if payload.Date == "" {
+		payload.Date = time.Now().UTC().Format(time.DateOnly)
+	}
 
 	// 请求批量查询Acceleronix批量数据信息
 	// 查询Sim卡基本信息列表
@@ -131,7 +134,9 @@ func (processor *SimCardProcessor) handleSimCardAcceleronixSyncOnePageTask(ctx c
 	// 转换数据结构
 	infoList := processor.acceleronixInfoToSimCardInfo(respInfoList.Data.List, dataUsageMap)
 	uuidStr := uuid.NewString()
-	processor.infoCacheMap[uuidStr] = infoList
+	processor.putBatches(uuidStr, []simCardPageBatch{{
+		Date: payload.Date, Pages: []domain.SimCardRunPage{{Platform: domain.SimPlatformAcceleronix, Page: payload.Page}}, Info: infoList,
+	}})
 	zap.L().Debug("Acceleronix get infoList.", zap.Int("len", len(infoList)))
 
 	// 创建聚合组任务
@@ -150,7 +155,10 @@ func (processor *SimCardProcessor) handleSimCardAcceleronixSyncOnePageTask(ctx c
 
 	// 判断是否查完所有信息
 	if payload.Page*payload.Size > uint64(respInfoList.Data.Total) {
-		return nil
+		if err := repositories.MarkSimCardLastPage(payload.Date, domain.SimPlatformAcceleronix, payload.Page); err != nil {
+			return err
+		}
+		return processor.enqueueSimCardReportIfComplete(payload.Date)
 	}
 
 	// 查询太快,间隔1s

+ 87 - 27
service/task/queue/simCardQueue/simCardTaskQueue.go

@@ -4,10 +4,12 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/repositories"
 	task_core "fwh-toolbox-server/task/core"
 	task_pg "fwh-toolbox-server/task/plugin"
+	"sync"
 	"time"
 
 	"github.com/google/uuid"
@@ -45,18 +47,48 @@ type TaskCore interface {
 type SimCardSyncOnePagePayload struct {
 	Size uint64 `json:"size"`
 	Page uint64 `json:"page"`
+	Date string `json:"date,omitempty"` // UTC collection date
+}
+
+type simCardPageBatch struct {
+	Date  string
+	Pages []domain.SimCardRunPage
+	Info  []domain.SimCardInfo
 }
 
 type SimCardProcessor struct {
-	// ... fields for struct
-	infoCacheMap map[string][]domain.SimCardInfo
+	cacheMu      sync.Mutex
+	infoCacheMap map[string][]simCardPageBatch
+	webhook      string
+	secret       string
+}
+
+func (processor *SimCardProcessor) putBatches(key string, batches []simCardPageBatch) {
+	processor.cacheMu.Lock()
+	defer processor.cacheMu.Unlock()
+	processor.infoCacheMap[key] = batches
+}
+
+func (processor *SimCardProcessor) getBatches(key string) ([]simCardPageBatch, bool) {
+	processor.cacheMu.Lock()
+	defer processor.cacheMu.Unlock()
+	batches, ok := processor.infoCacheMap[key]
+	return batches, ok
+}
+
+func (processor *SimCardProcessor) deleteBatches(key string) {
+	processor.cacheMu.Lock()
+	defer processor.cacheMu.Unlock()
+	delete(processor.infoCacheMap, key)
 }
 
 var TaskClient *asynq.Client
 
-func InitSimCardTaskQueue(tc *task_core.TaskCore) {
+func InitSimCardTaskQueue(tc *task_core.TaskCore, appConfig *config.AppConfig) {
 	processor := &SimCardProcessor{
-		infoCacheMap: make(map[string][]domain.SimCardInfo),
+		infoCacheMap: make(map[string][]simCardPageBatch),
+		webhook:      appConfig.SimCardDingTalkWebhook,
+		secret:       appConfig.SimCardDingTalkSecret,
 	}
 
 	// 注册SmartHub收集任务处理函数
@@ -68,23 +100,28 @@ func InitSimCardTaskQueue(tc *task_core.TaskCore) {
 
 	// 注册SimCard批处理任务
 	mux.HandleFunc(TaskTypeSimCardBulk, processor.handleSimCardBulkTask)
+	mux.HandleFunc(taskTypeSimCardDailyReport, processor.handleSimCardDailyReport)
 
 	// 注册SimCard组聚合器
 	aggManager := task_pg.NewAggregatorManager()
 	err := aggManager.Register(GroupNameSimCardAggregation, func(tasks []*asynq.Task) *asynq.Task {
-		var aggInfoList []domain.SimCardInfo
+		var batches []simCardPageBatch
 		for _, t := range tasks {
 			payloadStr := t.Payload()
 
-			infoList := processor.infoCacheMap[string(payloadStr)]
-			defer delete(processor.infoCacheMap, string(payloadStr))
+			pageBatches, ok := processor.getBatches(string(payloadStr))
+			if !ok {
+				zap.L().Error("SimCard aggregation payload missing", zap.String("key", string(payloadStr)))
+				continue
+			}
+			defer processor.deleteBatches(string(payloadStr))
 
-			aggInfoList = append(aggInfoList, infoList...)
+			batches = append(batches, pageBatches...)
 		}
 
 		uuidStr := uuid.NewString()
-		processor.infoCacheMap[uuidStr] = aggInfoList
-		zap.L().Debug("SimCardBulk get aggInfoList.", zap.Int("len", len(aggInfoList)))
+		processor.putBatches(uuidStr, batches)
+		zap.L().Debug("SimCardBulk get batches.", zap.Int("len", len(batches)))
 
 		bulkTask := asynq.NewTask(
 			TaskTypeSimCardBulk,
@@ -241,7 +278,7 @@ func CreateTaskSimCardCollect() error {
 }
 
 // 保存SimCard信息到数据库
-func (processor *SimCardProcessor) saveSimCardInfo(infoList []domain.SimCardInfo) error {
+func (processor *SimCardProcessor) saveSimCardInfo(infoList []domain.SimCardInfo, date time.Time) error {
 	zap.L().Info("saveSimCardInfo", zap.Int("Num", len(infoList)))
 
 	// 保存基本信息
@@ -255,15 +292,15 @@ func (processor *SimCardProcessor) saveSimCardInfo(infoList []domain.SimCardInfo
 	dayDataList := []domain.SimCardDayData{}
 	for _, info := range infoList {
 		dayData := domain.SimCardDayData{
-			SimId:        info.GICCID,                   // GICCID
-			UpdateTime:   info.UpdateTime,               // UTC时间
-			Month:        info.Month,                    // 统计月份 格式:yyyy-MM
-			AllDataUsage: info.AllDataUsage,             // 月数据用量(KB)
-			Day:          uint8(time.Now().UTC().Day()), // 当天日期值 1-31
+			SimId:        info.GICCID,       // GICCID
+			UpdateTime:   info.UpdateTime,   // UTC时间
+			Month:        info.Month,        // 统计月份 格式:yyyy-MM
+			AllDataUsage: info.AllDataUsage, // 月数据用量(KB)
+			Day:          uint8(date.Day()), // 当天日期值 1-31
 			DailyUsage: domain.SimCardDailyUsage{
-				UpdateTime: info.UpdateTime,                        // UTC时间
-				Day:        time.Now().UTC().Format(time.DateOnly), // 统计日期 格式:yyyy-MM-DD
-				DataUsage:  info.AllDataUsage,                      // 数据用量(KB)
+				UpdateTime: info.UpdateTime,            // UTC时间
+				Day:        date.Format(time.DateOnly), // 统计日期 格式:yyyy-MM-DD
+				DataUsage:  info.AllDataUsage,          // 数据用量(KB)
 			}, // 日用量数据信息
 		}
 		dayDataList = append(dayDataList, dayData)
@@ -282,12 +319,35 @@ func (processor *SimCardProcessor) saveSimCardInfo(infoList []domain.SimCardInfo
 // 处理SimCard批处理任务
 func (processor *SimCardProcessor) handleSimCardBulkTask(ctx context.Context, task *asynq.Task) error {
 	payloadStr := task.Payload()
-	originalList := processor.infoCacheMap[string(payloadStr)]
-	defer delete(processor.infoCacheMap, string(payloadStr))
-
-	// infoList := make([]domain.SimCardInfo, len(originalList))
-	// copy(infoList, originalList) // 这里使用拷贝,防止引用被删除
-	infoList := originalList
-
-	return processor.saveSimCardInfo(infoList)
+	batches, ok := processor.getBatches(string(payloadStr))
+	if !ok {
+		return fmt.Errorf("SIM card bulk payload missing: %s", string(payloadStr))
+	}
+	byDate := make(map[string]*simCardPageBatch)
+	for _, batch := range batches {
+		combined := byDate[batch.Date]
+		if combined == nil {
+			combined = &simCardPageBatch{Date: batch.Date}
+			byDate[batch.Date] = combined
+		}
+		combined.Pages = append(combined.Pages, batch.Pages...)
+		combined.Info = append(combined.Info, batch.Info...)
+	}
+	for dateStr, batch := range byDate {
+		date, err := time.Parse(time.DateOnly, dateStr)
+		if err != nil {
+			return err
+		}
+		if err := processor.saveSimCardInfo(batch.Info, date); err != nil {
+			return err
+		}
+		if err := repositories.MarkSimCardPagesSaved(dateStr, batch.Pages); err != nil {
+			return err
+		}
+		if err := processor.enqueueSimCardReportIfComplete(dateStr); err != nil {
+			return err
+		}
+	}
+	processor.deleteBatches(string(payloadStr))
+	return nil
 }

+ 11 - 3
service/task/queue/simCardQueue/simCardTaskSmartHub.go

@@ -59,7 +59,7 @@ func (processor *SimCardProcessor) smartHubInfoToSimCardInfo(infoList []domain.S
 			break
 		}
 
-		simCardInfo.Platform = domain.SimPlatformSmartHub               // 数据来源平台
+		simCardInfo.Platform = domain.SimPlatformSmartHub              // 数据来源平台
 		simCardInfo.ExpirationTime = ""                                // SmartHub没有有效期
 		simCardInfo.Month = time.Now().UTC().Format(time.DateOnly)[:7] // 统计月份 格式:yyyy-MM
 		simCardInfo.AllDataUsage = info.CurrentCycleUsages.DataUsage   // 月数据用量(KB)
@@ -81,6 +81,9 @@ func (processor *SimCardProcessor) handleSimCardSmartHubSyncOnePageTask(ctx cont
 		zap.L().Error("handleSimCardSmartHubSyncOnePageTask payload error.", zap.String("err", err.Error()), zap.String("payload", string(payloadStr)))
 		return err
 	}
+	if payload.Date == "" {
+		payload.Date = time.Now().UTC().Format(time.DateOnly)
+	}
 
 	// 请求批量查询SmartHub批量数据信息
 	// 获取指定页信息
@@ -93,7 +96,9 @@ func (processor *SimCardProcessor) handleSimCardSmartHubSyncOnePageTask(ctx cont
 	// 转换数据结构
 	infoList := processor.smartHubInfoToSimCardInfo(resp.Data)
 	uuidStr := uuid.NewString()
-	processor.infoCacheMap[uuidStr] = infoList
+	processor.putBatches(uuidStr, []simCardPageBatch{{
+		Date: payload.Date, Pages: []domain.SimCardRunPage{{Platform: domain.SimPlatformSmartHub, Page: payload.Page}}, Info: infoList,
+	}})
 
 	// 创建聚合组任务
 	aggTask := asynq.NewTask(
@@ -111,7 +116,10 @@ func (processor *SimCardProcessor) handleSimCardSmartHubSyncOnePageTask(ctx cont
 
 	// 判断是否查完所有信息
 	if payload.Page*payload.Size > resp.Total {
-		return nil
+		if err := repositories.MarkSimCardLastPage(payload.Date, domain.SimPlatformSmartHub, payload.Page); err != nil {
+			return err
+		}
+		return processor.enqueueSimCardReportIfComplete(payload.Date)
 	}
 
 	// 创建下一个任务(查询下一页)

+ 3 - 2
service/task/queue/taskQueue.go

@@ -1,18 +1,19 @@
 package taskQueue
 
 import (
+	"fwh-toolbox-server/config"
 	task_core "fwh-toolbox-server/task/core"
 	"fwh-toolbox-server/task/queue/dataDownloadQueue"
 	"fwh-toolbox-server/task/queue/fwhQueue"
 	"fwh-toolbox-server/task/queue/simCardQueue"
 )
 
-func InitTaskQueue(tc *task_core.TaskCore) {
+func InitTaskQueue(tc *task_core.TaskCore, appConfig *config.AppConfig) {
 	if tc == nil {
 		panic("InitTaskQueue failed, TaskCore is nil.")
 	}
 
 	fwhQueue.InitFwhTaskQueue(tc)
 	dataDownloadQueue.Init(tc)
-	simCardQueue.InitSimCardTaskQueue(tc)
+	simCardQueue.InitSimCardTaskQueue(tc, appConfig)
 }

+ 3 - 2
service/task/task.go

@@ -1,6 +1,7 @@
 package task
 
 import (
+	"fwh-toolbox-server/config"
 	task_core "fwh-toolbox-server/task/core"
 	taskQueue "fwh-toolbox-server/task/queue"
 
@@ -14,7 +15,7 @@ type TaskRedisOpt struct {
 	Password string
 }
 
-func InitTask(redisOpt TaskRedisOpt) {
+func InitTask(redisOpt TaskRedisOpt, appConfig *config.AppConfig) {
 	redisClientOpt := asynq.RedisClientOpt{
 		Addr:     redisOpt.Addr,
 		DB:       redisOpt.DB,
@@ -24,5 +25,5 @@ func InitTask(redisOpt TaskRedisOpt) {
 
 	tc := task_core.NewTaskCore(redisClientOpt)
 
-	taskQueue.InitTaskQueue(tc)
+	taskQueue.InitTaskQueue(tc, appConfig)
 }