// Package carrierthreshold 提供运营商通道阈值命中事实的只读查询投影。 // // 读取不经聚合根、不修改任何状态:命中事实是达量判定与周期恢复写入的冻结证据, // 本包只按既有权限与卡数据范围把它投影为可查询的列表,绝不回填或改写历史命中。 // 时间筛选只复用统一严格解析器(pkg/utils.ParseTimeRange):判定时间按闭区间筛选, // 计费周期起点按同一解析器的瞬时(退化为闭区间两端相同)精确匹配。 package carrierthreshold import ( "context" "time" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/utils" ) // periodStartFieldName 是计费周期起点参数的字段名,用于按本字段重述统一格式错误。 const periodStartFieldName = "period_start" // Query 通道阈值命中记录只读查询。 type Query struct { db *gorm.DB } // NewQuery 创建通道阈值命中记录查询。 func NewQuery(db *gorm.DB) *Query { return &Query{db: db} } // List 分页查询通道阈值命中记录,按判定时间倒序、同刻按 ID 倒序。 // // 权限与数据范围都在本方法内判定:仅超级管理员与平台账号可读(ENG-AUTHZ-001), // 并按卡的数据范围下推过滤,范围外卡的命中记录与不存在的记录同样不可见。 func (q *Query) List(ctx context.Context, req dto.CarrierThresholdHitListRequest) (*dto.CarrierThresholdHitPageResult, error) { if q == nil || q.db == nil { return nil, errors.New(errors.CodeInternalError, "通道阈值命中记录查询未配置") } if err := requirePlatformOperator(ctx); err != nil { return nil, err } start, end, err := utils.ParseTimeRange(req.StartTime, req.EndTime) if err != nil { return nil, err } periodStart, err := parsePeriodStart(req.PeriodStart) if err != nil { return nil, err } // 每次终结都重建查询:Count 与 Find 共用同一个链式对象会互相污染条件。 base := func() *gorm.DB { query := applyCardDataScope(ctx, q.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{})) if req.CarrierID != nil { query = query.Where("carrier_id = ?", *req.CarrierID) } if req.CardID != nil { query = query.Where("card_id = ?", *req.CardID) } if periodStart != nil { query = query.Where("period_start = ?", *periodStart) } if start != nil { query = query.Where("judged_at >= ?", *start) } if end != nil { query = query.Where("judged_at <= ?", *end) } return query } var total int64 if err := base().Count(&total).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计通道阈值命中记录失败") } page, pageSize := normalizePage(req.Page, req.PageSize) var locks []model.CarrierTrafficThresholdLock if err := base().Order("judged_at DESC, id DESC"). Offset((page - 1) * pageSize).Limit(pageSize).Find(&locks).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道阈值命中记录失败") } list := make([]*dto.CarrierThresholdHitItem, 0, len(locks)) for index := range locks { list = append(list, toHitItem(&locks[index])) } return &dto.CarrierThresholdHitPageResult{List: list, Total: total, Page: page, PageSize: pageSize}, nil } // toHitItem 把命中事实投影为响应项。 // // 历史行的空读数与空阈值原样保留为空指针,由读侧按「无」展示,绝不回填零值或当前配置; // 凭证与上游响应原文不落在本表,因此响应天然只含可安全展示的事实。 func toHitItem(lock *model.CarrierTrafficThresholdLock) *dto.CarrierThresholdHitItem { return &dto.CarrierThresholdHitItem{ ID: lock.ID, CarrierID: lock.CarrierID, CardID: lock.CardID, PeriodStart: lock.PeriodStart, HitTrafficMB: lock.HitTrafficMB, HitThresholdValue: lock.HitThresholdValue, HitThresholdUnit: lock.HitThresholdUnit, TriggerSource: lock.TriggerSource, JudgedAt: lock.JudgedAt, Status: lock.Status, UnlockedAt: lock.UnlockedAt, ResumeResult: lock.ResumeResult, FailureReason: lock.FailureReason, CreatedAt: lock.CreatedAt, } } // requirePlatformOperator 要求调用者为超级管理员或平台账号。 // 其他账号(代理、企业与个人客户)统一返回与资源不可见一致的拒绝,不产生可枚举差异。 func requirePlatformOperator(ctx context.Context) error { userType := middleware.GetUserTypeFromContext(ctx) if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } return nil } // applyCardDataScope 按卡的数据范围下推过滤。 // // 命中事实以 card_id 关联卡事实,卡的店铺归属取自 tb_iot_card.shop_id: // 范围为空表示不受限(超级管理员与平台账号当前无实际过滤,保留显式下推以冻结语义), // 范围外卡的命中记录对受限账号与不存在的记录同样不可见。 func applyCardDataScope(ctx context.Context, query *gorm.DB) *gorm.DB { shopIDs := middleware.GetSubordinateShopIDs(ctx) if len(shopIDs) == 0 { return query } return query.Where("card_id IN (SELECT id FROM tb_iot_card WHERE shop_id IN ? AND deleted_at IS NULL)", shopIDs) } // parsePeriodStart 解析计费周期起点筛选值;未传与空串等价。 // // 周期起点是单个瞬时,这里把同一个值同时作为闭区间两端交给统一严格解析器:格式接受范围、 // 归一 UTC 与闭区间语义因此与判定时间筛选完全一致,不复制第二套时间解析。 // 开始等于结束恒成立,解析失败只可能是格式错误,故按本字段名重述统一格式错误消息, // 避免把计费周期参数报成 start_time。 func parsePeriodStart(value string) (*time.Time, error) { if value == "" { return nil, nil } parsed, _, err := utils.ParseTimeRange(value, value) if err != nil { return nil, utils.TimeFilterFormatError(periodStartFieldName) } return parsed, nil } // normalizePage 归一化分页参数并执行既有上限(ENG-PAGE-001)。 func normalizePage(page, pageSize int) (int, int) { if page <= 0 { page = constants.DefaultPage } if pageSize <= 0 { pageSize = constants.DefaultPageSize } if pageSize > constants.MaxPageSize { pageSize = constants.MaxPageSize } return page, pageSize }