// Package prioritypolling 提供卡轮询优先项的只读查询投影。 // // 读取不经聚合根、不修改任何状态;数据范围按事实表冗余的卡店铺快照下推, // 平台卡(快照为空)与范围外的行对代理账号同等不可见,越权与不存在不得形成可枚举差异。 package prioritypolling import ( "context" stderrors "errors" "strings" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" pollingsvc "github.com/break/junhong_cmp_fiber/internal/service/polling" "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" "github.com/break/junhong_cmp_fiber/pkg/utils" ) // Query 优先轮询项只读查询。 type Query struct { db *gorm.DB store *postgres.PollingPriorityItemStore } // NewQuery 创建优先轮询项查询。 func NewQuery(db *gorm.DB, store *postgres.PollingPriorityItemStore) *Query { return &Query{db: db, store: store} } // List 分页查询优先轮询项,按创建时间倒序,并按当前账号数据范围下推。 // 时间范围按入队时间(created_at)取闭区间,复用统一严格时间解析器;解析失败按参数非法返回。 func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest) (*dto.PriorityPollingItemListResponse, error) { if q == nil || q.store == nil { return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置") } scope, err := priorityPollingShopScope(ctx) if err != nil { return nil, err } createdFrom, createdTo, err := utils.ParseTimeRange(req.StartTime, req.EndTime) if err != nil { return nil, err } filter := postgres.PollingPriorityItemListFilter{ CardID: req.CardID, TaskType: req.TaskType, Status: req.Status, TriggerType: req.TriggerType, CreatedFrom: createdFrom, CreatedTo: createdTo, AbnormalOnly: req.AbnormalOnly != nil && *req.AbnormalOnly, ShopIDSnapshot: scope, Page: req.Page, PageSize: req.PageSize, } items, total, err := q.store.List(ctx, filter) if err != nil { return nil, err } list, err := q.projectList(ctx, items) if err != nil { return nil, err } page, pageSize := normalizePage(req.Page, req.PageSize) return &dto.PriorityPollingItemListResponse{ List: list, Total: total, Page: page, PageSize: pageSize, }, nil } // Detail 查询优先轮询项详情,并按当前账号数据范围下推。 // 越权与不存在返回同一稳定错误,不提供可枚举差异。 func (q *Query) Detail(ctx context.Context, id uint) (*dto.PriorityPollingItemResponse, error) { if q == nil || q.store == nil { return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置") } scope, err := priorityPollingShopScope(ctx) if err != nil { return nil, err } item, err := q.store.GetByIDScoped(ctx, id, scope) if err != nil { if stderrors.Is(err, gorm.ErrRecordNotFound) { // 越权与不存在共用同一稳定错误,避免通过状态码枚举他人资源。 return nil, errors.New(errors.CodeForbidden, priorityPollingDeniedMessage) } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项详情失败") } iccid := q.loadICCID(ctx, []*model.PollingPriorityItem{item}) return toPriorityPollingItemResponse(item, iccid[item.CardID]), nil } // projectList 批量载入卡的 ICCID 后投影列表,避免逐行查询。 func (q *Query) projectList(ctx context.Context, items []*model.PollingPriorityItem) ([]*dto.PriorityPollingItemResponse, error) { if len(items) == 0 { return []*dto.PriorityPollingItemResponse{}, nil } iccids := q.loadICCID(ctx, items) list := make([]*dto.PriorityPollingItemResponse, 0, len(items)) for _, item := range items { if item == nil { continue } list = append(list, toPriorityPollingItemResponse(item, iccids[item.CardID])) } return list, nil } // loadICCID 按本批卡ID一次投影 ICCID,已删除的卡不返回,由响应保留空字符串。 func (q *Query) loadICCID(ctx context.Context, items []*model.PollingPriorityItem) map[uint]string { cardIDs := make([]uint, 0, len(items)) seen := make(map[uint]bool, len(items)) for _, item := range items { if item == nil || seen[item.CardID] { continue } seen[item.CardID] = true cardIDs = append(cardIDs, item.CardID) } result := make(map[uint]string, len(cardIDs)) if len(cardIDs) == 0 { return result } type cardICCIDRow struct { ID uint `gorm:"column:id"` ICCID string `gorm:"column:iccid"` } var rows []cardICCIDRow if err := q.db.WithContext(ctx). Model(&model.IotCard{}). Select("id, iccid"). Where("id IN ? AND deleted_at IS NULL", cardIDs). Scan(&rows).Error; err != nil { // 卡号只用于展示,查询失败降级为空号,不影响读取优先轮询事实。 return result } for _, row := range rows { result[row.ID] = row.ICCID } return result } // priorityPollingShopScope 返回当前账号可访问的店铺快照范围,并完成读侧鉴权。 // // 复用与人工入队同一套用户类型分类(internal/service/polling.PriorityPollingReadScope): // 只有超级管理员与平台账号不受限(nil);企业账号与未预计算范围的账号一律拒绝。 // 返回 nil 表示不受限,非 nil 时按列表下推,空切片表示空结果。 func priorityPollingShopScope(ctx context.Context) ([]uint, error) { return pollingsvc.PriorityPollingReadScope(ctx) } // priorityPollingDeniedMessage 与人工入队共用同一拒绝文案。 const priorityPollingDeniedMessage = "无权限操作该资源或资源不存在" // normalizePage 归一化分页参数并执行默认值与上限。 func normalizePage(page, pageSize int) (int, int) { if page < 1 { page = constants.DefaultPage } if pageSize < 1 || pageSize > constants.MaxPageSize { pageSize = constants.DefaultPageSize } return page, pageSize } // toPriorityPollingItemResponse 把事实行投影为读侧 DTO,枚举按 constants 提供中文名称。 // 有效期与「是否已过期」一律读事实状态,不按 next_run_at 现算:next_run_at 为空只表示未排期。 func toPriorityPollingItemResponse(item *model.PollingPriorityItem, iccid string) *dto.PriorityPollingItemResponse { if item == nil { return nil } return &dto.PriorityPollingItemResponse{ ID: item.ID, CardID: item.CardID, ICCID: iccid, AssetType: item.AssetType, AssetID: item.AssetID, DeviceNo: item.DeviceNoSnapshot, TaskType: item.TaskType, TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType), Status: item.Status, StatusName: constants.PollingPriorityStatusName(item.Status), TriggerType: item.TriggerType, TriggerTypeName: constants.PollingPriorityTriggerName(item.TriggerType), TriggerTypes: strings.Trim(item.TriggerTypes, ","), TriggerCount: item.TriggerCount, LastTriggeredAt: item.LastTriggeredAt, SourceOrderID: item.SourceOrderID, SourcePackageUse: item.SourcePackageUsageID, AttemptCount: item.AttemptCount, AttemptLimit: item.AttemptLimit, ClaimedAt: item.ClaimedAt, StartedAt: item.StartedAt, FinishedAt: item.FinishedAt, DurationMS: pollingPriorityDurationMS(item), NextRunAt: item.NextRunAt, PriorityEffectiveFrom: item.PriorityEffectiveFrom, DequeuedAt: item.DequeuedAt, ManualReason: item.ManualReason, ManualOperator: item.ManualOperatorID, ManualOperatorNm: item.ManualOperatorName, ShopIDSnapshot: item.ShopIDSnapshot, AgentShopID: item.AgentShopIDSnapshot, Result: item.Result, ResultName: constants.PollingPriorityResultName(item.Result), FailureReason: item.FailureReason, IntegrationLogID: item.IntegrationLogID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } // pollingPriorityDurationMS 返回最近一次执行的耗时毫秒数。 // 只有开始与结束时间都在时才有耗时;结束早于开始等异常数据按 0 处理,不返回负数。 func pollingPriorityDurationMS(item *model.PollingPriorityItem) int64 { if item == nil || item.StartedAt == nil || item.FinishedAt == nil { return 0 } if item.FinishedAt.Before(*item.StartedAt) { return 0 } return item.FinishedAt.Sub(*item.StartedAt).Milliseconds() }