Files
junhong_cmp_fiber/internal/query/prioritypolling/query.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

227 lines
8.5 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 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()
}