Files
junhong_cmp_fiber/internal/service/polling/gateway_call_query.go
邹进 94cbf2adab feat: 采集记录成功失败分表并保存网关原始数据,到期归档 csv.gz,新增记录分页查询接口
- 网关客户端新增原始数据收集器,记录最后一次尝试的明文请求、HTTP 状态、响应体与 code/msg/trace_id
- tb_card_traffic_collection 只写成功读数,新增 carrier_id 与原始数据列;新增 tb_card_traffic_collection_failure,失败不判断有无套餐并带错误详情
- 采集记录在线保留当月加最近 2 个整月,到期分区摘出、导出 csv.gz、校验行数与 SHA-256 后删除,本地保留 3 个月
- 新增采集成功/失败、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试列表接口:可查单个也可查全部,分页与条件筛选
- 停复机记录支持资产标识、场景、原因、指令号筛选;单卡历史支持动作、结果、来源筛选
- 单卡采集记录支持筛选与合并失败记录;套餐日流量详单支持分页;退款列表支持后处理状态筛选
- 测试环境 api、worker 日志分目录,保留 40 个压缩备份;worker 挂载 archive 目录
- 迁移 000272、000273 需维护者在测试库执行后再部署

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:47:00 +09:00

397 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package polling
import (
"context"
"sort"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 分钟计数 Hash 字段(与 gatewaylimit.Guard 写入口径一致)。
const (
statAttempts = "attempts"
statDurationSum = "duration_ms_sum"
statWouldLimit = "would_limit"
statPrefixDur = "duration_le:"
statPrefixExem = "exempt:"
statPrefixUp = "upstream:"
)
// statDurationBuckets 与写入端分桶上界一致(毫秒)。
var statDurationBuckets = []string{"100", "300", "500", "1000", "3000", "10000", "60000", "inf"}
// ListStopResumeRecords 按条件查询停复机记录;时间范围必填且跨度不超过 31 天。
func (s *GatewayRateLimitService) ListStopResumeRecords(ctx context.Context, req *dto.StopResumeRecordListRequest) (*dto.StopResumeRecordPageResult, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
if req.StartTime.IsZero() || req.EndTime.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:开始时间与结束时间必填")
}
if !req.EndTime.After(req.StartTime) || req.EndTime.Sub(req.StartTime) > time.Duration(constants.StopResumeRecordQueryMaxDays)*24*time.Hour {
return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:时间跨度须大于 0 且不超过 31 天")
}
page, size := normalizePage(req.Page, req.PageSize)
filter := postgres.StopResumeRecordFilter{
ICCID: strings.TrimSpace(req.ICCID), CarrierID: req.CarrierID, Action: req.Action, Source: req.Source,
Result: req.Result, ErrorCategory: req.ErrorCategory, StartTime: req.StartTime, EndTime: req.EndTime,
Scene: req.Scene, Reason: req.Reason, CommandID: strings.TrimSpace(req.CommandID), Page: page, PageSize: size,
}
if identifier := strings.TrimSpace(req.Identifier); identifier != "" {
cardIDs, err := s.cardIDsByIdentifier(ctx, identifier)
if err != nil {
return nil, err
}
filter.CardIDs = cardIDs
}
return s.listRecords(ctx, filter)
}
// ListCardStopResumeRecords 查询单卡停复机历史;未传时间默认最近 31 天。
func (s *GatewayRateLimitService) ListCardStopResumeRecords(ctx context.Context, req *dto.CardStopResumeRecordListRequest) (*dto.StopResumeRecordPageResult, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
var count int64
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).Where("id = ?", req.ID).Count(&count).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败")
}
if count == 0 {
return nil, errors.New(errors.CodeNotFound, "IoT 卡不存在")
}
end := req.EndTime
if end.IsZero() {
end = time.Now()
}
start := req.StartTime
if start.IsZero() {
start = end.AddDate(0, 0, -constants.StopResumeRecordQueryMaxDays)
}
if !end.After(start) || end.Sub(start) > time.Duration(constants.StopResumeRecordQueryMaxDays)*24*time.Hour {
return nil, errors.New(errors.CodeInvalidParam, "停复机记录查询参数不合法:时间跨度须大于 0 且不超过 31 天")
}
page, size := normalizePage(req.Page, req.PageSize)
cardID := req.ID
return s.listRecords(ctx, postgres.StopResumeRecordFilter{CardID: &cardID, StartTime: start, EndTime: end, Page: page, PageSize: size,
Action: req.Action, Result: req.Result, Source: req.Source})
}
// cardIDsByIdentifier 把资产标识解析为卡:卡按虚拟号、ICCID、MSISDN 匹配,设备按虚拟号、IMEI、SN 匹配后取其绑定卡。
// 匹配不到时返回空切片(查询结果为空页)。
func (s *GatewayRateLimitService) cardIDsByIdentifier(ctx context.Context, identifier string) ([]uint, error) {
cardIDs := []uint{}
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("iccid = ? OR virtual_no = ? OR msisdn = ?", identifier, identifier, identifier).
Pluck("id", &cardIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产标识查询卡失败")
}
if len(cardIDs) > 0 {
return cardIDs, nil
}
if err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Joins("JOIN tb_device d ON d.id = tb_device_sim_binding.device_id AND d.deleted_at IS NULL").
Where("(d.virtual_no = ? OR d.imei = ? OR d.sn = ?) AND tb_device_sim_binding.bind_status = ?", identifier, identifier, identifier, constants.BindStatusBound).
Pluck("tb_device_sim_binding.iot_card_id", &cardIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产标识查询设备绑定卡失败")
}
return cardIDs, nil
}
func (s *GatewayRateLimitService) listRecords(ctx context.Context, filter postgres.StopResumeRecordFilter) (*dto.StopResumeRecordPageResult, error) {
rows, total, err := postgres.NewCardStopResumeRecordStore(s.db).List(ctx, filter)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询停复机记录失败")
}
items := make([]dto.StopResumeRecordResponse, 0, len(rows))
for _, row := range rows {
items = append(items, dto.StopResumeRecordResponse{
ID: row.ID, CardID: row.CardID, ICCID: row.ICCID, CarrierID: row.CarrierID, Action: row.Action,
ActionName: stopResumeActionName(row.Action), Source: row.Source, Scene: row.Scene, Reason: row.Reason,
CommandID: row.CommandID, Attempt: row.Attempt, Result: row.Result, ErrorCategory: row.ErrorCategory,
ErrorCategoryName: errorCategoryName(row.ErrorCategory), ProviderCode: row.ProviderCode,
ProviderMessage: row.ProviderMessage, DurationMS: row.DurationMS, CreatedAt: row.CreatedAt,
})
}
return &dto.StopResumeRecordPageResult{List: items, Total: total, Page: filter.Page, PageSize: filter.PageSize}, nil
}
// CarrierCallStats 汇总最近若干分钟各运营商账户的网关调用(只返回有调用的账户)。
func (s *GatewayRateLimitService) CarrierCallStats(ctx context.Context, redisClient *redis.Client, req *dto.GatewayCallStatsRequest) (*dto.GatewayCallStatsResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
minutes := req.Minutes
if minutes <= 0 {
minutes = 15
}
names, err := s.carrierNames(ctx)
if err != nil {
return nil, err
}
carrierIDs := []uint{0}
for id := range names {
carrierIDs = append(carrierIDs, id)
}
sort.Slice(carrierIDs, func(i, j int) bool { return carrierIDs[i] < carrierIDs[j] })
stamps := minuteStamps(time.Now(), minutes)
data, err := loadStatHashes(ctx, redisClient, carrierIDs, stamps)
if err != nil {
return nil, err
}
resp := &dto.GatewayCallStatsResponse{Mode: s.currentMode(ctx), Minutes: minutes, Carriers: []dto.GatewayCarrierStat{}}
for _, carrierID := range carrierIDs {
var ops []dto.GatewayOperationStat
for _, op := range constants.GatewayCardOperations {
stat := aggregateOperation(op, data[statKey{carrierID, op}], len(stamps))
if stat.Attempts > 0 || stat.RateLimited > 0 || stat.WouldLimit > 0 {
ops = append(ops, stat)
}
}
if len(ops) == 0 {
continue
}
name := names[carrierID]
if carrierID == 0 {
name = "未知账户"
}
resp.Carriers = append(resp.Carriers, dto.GatewayCarrierStat{CarrierID: carrierID, CarrierName: name, Operations: ops})
}
return resp, nil
}
// CarrierCallTrend 返回单个运营商账户按时间桶聚合的网关调用趋势。
func (s *GatewayRateLimitService) CarrierCallTrend(ctx context.Context, redisClient *redis.Client, req *dto.GatewayCallTrendRequest) (*dto.GatewayCallTrendResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
minutes, bucket := req.Minutes, req.BucketMinutes
if minutes <= 0 {
minutes = 60
}
if bucket <= 0 {
bucket = 1
}
stamps := minuteStamps(time.Now(), minutes)
data, err := loadStatHashes(ctx, redisClient, []uint{req.CarrierID}, stamps)
if err != nil {
return nil, err
}
resp := &dto.GatewayCallTrendResponse{CarrierID: req.CarrierID, Points: []dto.GatewayCallTrendPoint{}}
for start := 0; start < len(stamps); start += bucket {
end := start + bucket
if end > len(stamps) {
end = len(stamps)
}
point := dto.GatewayCallTrendPoint{Time: formatStamp(stamps[start]), Operations: []dto.GatewayOperationStat{}}
for _, op := range constants.GatewayCardOperations {
series := data[statKey{req.CarrierID, op}]
if series == nil {
continue
}
stat := aggregateOperation(op, series[start:end], end-start)
if stat.Attempts > 0 || stat.RateLimited > 0 || stat.WouldLimit > 0 {
point.Operations = append(point.Operations, stat)
}
}
resp.Points = append(resp.Points, point)
}
return resp, nil
}
func (s *GatewayRateLimitService) currentMode(ctx context.Context) string {
if s.runtime == nil {
return constants.RateLimitModeObserve
}
return s.runtime.Settings.Current(ctx).Mode
}
type statKey struct {
carrierID uint
op string
}
// minuteStamps 返回从最早到最近的分钟标识(上海时区 yyyyMMddHHmm)。
func minuteStamps(now time.Time, minutes int) []string {
location := shanghaiLocation()
current := now.In(location).Truncate(time.Minute)
stamps := make([]string, minutes)
for i := 0; i < minutes; i++ {
stamps[minutes-1-i] = current.Add(-time.Duration(i) * time.Minute).Format("200601021504")
}
return stamps
}
func formatStamp(stamp string) string {
t, err := time.ParseInLocation("200601021504", stamp, shanghaiLocation())
if err != nil {
return stamp
}
return t.Format("2006-01-02 15:04")
}
// loadStatHashes 用管道批量读取分钟计数;返回每个账户 × 操作按分钟顺序的 Hash 列表。
func loadStatHashes(ctx context.Context, redisClient *redis.Client, carrierIDs []uint, stamps []string) (map[statKey][]map[string]string, error) {
result := map[statKey][]map[string]string{}
if redisClient == nil {
return result, nil
}
type pending struct {
key statKey
idx int
cmd *redis.MapStringStringCmd
}
pipe := redisClient.Pipeline()
cmds := make([]pending, 0, len(carrierIDs)*len(constants.GatewayCardOperations)*len(stamps))
for _, carrierID := range carrierIDs {
for _, op := range constants.GatewayCardOperations {
key := statKey{carrierID, op}
result[key] = make([]map[string]string, len(stamps))
for idx, stamp := range stamps {
cmds = append(cmds, pending{key: key, idx: idx, cmd: pipe.HGetAll(ctx, constants.RedisGatewayCallStatKey(carrierID, op, stamp))})
}
}
}
if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "读取网关调用统计失败")
}
for _, item := range cmds {
values, err := item.cmd.Result()
if err == nil && len(values) > 0 {
result[item.key][item.idx] = values
}
}
return result, nil
}
// aggregateOperation 汇总一个操作在若干分钟内的计数。
func aggregateOperation(op string, minutes []map[string]string, minuteCount int) dto.GatewayOperationStat {
stat := dto.GatewayOperationStat{Operation: op, OperationName: constants.GatewayOperationName(op), Failures: map[string]int64{}, Exempt: map[string]int64{}}
buckets := map[string]int64{}
var durationSum int64
for _, values := range minutes {
var minuteAttempts int64
for field, raw := range values {
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
continue
}
switch {
case field == statAttempts:
stat.Attempts += value
minuteAttempts = value
case field == statDurationSum:
durationSum += value
case field == statWouldLimit:
stat.WouldLimit += value
case field == constants.GatewayErrorRateLimited:
stat.RateLimited += value
case field == constants.GatewayResultSuccess:
stat.Success += value
case strings.HasPrefix(field, statPrefixDur):
buckets[strings.TrimPrefix(field, statPrefixDur)] += value
case strings.HasPrefix(field, statPrefixExem):
stat.Exempt[strings.TrimPrefix(field, statPrefixExem)] += value
default:
stat.Failures[field] += value
}
}
if qps := float64(minuteAttempts) / 60; qps > stat.PeakMinuteQPS {
stat.PeakMinuteQPS = qps
}
}
if minuteCount > 0 {
stat.AvgQPS = float64(stat.Attempts) / float64(minuteCount*60)
}
if stat.Attempts > 0 {
stat.SuccessRate = float64(stat.Success) / float64(stat.Attempts)
stat.AvgDurationMS = float64(durationSum) / float64(stat.Attempts)
stat.P95DurationMS = percentileBucket(buckets, stat.Attempts, 0.95)
}
return stat
}
// percentileBucket 返回累计占比达到 p 的分桶上界;落在最后一个分桶时返回 -1。
func percentileBucket(buckets map[string]int64, total int64, p float64) int64 {
threshold := int64(float64(total)*p + 0.5)
var cumulative int64
for _, upper := range statDurationBuckets {
cumulative += buckets[upper]
if cumulative >= threshold {
if upper == "inf" {
return -1
}
value, _ := strconv.ParseInt(upper, 10, 64)
return value
}
}
return -1
}
func shanghaiLocation() *time.Location {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("CST", 8*3600)
}
return location
}
func stopResumeActionName(action string) string {
if action == constants.StopResumeActionStart {
return "复机"
}
return "停机"
}
func errorCategoryName(category string) string {
switch category {
case "":
return ""
case constants.GatewayErrorNetwork:
return "网络错误"
case constants.GatewayErrorTimeout:
return "请求超时"
case constants.GatewayErrorHTTP:
return "HTTP 异常"
case constants.GatewayErrorInvalidResponse:
return "响应无效"
default:
return constants.GatewayErrorClassName(category)
}
}
// PollingOptions 返回轮询与网关限流相关的前端枚举,前端不写死取值。
func (s *GatewayRateLimitService) PollingOptions(ctx context.Context) (*dto.PollingOptionsResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
resp := &dto.PollingOptionsResponse{}
for _, op := range constants.GatewayCardOperations {
resp.GatewayOperations = append(resp.GatewayOperations, dto.PollingOptionItem{Value: op, Label: constants.GatewayOperationName(op)})
}
for _, class := range constants.GatewayErrorClasses {
resp.ErrorCategories = append(resp.ErrorCategories, dto.PollingOptionItem{Value: class, Label: constants.GatewayErrorClassName(class)})
}
resp.MatchTypes = []dto.PollingOptionItem{{Value: constants.GatewayMatchContains, Label: "包含"}, {Value: constants.GatewayMatchPrefix, Label: "前缀"}, {Value: constants.GatewayMatchRegex, Label: "正则"}}
resp.RateLimitModes = []dto.PollingOptionItem{{Value: constants.RateLimitModeObserve, Label: "只统计不拦截"}, {Value: constants.RateLimitModeEnforce, Label: "拦截"}}
resp.ExemptSources = []dto.PollingOptionItem{
{Value: constants.RateLimitExemptManualClick, Label: "后台人工点击"}, {Value: constants.RateLimitExemptChannelThreshold, Label: "通道阈值"},
{Value: constants.RateLimitExemptAutoRenewal, Label: "自动续费"}, {Value: constants.RateLimitExemptManualRefresh, Label: "手动刷新"},
}
resp.StopResumeResults = []dto.PollingOptionItem{
{Value: constants.StopResumeRecordSuccess, Label: "成功"}, {Value: constants.StopResumeRecordFailed, Label: "失败"},
{Value: constants.StopResumeRecordUnknown, Label: "结果未知"}, {Value: constants.StopResumeRecordAlreadyTarget, Label: "已是目标状态"},
{Value: constants.StopResumeRecordSkipped, Label: "跳过"},
}
resp.StopResumeActions = []dto.PollingOptionItem{{Value: constants.StopResumeActionStop, Label: "停机"}, {Value: constants.StopResumeActionStart, Label: "复机"}}
return resp, nil
}