// Package packagetrafficalert 提供套餐真流量预警规则与达量预警的只读投影。 // Query 只做筛选、分页与 DTO 投影,不修改任何状态;越权与不存在统一按资源不可见处理。 package packagetrafficalert import ( "context" "strings" "time" "gorm.io/gorm" domainpackagetrafficalert "github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert" "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" ) // shanghaiLocation 是剩余天数推算使用的上海时区。 var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) // Query 查询套餐真流量预警规则与达量预警。 type Query struct { db *gorm.DB // now 可在验证时替换,默认使用系统时间。 now func() time.Time } // NewQuery 创建套餐真流量预警查询。 func NewQuery(db *gorm.DB) *Query { return &Query{db: db, now: func() time.Time { return time.Now().UTC() }} } // ListRules 分页查询套餐真流量预警规则。 // 规则列表返回套餐名称与商品当前真流量额度,供维护页核对配置合法性;商品额度不作为预警分母。 func (q *Query) ListRules(ctx context.Context, request dto.ListPackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleListResponse, error) { if q == nil || q.db == nil { return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置") } if err := requirePlatformOperator(ctx); err != nil { return nil, err } page, pageSize := normalizePage(request.Page, request.PageSize) var rows []ruleRow query := q.db.WithContext(ctx).Table("tb_package_traffic_alert_rule AS r"). Joins("LEFT JOIN tb_package AS p ON p.id = r.package_id AND p.deleted_at IS NULL") if request.PackageID != nil { query = query.Where("r.package_id = ?", *request.PackageID) } if request.Enabled != nil { query = query.Where("r.enabled = ?", boolToStatus(*request.Enabled)) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则总数失败") } if err := query.Select("r.id, r.package_id, r.threshold_percent, r.enabled, r.remark, r.updated_at, " + "COALESCE(p.package_name, '') AS package_name, COALESCE(p.real_data_mb, 0) AS real_data_mb"). Order("r.updated_at DESC, r.id DESC"). Offset((page - 1) * pageSize).Limit(pageSize). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则列表失败") } items := make([]dto.PackageTrafficAlertRuleItem, 0, len(rows)) for _, row := range rows { items = append(items, dto.PackageTrafficAlertRuleItem{ ID: row.ID, PackageID: row.PackageID, PackageName: row.PackageName, RealDataMB: row.RealDataMB, ThresholdPercent: row.ThresholdPercent, Enabled: row.Enabled == constants.StatusEnabled, EnabledName: enabledStatusName(row.Enabled), Remark: row.Remark, UpdatedAt: row.UpdatedAt, }) } return &dto.PackageTrafficAlertRuleListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil } type ruleRow struct { ID uint `gorm:"column:id"` PackageID uint `gorm:"column:package_id"` PackageName string `gorm:"column:package_name"` RealDataMB int64 `gorm:"column:real_data_mb"` ThresholdPercent float64 `gorm:"column:threshold_percent"` Enabled int `gorm:"column:enabled"` Remark string `gorm:"column:remark"` UpdatedAt time.Time `gorm:"column:updated_at"` } // ListAlerts 分页查询套餐真流量达量预警。 // 先应用既有资产数据范围(当前对超级管理员与平台无实际过滤,保留为冻结语义与未来放开的前置), // 再按套餐、店铺、业务员、资产/卡标识、阈值、触发时间与通知投递结果筛选。 func (q *Query) ListAlerts(ctx context.Context, request dto.ListPackageTrafficAlertRequest) (*dto.PackageTrafficAlertListResponse, error) { if q == nil || q.db == nil { return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置") } if err := requirePlatformOperator(ctx); err != nil { return nil, err } page, pageSize := normalizePage(request.Page, request.PageSize) query := q.applyAlertFilters(ctx, q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}), request) var total int64 if err := query.Count(&total).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警总数失败") } var alerts []*model.PackageTrafficAlert if err := query.Order("triggered_at DESC, id DESC"). Offset((page - 1) * pageSize).Limit(pageSize). Find(&alerts).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警列表失败") } items, err := q.projectAlerts(ctx, alerts, false) if err != nil { return nil, err } return &dto.PackageTrafficAlertListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil } // GetAlert 查询单条达量预警详情。 // 越权与不存在统一返回资源不可见错误,不形成可枚举差异。 func (q *Query) GetAlert(ctx context.Context, alertID uint) (*dto.PackageTrafficAlertDetailResponse, error) { if q == nil || q.db == nil { return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置") } if alertID == 0 { return nil, errors.New(errors.CodeInvalidParam) } if err := requirePlatformOperator(ctx); err != nil { return nil, err } var alert model.PackageTrafficAlert err := q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}). Where("id = ?", alertID). Scopes(func(scopeQuery *gorm.DB) *gorm.DB { return applyAssetDataScope(ctx, scopeQuery, "tb_package_traffic_alert.shop_id_snapshot") }). First(&alert).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, invisibleAlertError() } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警详情失败") } items, projectErr := q.projectAlerts(ctx, []*model.PackageTrafficAlert{&alert}, true) if projectErr != nil { return nil, projectErr } detail := &dto.PackageTrafficAlertDetailResponse{ PackageTrafficAlertItem: items[0], NotificationEventID: alert.NotificationEventID, } detail.NotificationSummary = notificationSummary(items[0].NotificationStatus) detail.ShopChangedSinceTrigger, detail.OwnerChangedSinceTrigger = q.detectOwnershipDrift(ctx, &alert) return detail, nil } // applyAlertFilters 应用数据范围与筛选条件。 func (q *Query) applyAlertFilters(ctx context.Context, query *gorm.DB, request dto.ListPackageTrafficAlertRequest) *gorm.DB { query = applyAssetDataScope(ctx, query, "tb_package_traffic_alert.shop_id_snapshot") if request.PackageID != nil { query = query.Where("tb_package_traffic_alert.package_id = ?", *request.PackageID) } if request.ShopID != nil { query = query.Where("tb_package_traffic_alert.shop_id_snapshot = ?", *request.ShopID) } if request.BusinessOwnerAccountID != nil { query = query.Where("tb_package_traffic_alert.business_owner_account_id_snapshot = ?", *request.BusinessOwnerAccountID) } if request.AssetType != "" { query = query.Where("tb_package_traffic_alert.asset_type = ?", request.AssetType) } if keyword := strings.TrimSpace(request.AssetIdentifier); keyword != "" { pattern := "%" + keyword + "%" query = query.Where("(tb_package_traffic_alert.asset_identifier_snapshot ILIKE ? "+ "OR tb_package_traffic_alert.card_identifier_snapshot ILIKE ? "+ "OR tb_package_traffic_alert.counterpart_identifier_snapshot ILIKE ?)", pattern, pattern, pattern) } if request.ThresholdPercent != nil { query = query.Where("tb_package_traffic_alert.threshold_percent_snapshot = ?", domainpackagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent)) } if request.StartTime != nil { query = query.Where("tb_package_traffic_alert.triggered_at >= ?", request.StartTime.UTC()) } if request.EndTime != nil { query = query.Where("tb_package_traffic_alert.triggered_at <= ?", request.EndTime.UTC()) } return applyNotificationStatusFilter(query, request.NotificationStatus) } // projectAlerts 批量投影预警列表项。 // 冻结快照直接读预警行;通知投递结果由可靠通知事件、Outbox 状态与站内通知事实交叉推导; // 用户组按冻结的业务员账号实时推导(账号不变则稳定),不写入店铺表。 func (q *Query) projectAlerts(ctx context.Context, alerts []*model.PackageTrafficAlert, withReadState bool) ([]dto.PackageTrafficAlertItem, error) { items := make([]dto.PackageTrafficAlertItem, 0, len(alerts)) if len(alerts) == 0 { return items, nil } eventIDs := make([]string, 0, len(alerts)) ownerIDs := make([]uint, 0, len(alerts)) for _, alert := range alerts { if alert.NotificationEventID != "" { eventIDs = append(eventIDs, alert.NotificationEventID) } if alert.BusinessOwnerAccountIDSnapshot != nil && *alert.BusinessOwnerAccountIDSnapshot > 0 { ownerIDs = append(ownerIDs, *alert.BusinessOwnerAccountIDSnapshot) } } outboxStates, err := q.loadOutboxStates(ctx, eventIDs) if err != nil { return nil, err } notificationStates, err := q.loadNotificationStates(ctx, eventIDs) if err != nil { return nil, err } groupNames, err := q.loadBusinessUserGroupNames(ctx, ownerIDs) if err != nil { return nil, err } now := q.now() for _, alert := range alerts { item := dto.PackageTrafficAlertItem{ ID: alert.ID, PackageUsageID: alert.PackageUsageID, PackageID: alert.PackageID, PackageName: alert.PackageNameSnapshot, AssetType: alert.AssetType, AssetID: alert.AssetID, AssetIdentifier: alert.AssetIdentifierSnapshot, CardIdentifier: alert.CardIdentifierSnapshot, CounterpartIdentifier: alert.CounterpartIdentifierSnapshot, DeviceType: alert.DeviceTypeSnapshot, DeviceModel: alert.DeviceModelSnapshot, UsedMB: alert.UsedMBSnapshot, LimitMB: alert.LimitMBSnapshot, UsagePercent: alert.UsagePercentSnapshot, ThresholdPercent: alert.ThresholdPercentSnapshot, ExpiresAt: alert.ExpiresAtSnapshot, TriggeredAt: alert.TriggeredAt, ShopName: alert.ShopNameSnapshot, BusinessOwnerName: alert.BusinessOwnerNameSnapshot, BusinessUserGroupNames: []string{}, } if alert.ShopIDSnapshot > 0 { shopID := alert.ShopIDSnapshot item.ShopID = &shopID } if alert.BusinessOwnerAccountIDSnapshot != nil && *alert.BusinessOwnerAccountIDSnapshot > 0 { ownerID := *alert.BusinessOwnerAccountIDSnapshot item.BusinessOwnerAccountID = &ownerID if names, ok := groupNames[ownerID]; ok { item.BusinessUserGroupNames = names } } if alert.ExpiresAtSnapshot != nil { days := daysUntil(*alert.ExpiresAtSnapshot, now) item.DaysRemaining = &days } status, deliveredAt, readAt, expiresAt := resolveNotificationState(alert, outboxStates, notificationStates) item.NotificationStatus = status item.NotificationStatusName = constants.GetPackageTrafficAlertNotifyStatusName(status) item.NotificationDeliveredAt = deliveredAt if withReadState { item.NotificationReadAt = readAt item.NotificationExpiresAt = expiresAt } items = append(items, item) } return items, nil } type outboxState struct { Status int `gorm:"column:status"` DeliveredAt *time.Time `gorm:"column:delivered_at"` } // loadOutboxStates 按事件ID批量读取 Outbox 状态。 func (q *Query) loadOutboxStates(ctx context.Context, eventIDs []string) (map[string]outboxState, error) { result := make(map[string]outboxState, len(eventIDs)) if len(eventIDs) == 0 { return result, nil } var rows []struct { EventID string `gorm:"column:event_id"` Status int `gorm:"column:status"` DeliveredAt *time.Time `gorm:"column:delivered_at"` } if err := q.db.WithContext(ctx).Table("tb_outbox_event"). Select("event_id, status, delivered_at"). Where("event_id IN ?", eventIDs). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询预警通知事件状态失败") } for _, row := range rows { result[row.EventID] = outboxState{Status: row.Status, DeliveredAt: row.DeliveredAt} } return result, nil } type notificationState struct { ReadAt *time.Time `gorm:"column:read_at"` ExpiresAt *time.Time `gorm:"column:expires_at"` } // loadNotificationStates 按事件ID批量读取站内通知事实。 func (q *Query) loadNotificationStates(ctx context.Context, eventIDs []string) (map[string]notificationState, error) { result := make(map[string]notificationState, len(eventIDs)) if len(eventIDs) == 0 { return result, nil } var rows []struct { EventID string `gorm:"column:event_id"` ReadAt *time.Time `gorm:"column:read_at"` ExpiresAt *time.Time `gorm:"column:expires_at"` } if err := q.db.WithContext(ctx).Table("tb_notification"). Select("event_id, read_at, expires_at"). Where("event_id IN ?", eventIDs). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询预警站内通知失败") } for _, row := range rows { result[row.EventID] = notificationState{ReadAt: row.ReadAt, ExpiresAt: row.ExpiresAt} } return result, nil } // loadBusinessUserGroupNames 按业务员账号批量推导当前所属业务用户组名称。 func (q *Query) loadBusinessUserGroupNames(ctx context.Context, accountIDs []uint) (map[uint][]string, error) { result := make(map[uint][]string, len(accountIDs)) if len(accountIDs) == 0 { return result, nil } var rows []struct { AccountID uint `gorm:"column:account_id"` GroupName string `gorm:"column:group_name"` } if err := q.db.WithContext(ctx).Table("tb_business_user_group_member AS m"). Select("m.account_id, g.name AS group_name"). Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL"). Where("m.account_id IN ? AND m.deleted_at IS NULL", accountIDs). Order("g.sort_order ASC, g.id ASC"). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败") } for _, row := range rows { result[row.AccountID] = append(result[row.AccountID], row.GroupName) } return result, nil } // detectOwnershipDrift 判断资产当前归属店铺与店铺当前业务员是否已偏离触发快照。 func (q *Query) detectOwnershipDrift(ctx context.Context, alert *model.PackageTrafficAlert) (bool, bool) { currentShopID, err := q.currentAssetShopID(ctx, alert.AssetType, alert.AssetID) if err != nil || currentShopID == 0 { return false, false } shopChanged := currentShopID != alert.ShopIDSnapshot var shopOwner struct { BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"` } if err := q.db.WithContext(ctx).Table("tb_shop"). Select("business_owner_account_id"). Where("id = ?", currentShopID). Scan(&shopOwner).Error; err != nil { return shopChanged, false } ownerID := shopOwner.BusinessOwnerAccountID ownerChanged := !sameOptionalID(ownerID, alert.BusinessOwnerAccountIDSnapshot) return shopChanged, ownerChanged } // currentAssetShopID 查询资产当前所属店铺ID。 func (q *Query) currentAssetShopID(ctx context.Context, assetType string, assetID uint) (uint, error) { if assetID == 0 { return 0, nil } table := "tb_iot_card" if assetType == constants.AssetTypeDevice { table = "tb_device" } var shopID *uint if err := q.db.WithContext(ctx).Table(table). Select("shop_id"). Where("id = ? AND deleted_at IS NULL", assetID). Scan(&shopID).Error; err != nil { return 0, err } if shopID == nil { return 0, nil } return *shopID, nil } // applyNotificationStatusFilter 按通知投递结果筛选。 // 结果由通知事件、Outbox 状态与站内通知事实推导,因此筛选必须与投影同口径。 func applyNotificationStatusFilter(query *gorm.DB, status *int) *gorm.DB { if status == nil { return query } const hasNotification = "EXISTS (SELECT 1 FROM tb_notification AS n WHERE n.event_id = tb_package_traffic_alert.notification_event_id)" const outboxStatusExpr = `(SELECT oe.status FROM tb_outbox_event AS oe WHERE oe.event_id = tb_package_traffic_alert.notification_event_id)` switch *status { case constants.PackageTrafficAlertNotifyNoBusinessOwner: return query.Where("tb_package_traffic_alert.notification_event_id = ''") case constants.PackageTrafficAlertNotifyNotified: return query.Where("tb_package_traffic_alert.notification_event_id <> ''").Where(hasNotification) case constants.PackageTrafficAlertNotifyPending: return query.Where("tb_package_traffic_alert.notification_event_id <> ''"). Where("NOT "+hasNotification). Where(outboxStatusExpr+" IN ?", []int{constants.OutboxStatusPending, constants.OutboxStatusDelivering}) case constants.PackageTrafficAlertNotifyFailed: return query.Where("tb_package_traffic_alert.notification_event_id <> ''"). Where("NOT "+hasNotification). Where(outboxStatusExpr+" = ?", constants.OutboxStatusFailed) case constants.PackageTrafficAlertNotifyRecipientGone: return query.Where("tb_package_traffic_alert.notification_event_id <> ''"). Where("NOT "+hasNotification). Where(outboxStatusExpr+" = ?", constants.OutboxStatusDelivered) default: return query } } // resolveNotificationState 推导单条预警的通知投递结果,口径由 constants 统一定义。 func resolveNotificationState(alert *model.PackageTrafficAlert, outboxStates map[string]outboxState, notificationStates map[string]notificationState) (int, *time.Time, *time.Time, *time.Time) { if alert.NotificationEventID == "" { return constants.ResolvePackageTrafficAlertNotifyStatus(false, nil, false), nil, nil, nil } state, hasNotification := notificationStates[alert.NotificationEventID] var outboxStatus *int var deliveredAt *time.Time if outbox, ok := outboxStates[alert.NotificationEventID]; ok { status := outbox.Status outboxStatus = &status deliveredAt = outbox.DeliveredAt } status := constants.ResolvePackageTrafficAlertNotifyStatus(true, outboxStatus, hasNotification) if status == constants.PackageTrafficAlertNotifyNotified { return status, deliveredAt, state.ReadAt, state.ExpiresAt } if status == constants.PackageTrafficAlertNotifyRecipientGone { return status, deliveredAt, nil, nil } return status, nil, nil, nil } // notificationSummary 返回详情页的通知补充说明。 func notificationSummary(status int) string { switch status { case constants.PackageTrafficAlertNotifyNoBusinessOwner: return "触发时店铺无有效业务员,只保存预警且不补发通知" case constants.PackageTrafficAlertNotifyRecipientGone: return "通知事件已投递,但接收人账号在投递时已失效,未生成站内通知" case constants.PackageTrafficAlertNotifyFailed: return "通知事件投递失败,已进入既有可靠投递恢复" default: return "" } } // requirePlatformOperator 要求调用者为超级管理员或平台账号;其他账号统一按资源不可见处理。 func requirePlatformOperator(ctx context.Context) error { userType := middleware.GetUserTypeFromContext(ctx) if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { return invisibleAlertError() } return nil } // applyAssetDataScope 应用既有资产数据范围。 // 范围为空表示不受限(超级管理员与平台账号当前无实际过滤),保留为冻结语义与未来放开的前置; // 列名必须显式给出,因为预警行冻结的是 shop_id_snapshot,而不是通用的 shop_id 列。 func applyAssetDataScope(ctx context.Context, query *gorm.DB, column string) *gorm.DB { shopIDs := middleware.GetSubordinateShopIDs(ctx) if len(shopIDs) == 0 { return query } return query.Where(column+" IN ?", shopIDs) } // invisibleAlertError 返回与既有资源不可见一致的统一错误,避免越权与不存在形成可枚举差异。 func invisibleAlertError() error { return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } // daysUntil 按上海自然日计算剩余天数,负数表示已过期。 func daysUntil(expiresAt time.Time, now time.Time) int { days := dateInShanghai(expiresAt).Sub(dateInShanghai(now)) return int(days.Hours() / 24) } // dateInShanghai 归一化到上海时区的自然日零点。 func dateInShanghai(value time.Time) time.Time { local := value.In(shanghaiLocation) return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation) } // normalizePage 归一化分页参数并执行默认值与上限。 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 } // boolToStatus 把对外启停布尔映射为既有整型状态。 func boolToStatus(enabled bool) int { if enabled { return constants.StatusEnabled } return constants.StatusDisabled } // enabledStatusName 返回启停状态的中文名称。 func enabledStatusName(status int) string { if status == constants.StatusEnabled { return "启用" } return "停用" } // sameOptionalID 判断两个可空账号ID是否指向同一非空账号。 func sameOptionalID(left, right *uint) bool { leftID, rightID := uint(0), uint(0) if left != nil { leftID = *left } if right != nil { rightID = *right } return leftID == rightID }