Files
junhong_cmp_fiber/internal/query/carrierthreshold/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

168 lines
6.6 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 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
}