simCardStatistics.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. package simCardQueue
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/base64"
  8. "encoding/json"
  9. "fmt"
  10. "fwh-toolbox-server/domain"
  11. "fwh-toolbox-server/repositories/mongo"
  12. "net/http"
  13. "net/url"
  14. "sort"
  15. "strconv"
  16. "strings"
  17. "time"
  18. "github.com/hibiken/asynq"
  19. "go.uber.org/zap"
  20. )
  21. const taskTypeSimCardDailyReport = "SimCard:DailyReport"
  22. const (
  23. simCardHeavyUsageKB = 100 * 1024 // 进入日报清单的日用量阈值(严格大于)
  24. simCardHeavyUsageBoldKB = 500 * 1024 // 清单中加粗显示的日用量阈值(严格大于)
  25. simCardHeavyUsageMaxRows = 30 // 清单最多列出的行数,避免超过钉钉消息长度上限
  26. )
  27. // simCardHeavyUsage 是日用量超过阈值的一张卡;SN 为空表示没有关联站点。
  28. type simCardHeavyUsage struct {
  29. ICCID string
  30. IncrementKB float64
  31. SN string
  32. Version string
  33. }
  34. type simCardDailyReport struct {
  35. Stats domain.SimCardDailyStatistics
  36. HeavyCount int // 超过阈值的卡总数
  37. Heavy []simCardHeavyUsage // 按日用量降序,最多 simCardHeavyUsageMaxRows 条
  38. SiteLookupFailed bool
  39. }
  40. func calculateSimCardStatistics(date time.Time) (domain.SimCardDailyStatistics, []simCardHeavyUsage, error) {
  41. stats := domain.SimCardDailyStatistics{Date: date.Format(time.DateOnly)}
  42. var heavy []simCardHeavyUsage
  43. err := mongo.StreamSimCardDailyUsage(date, func(simId string, total float64, previous *float64) error {
  44. if increment, ok := accumulateSimCardStatistics(&stats, total, previous); ok && increment > simCardHeavyUsageKB {
  45. heavy = append(heavy, simCardHeavyUsage{ICCID: simId, IncrementKB: increment})
  46. }
  47. return nil
  48. })
  49. return stats, heavy, err
  50. }
  51. // buildSimCardDailyReport 排序截断清单并补充站点信息;站点查询失败不影响统计部分。
  52. func buildSimCardDailyReport(stats domain.SimCardDailyStatistics, heavy []simCardHeavyUsage,
  53. lookup func(giccids []string) ([]domain.SiteInfoDomain, error)) simCardDailyReport {
  54. sort.SliceStable(heavy, func(i, j int) bool { return heavy[i].IncrementKB > heavy[j].IncrementKB })
  55. report := simCardDailyReport{Stats: stats, HeavyCount: len(heavy)}
  56. if len(heavy) > simCardHeavyUsageMaxRows {
  57. heavy = heavy[:simCardHeavyUsageMaxRows]
  58. }
  59. report.Heavy = heavy
  60. if len(heavy) == 0 {
  61. return report
  62. }
  63. ids := make([]string, 0, len(heavy))
  64. for _, h := range heavy {
  65. ids = append(ids, h.ICCID)
  66. }
  67. sites, err := lookup(ids)
  68. if err != nil {
  69. zap.L().Error("SIM card daily report: site lookup failed", zap.String("date", stats.Date), zap.Error(err))
  70. report.SiteLookupFailed = true
  71. return report
  72. }
  73. for i := range report.Heavy {
  74. var candidates []domain.SiteInfoDomain
  75. for _, site := range sites {
  76. if report.Heavy[i].ICCID != "" && strings.Contains(strings.ToUpper(site.ICCID), strings.ToUpper(report.Heavy[i].ICCID)) {
  77. candidates = append(candidates, site)
  78. }
  79. }
  80. if site := pickSimCardSite(candidates); site != nil {
  81. report.Heavy[i].SN = site.Sn
  82. report.Heavy[i].Version = site.Version
  83. }
  84. }
  85. return report
  86. }
  87. // pickSimCardSite 在一张卡关联的多个站点中选一个:优先在线站点(多个在线取最后上线最晚的),
  88. // 都不在线时取最后离线时间最接近现在的。时间格式为 YYYY-MM-DD HH:mm:SS,可直接按字符串比较。
  89. func pickSimCardSite(sites []domain.SiteInfoDomain) *domain.SiteInfoDomain {
  90. var best *domain.SiteInfoDomain
  91. for i := range sites {
  92. site := &sites[i]
  93. if best == nil || simCardSiteBetter(site, best) {
  94. best = site
  95. }
  96. }
  97. return best
  98. }
  99. func simCardSiteBetter(a, b *domain.SiteInfoDomain) bool {
  100. aOnline, bOnline := a.Status == 1, b.Status == 1
  101. if aOnline != bOnline {
  102. return aOnline
  103. }
  104. if aOnline {
  105. return derefString(a.OnLineTime) > derefString(b.OnLineTime)
  106. }
  107. return derefString(a.OffLineTime) > derefString(b.OffLineTime)
  108. }
  109. func derefString(s *string) string {
  110. if s == nil {
  111. return ""
  112. }
  113. return *s
  114. }
  115. // accumulateSimCardStatistics 返回该卡的日增量;没有前一日数据时 ok 为 false。
  116. func accumulateSimCardStatistics(stats *domain.SimCardDailyStatistics, total float64, previous *float64) (increment float64, ok bool) {
  117. stats.CardCount++
  118. if total > stats.MaxTotalKB {
  119. stats.MaxTotalKB = total
  120. }
  121. if total > 200*1024 {
  122. stats.TotalOver200MB++
  123. }
  124. if total > 1024*1024 {
  125. stats.TotalOver1GB++
  126. }
  127. if total > 2*1024*1024 {
  128. stats.TotalOver2GB++
  129. }
  130. if previous == nil {
  131. return 0, false
  132. }
  133. increment = total - *previous
  134. // Monthly counters can reset at a cycle boundary. The new counter then
  135. // represents the usage accumulated since the reset.
  136. if increment < 0 {
  137. increment = total
  138. }
  139. stats.IncrementCardCount++
  140. if stats.MaxIncrementKB == nil || increment > *stats.MaxIncrementKB {
  141. stats.MaxIncrementKB = &increment
  142. }
  143. if increment > 20*1024 {
  144. stats.IncrementOver20MB++
  145. }
  146. if increment > 50*1024 {
  147. stats.IncrementOver50MB++
  148. }
  149. if increment > 200*1024 {
  150. stats.IncrementOver200MB++
  151. }
  152. return increment, true
  153. }
  154. func (processor *SimCardProcessor) enqueueSimCardReportIfComplete(date string) error {
  155. if processor.webhook == "" {
  156. return nil
  157. }
  158. complete, err := mongo.IsSimCardRunComplete(date)
  159. if err != nil || !complete {
  160. return err
  161. }
  162. _, err = TaskClient.Enqueue(asynq.NewTask(taskTypeSimCardDailyReport, []byte(date),
  163. asynq.Queue("default"), asynq.MaxRetry(10), asynq.Timeout(3*time.Minute),
  164. asynq.TaskID("sim-card-daily-report-"+date), asynq.Retention(7*24*time.Hour)))
  165. if err == asynq.ErrTaskIDConflict {
  166. return nil
  167. }
  168. return err
  169. }
  170. func (processor *SimCardProcessor) handleSimCardDailyReport(ctx context.Context, task *asynq.Task) error {
  171. if processor.webhook == "" {
  172. return nil
  173. }
  174. dateStr := string(task.Payload())
  175. date, err := time.Parse(time.DateOnly, dateStr)
  176. if err != nil {
  177. return err
  178. }
  179. complete, err := mongo.IsSimCardRunComplete(dateStr)
  180. if err != nil || !complete {
  181. return err
  182. }
  183. stats, heavy, err := calculateSimCardStatistics(date)
  184. if err != nil {
  185. return err
  186. }
  187. if err := mongo.SaveSimCardDailyStatistics(stats); err != nil {
  188. return err
  189. }
  190. report := buildSimCardDailyReport(stats, heavy, mongo.GetSiteInfoListFromGiccids)
  191. if err := sendSimCardStatistics(ctx, processor.webhook, processor.secret, report); err != nil {
  192. return err
  193. }
  194. return mongo.MarkSimCardReportSent(dateStr)
  195. }
  196. func formatSimCardDailyReport(report simCardDailyReport) string {
  197. var b strings.Builder
  198. b.WriteString(formatSimCardStatistics(report.Stats))
  199. fmt.Fprintf(&b, "\n\n日用量超过 %d MB 的站点(%d 张卡,SN · 日用量 · 固件版本):\n\n",
  200. simCardHeavyUsageKB/1024, report.HeavyCount)
  201. if report.HeavyCount == 0 {
  202. b.WriteString("- 无")
  203. return b.String()
  204. }
  205. if report.SiteLookupFailed {
  206. b.WriteString("> 站点信息查询失败,以下仅列出 ICCID\n\n")
  207. }
  208. for i, h := range report.Heavy {
  209. name, version := h.SN, h.Version
  210. if name == "" {
  211. name = "ICCID " + h.ICCID
  212. if !report.SiteLookupFailed {
  213. name += "(未绑定站点)"
  214. }
  215. }
  216. if version == "" {
  217. version = "-"
  218. }
  219. line := fmt.Sprintf("%s · %.2f MB · %s", name, h.IncrementKB/1024, version)
  220. if h.IncrementKB > simCardHeavyUsageBoldKB {
  221. line = "**" + line + "**"
  222. }
  223. if i > 0 {
  224. b.WriteString("\n")
  225. }
  226. b.WriteString("- " + line)
  227. }
  228. if rest := report.HeavyCount - len(report.Heavy); rest > 0 {
  229. fmt.Fprintf(&b, "\n- 其余 %d 张省略", rest)
  230. }
  231. return b.String()
  232. }
  233. func formatSimCardStatistics(stats domain.SimCardDailyStatistics) string {
  234. maxIncrement := "无可比数据"
  235. if stats.MaxIncrementKB != nil {
  236. maxIncrement = fmt.Sprintf("%.2f MB", *stats.MaxIncrementKB/1024)
  237. }
  238. return fmt.Sprintf("### SIM 卡流量日报(%s UTC)\n\n"+
  239. "总流量(%d 张卡,月累计):\n\n"+
  240. "- 超过 200 MB:%d 张\n- 超过 1 GB:%d 张\n- 超过 2 GB:%d 张\n- 最大总量:%.2f MB\n\n"+
  241. "增量流量(%d 张卡有前一日数据):\n\n"+
  242. "- 超过 20 MB:%d 张\n- 超过 50 MB:%d 张\n- 超过 200 MB:%d 张\n- 最大增量:%s",
  243. stats.Date, stats.CardCount, stats.TotalOver200MB, stats.TotalOver1GB, stats.TotalOver2GB,
  244. stats.MaxTotalKB/1024, stats.IncrementCardCount, stats.IncrementOver20MB,
  245. stats.IncrementOver50MB, stats.IncrementOver200MB, maxIncrement)
  246. }
  247. func sendSimCardStatistics(ctx context.Context, webhook, secret string, report simCardDailyReport) error {
  248. return postSimCardStatistics(ctx, &http.Client{Timeout: 10 * time.Second}, webhook, secret, report)
  249. }
  250. func postSimCardStatistics(ctx context.Context, client *http.Client, webhook, secret string, report simCardDailyReport) error {
  251. if webhook == "" {
  252. return fmt.Errorf("SIM_CARD_DINGTALK_WEBHOOK is not configured")
  253. }
  254. u, err := url.Parse(webhook)
  255. if err != nil || u.Scheme != "https" || u.Host == "" {
  256. return fmt.Errorf("SIM_CARD_DINGTALK_WEBHOOK must be an HTTPS URL")
  257. }
  258. if secret != "" {
  259. timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
  260. mac := hmac.New(sha256.New, []byte(secret))
  261. _, _ = mac.Write([]byte(timestamp + "\n" + secret))
  262. query := u.Query()
  263. query.Set("timestamp", timestamp)
  264. query.Set("sign", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
  265. u.RawQuery = query.Encode()
  266. }
  267. message := map[string]any{"msgtype": "markdown", "markdown": map[string]string{
  268. "title": "SIM 卡流量日报 " + report.Stats.Date,
  269. "text": formatSimCardDailyReport(report),
  270. }}
  271. body, err := json.Marshal(message)
  272. if err != nil {
  273. return err
  274. }
  275. req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
  276. if err != nil {
  277. return err
  278. }
  279. req.Header.Set("Content-Type", "application/json")
  280. resp, err := client.Do(req)
  281. if err != nil {
  282. return fmt.Errorf("send SIM card statistics to DingTalk: %w", err)
  283. }
  284. defer resp.Body.Close()
  285. var result struct {
  286. ErrCode *int `json:"errcode"`
  287. ErrMsg string `json:"errmsg"`
  288. }
  289. if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
  290. return err
  291. }
  292. if resp.StatusCode != http.StatusOK || result.ErrCode == nil || *result.ErrCode != 0 {
  293. return fmt.Errorf("DingTalk rejected SIM card statistics: HTTP %d, code %v, %s", resp.StatusCode, result.ErrCode, result.ErrMsg)
  294. }
  295. return nil
  296. }