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