// 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" ) // 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 分页查询优先轮询项,按创建时间倒序,并按当前账号数据范围下推。 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 } filter := postgres.PollingPriorityItemListFilter{ CardID: req.CardID, TaskType: req.TaskType, Status: req.Status, TriggerType: req.TriggerType, 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 提供中文名称。 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, 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, ClaimedAt: item.ClaimedAt, ManualReason: item.ManualReason, ManualOperator: item.ManualOperatorID, ManualOperatorNm: item.ManualOperatorName, ShopIDSnapshot: item.ShopIDSnapshot, Result: item.Result, ResultName: constants.PollingPriorityResultName(item.Result), FailureReason: item.FailureReason, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } }