package exporter import ( "context" "strconv" "strings" "time" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // PackageTrafficAlertDataSource 套餐真流量达量预警导出数据源。 // // 粒度为一条预警记录。套餐、用量、总量、阈值、到期时间与资产标识类列一律读预警行冻结的触发快照; // 店铺、业务员与用户组按导出执行时当前归属补充,用户组按既有实时推导,不写入店铺表。 // 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再校验一次, // 阻止通过通用导出入口以代理身份创建本场景任务后读到预警数据。 type PackageTrafficAlertDataSource struct { db *gorm.DB } // NewPackageTrafficAlertDataSource 创建套餐真流量达量预警导出数据源。 func NewPackageTrafficAlertDataSource(db *gorm.DB) *PackageTrafficAlertDataSource { return &PackageTrafficAlertDataSource{db: db} } // Scene 返回导出场景编码。 func (s *PackageTrafficAlertDataSource) Scene() string { return constants.ExportTaskScenePackageTrafficAlert } // Count 统计导出预警行数。 func (s *PackageTrafficAlertDataSource) Count(ctx context.Context, params ExportParams) (int, error) { if err := ensurePackageTrafficAlertExportAllowed(params); err != nil { return 0, err } var total int64 if err := s.applyFilters(s.baseQuery(ctx, params), params).Count(&total).Error; err != nil { return 0, err } return int(total), nil } // Headers 返回套餐真流量达量预警导出表头。 // 表头在 dispatch 阶段冻结,历史任务重导出沿用同一列序;不含任何运营商通道列。 func (s *PackageTrafficAlertDataSource) Headers(context.Context, ExportParams) ([]string, error) { return []string{ "资产类型", "资产标识", "对应标识符", "卡标识", "设备类型", "设备型号", "套餐名称", "真流量已用量(MB)", "真流量额度(MB)", "比例(%)", "阈值快照(%)", "到期时间", "剩余天数", "触发时间", "店铺", "业务员", "用户组", "通知投递结果", }, nil } // Fetch 按 offset/limit 查询预警导出数据。 func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { if limit <= 0 { return [][]string{}, nil } if err := ensurePackageTrafficAlertExportAllowed(params); err != nil { return nil, err } var items []packageTrafficAlertExportRow query := s.applyFilters(s.baseQuery(ctx, params), params). Select(` a.asset_type, a.asset_identifier_snapshot, a.counterpart_identifier_snapshot, a.card_identifier_snapshot, a.device_type_snapshot, a.device_model_snapshot, a.package_name_snapshot, a.used_mb_snapshot, a.limit_mb_snapshot, a.usage_percent_snapshot, a.threshold_percent_snapshot, a.expires_at_snapshot, a.triggered_at, a.shop_id_snapshot, a.shop_name_snapshot, a.business_owner_account_id_snapshot, a.business_owner_name_snapshot, a.notification_event_id, sh.id AS current_shop_id, COALESCE(sh.shop_name, '') AS current_shop_name, owner.id AS current_owner_id, COALESCE(owner.username, '') AS current_owner_name, oe.status AS outbox_status, n.id AS notification_id `). Order("a.triggered_at DESC").Order("a.id DESC"). Limit(limit).Offset(offset) if err := query.Scan(&items).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警导出数据失败") } groupNames, err := s.loadBusinessUserGroupNames(ctx, items) if err != nil { return nil, err } now := time.Now().UTC() rows := make([][]string, 0, len(items)) for _, item := range items { rows = append(rows, []string{ assetTypeName(item.AssetType), item.AssetIdentifier, item.CounterpartIdentifier, item.CardIdentifier, item.DeviceType, item.DeviceModel, item.PackageName, strconv.FormatInt(item.UsedMB, 10), strconv.FormatInt(item.LimitMB, 10), formatPercentValue(item.UsagePercent), formatPercentValue(item.ThresholdPercent), formatOptionalTime(item.ExpiresAt), formatRemainingDays(item.ExpiresAt, now), item.TriggeredAt.Format(exportTimeLayout), item.CurrentShopName, item.CurrentOwnerName, currentOwnerGroupName(groupNames, item.CurrentOwnerID), constants.GetPackageTrafficAlertNotifyStatusName(resolveAlertNotifyStatus(item)), }) } return rows, nil } // baseQuery 构造预警导出基础查询。 // 归属展示列按执行时当前归属补充:资产 → 当前店铺 → 店铺当前业务员;用户组随后按业务员账号实时推导。 func (s *PackageTrafficAlertDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB { query := s.db.WithContext(ctx).Table("tb_package_traffic_alert AS a"). Joins("LEFT JOIN tb_iot_card AS c ON a.asset_type = ? AND c.id = a.asset_id AND c.deleted_at IS NULL", constants.AssetTypeIotCard). Joins("LEFT JOIN tb_device AS d ON a.asset_type = ? AND d.id = a.asset_id AND d.deleted_at IS NULL", constants.AssetTypeDevice). Joins("LEFT JOIN tb_shop AS sh ON sh.id = COALESCE(c.shop_id, d.shop_id) AND sh.deleted_at IS NULL"). Joins("LEFT JOIN tb_account AS owner ON owner.id = sh.business_owner_account_id AND owner.deleted_at IS NULL"). Joins("LEFT JOIN tb_outbox_event AS oe ON oe.event_id = a.notification_event_id"). Joins("LEFT JOIN tb_notification AS n ON n.event_id = a.notification_event_id") // 数据范围使用导出侧范围过滤(空范围拒绝),不得使用请求上下文版过滤(空范围语义相反)。 return applyExportShopScope(query, params, "a.shop_id_snapshot") } // applyFilters 应用导出筛选快照。 // 筛选口径与列表一致,都作用在触发快照列上;时间范围按触发时间的闭区间解析。 func (s *PackageTrafficAlertDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { if packageID, ok := filterUint(params.Filters, "package_id"); ok { query = query.Where("a.package_id = ?", packageID) } if shopID, ok := filterUint(params.Filters, "shop_id"); ok { query = query.Where("a.shop_id_snapshot = ?", shopID) } if ownerID, ok := filterUint(params.Filters, "business_owner_account_id"); ok { query = query.Where("a.business_owner_account_id_snapshot = ?", ownerID) } if assetType, ok := filterString(params.Filters, "asset_type"); ok { query = query.Where("a.asset_type = ?", assetType) } if identifier, ok := filterString(params.Filters, "asset_identifier"); ok { pattern := "%" + identifier + "%" query = query.Where("(a.asset_identifier_snapshot ILIKE ? OR a.card_identifier_snapshot ILIKE ? "+ "OR a.counterpart_identifier_snapshot ILIKE ?)", pattern, pattern, pattern) } if threshold, ok := alertFilterFloat(params.Filters, "threshold_percent"); ok { query = query.Where("a.threshold_percent_snapshot = ?", packagetrafficalert.NormalizeThresholdPercent(threshold)) } if startTime, ok := filterTime(params.Filters, "start_time"); ok { query = query.Where("a.triggered_at >= ?", startTime.UTC()) } if endTime, ok := filterTime(params.Filters, "end_time"); ok { query = query.Where("a.triggered_at <= ?", endTime.UTC()) } if status, ok := filterInt(params.Filters, "notification_status"); ok { query = applyAlertNotificationStatusFilter(query, status) } return query } // applyAlertNotificationStatusFilter 按通知投递结果筛选,口径与读侧列表一致。 func applyAlertNotificationStatusFilter(query *gorm.DB, status int) *gorm.DB { const hasEvent = "a.notification_event_id <> ''" const hasNotification = "n.id IS NOT NULL" switch status { case constants.PackageTrafficAlertNotifyNoBusinessOwner: return query.Where("a.notification_event_id = ''") case constants.PackageTrafficAlertNotifyNotified: return query.Where(hasEvent).Where(hasNotification) case constants.PackageTrafficAlertNotifyPending: return query.Where(hasEvent).Where("NOT ("+hasNotification+")"). Where("oe.status IN ?", []int{constants.OutboxStatusPending, constants.OutboxStatusDelivering}) case constants.PackageTrafficAlertNotifyFailed: return query.Where(hasEvent).Where("NOT ("+hasNotification+")"). Where("oe.status = ?", constants.OutboxStatusFailed) case constants.PackageTrafficAlertNotifyRecipientGone: return query.Where(hasEvent).Where("NOT ("+hasNotification+")"). Where("oe.status = ?", constants.OutboxStatusDelivered) default: return query } } // loadBusinessUserGroupNames 按执行时当前业务员账号批量推导业务用户组名称。 // 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接。 func (s *PackageTrafficAlertDataSource) loadBusinessUserGroupNames(ctx context.Context, items []packageTrafficAlertExportRow) (map[uint]string, error) { result := make(map[uint]string) ownerIDs := make([]uint, 0, len(items)) seen := make(map[uint]struct{}, len(items)) for _, item := range items { if item.CurrentOwnerID == nil || *item.CurrentOwnerID == 0 { continue } if _, exists := seen[*item.CurrentOwnerID]; exists { continue } seen[*item.CurrentOwnerID] = struct{}{} ownerIDs = append(ownerIDs, *item.CurrentOwnerID) } if len(ownerIDs) == 0 { return result, nil } var rows []struct { AccountID uint `gorm:"column:account_id"` GroupName string `gorm:"column:group_name"` } if err := s.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", ownerIDs). Order("m.account_id ASC, g.sort_order ASC, g.id ASC"). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败") } for _, row := range rows { if existing := result[row.AccountID]; existing != "" { result[row.AccountID] = existing + "、" + row.GroupName continue } result[row.AccountID] = row.GroupName } return result, nil } // packageTrafficAlertExportRow 是预警导出的一行原始投影。 type packageTrafficAlertExportRow struct { AssetType string `gorm:"column:asset_type"` AssetIdentifier string `gorm:"column:asset_identifier_snapshot"` CounterpartIdentifier string `gorm:"column:counterpart_identifier_snapshot"` CardIdentifier string `gorm:"column:card_identifier_snapshot"` DeviceType string `gorm:"column:device_type_snapshot"` DeviceModel string `gorm:"column:device_model_snapshot"` PackageName string `gorm:"column:package_name_snapshot"` UsedMB int64 `gorm:"column:used_mb_snapshot"` LimitMB int64 `gorm:"column:limit_mb_snapshot"` UsagePercent float64 `gorm:"column:usage_percent_snapshot"` ThresholdPercent float64 `gorm:"column:threshold_percent_snapshot"` ExpiresAt *time.Time `gorm:"column:expires_at_snapshot"` TriggeredAt time.Time `gorm:"column:triggered_at"` ShopIDSnapshot uint `gorm:"column:shop_id_snapshot"` ShopNameSnapshot string `gorm:"column:shop_name_snapshot"` BusinessOwnerID *uint `gorm:"column:business_owner_account_id_snapshot"` BusinessOwnerName string `gorm:"column:business_owner_name_snapshot"` NotificationEventID string `gorm:"column:notification_event_id"` CurrentShopID *uint `gorm:"column:current_shop_id"` CurrentShopName string `gorm:"column:current_shop_name"` CurrentOwnerID *uint `gorm:"column:current_owner_id"` CurrentOwnerName string `gorm:"column:current_owner_name"` OutboxStatus *int `gorm:"column:outbox_status"` NotificationID *uint `gorm:"column:notification_id"` } // resolveAlertNotifyStatus 推导导出行的通知投递结果,与列表、详情同口径。 func resolveAlertNotifyStatus(item packageTrafficAlertExportRow) int { return constants.ResolvePackageTrafficAlertNotifyStatus( item.NotificationEventID != "", item.OutboxStatus, item.NotificationID != nil) } // ensurePackageTrafficAlertExportAllowed 只允许超级管理员与平台账号使用本场景。 // 通用导出入口不做场景级角色校验,因此这一层门禁是防止代理越权读取预警数据的必要防线。 func ensurePackageTrafficAlertExportAllowed(params ExportParams) error { if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform { return nil } return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } // alertFilterFloat 解析导出筛选中的小数百分比。 // 阈值筛选只在预警导出使用,为避免改动既有共享筛选助手文件,这里就地解析。 func alertFilterFloat(filters map[string]any, key string) (float64, bool) { value, ok := filters[key] if !ok || value == nil { return 0, false } switch typed := value.(type) { case float64: return typed, true case float32: return float64(typed), true case int: return float64(typed), true case int64: return float64(typed), true case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) if err != nil { return 0, false } return parsed, true default: return 0, false } } // currentOwnerGroupName 返回执行时当前业务员的用户组名称,无有效业务员时为空。 func currentOwnerGroupName(groupNames map[uint]string, ownerID *uint) string { if ownerID == nil { return "" } return groupNames[*ownerID] } // assetTypeName 返回资产类型的中文名称。 func assetTypeName(assetType string) string { if assetType == constants.AssetTypeDevice { return "设备" } return "物联网卡" } // formatPercentValue 输出保留两位小数的百分比。 func formatPercentValue(value float64) string { return strconv.FormatFloat(value, 'f', 2, 64) } // formatRemainingDays 按上海自然日推算剩余天数;无到期时间时输出空字符串。 func formatRemainingDays(expiresAt *time.Time, now time.Time) string { if expiresAt == nil { return "" } location := time.FixedZone("Asia/Shanghai", 8*60*60) localExpires := expiresAt.In(location) localNow := now.In(location) expiresDate := time.Date(localExpires.Year(), localExpires.Month(), localExpires.Day(), 0, 0, 0, 0, location) nowDate := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location) days := int(expiresDate.Sub(nowDate).Hours() / 24) return strconv.Itoa(days) }