// Package packagetrafficalert 提供套餐真流量达量扫描的 PostgreSQL 只读 Adapter // 与预警落库(事实 + 可靠通知事件 + 审计)的写 Adapter。 package packagetrafficalert import ( "context" "time" "gorm.io/gorm" app "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // Scanner 按资产汇总真流量并提供主套餐、规则与资产事实。 // // 真流量口径固定为 tb_package_usage.data_usage_mb(分子)与 data_limit_mb(分母快照); // 不读取虚流量、展示量、卡级累计或运营商通道累计。有效集合为 // status IN (1,2) AND refund_id IS NULL AND deleted_at IS NULL,只按状态判定过期,不引入到期时间判断。 // iot_card_id 与 device_id 在历史数据中同时存在 NULL 与 0 两种「无值」写法, // 因此分组与过滤一律使用 COALESCE(..., 0),禁止只写 > 0。 type Scanner struct { db *gorm.DB } // NewScanner 创建套餐真流量达量扫描只读 Adapter。 func NewScanner(db *gorm.DB) *Scanner { return &Scanner{db: db} } type aggregateRow struct { CardKey uint `gorm:"column:card_key"` DeviceKey uint `gorm:"column:device_key"` UsedMB int64 `gorm:"column:used_mb"` LimitMB int64 `gorm:"column:limit_mb"` } // assetKeyColumns 是资产键的权威 SQL 表达式:卡优先,仅当行无卡归属时才取设备 ID。 // 同一卡下设备 ID 混绑的多行必须归入同一资产键,因此键的两列都由 iot_card_id 主导, // 聚合 GROUP BY 与主套餐 ROW_NUMBER 分区必须使用同一表达式,避免 used/limit 汇总分裂。 const assetKeyColumns = "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN COALESCE(pu.iot_card_id, 0) ELSE 0 END, " + "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN 0 ELSE COALESCE(pu.device_id, 0) END" // LoadAssetAggregates 按资产汇总当前有效套餐的真已用量与真总量快照。 func (s *Scanner) LoadAssetAggregates(ctx context.Context) ([]app.AssetAggregate, error) { var rows []aggregateRow err := s.db.WithContext(ctx).Table("tb_package_usage AS pu"). Select("CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN COALESCE(pu.iot_card_id, 0) ELSE 0 END AS card_key, "+ "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN 0 ELSE COALESCE(pu.device_id, 0) END AS device_key, "+ "SUM(pu.data_usage_mb) AS used_mb, SUM(pu.data_limit_mb) AS limit_mb"). Where("pu.deleted_at IS NULL"). Where("pu.refund_id IS NULL"). Where("pu.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). Where("COALESCE(pu.iot_card_id, 0) > 0 OR COALESCE(pu.device_id, 0) > 0"). Group(assetKeyColumns). Scan(&rows).Error if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产汇总套餐真流量失败") } aggregates := make([]app.AssetAggregate, 0, len(rows)) for _, row := range rows { key, ok := assetKey(row.CardKey, row.DeviceKey) if !ok { continue } aggregates = append(aggregates, app.AssetAggregate{Key: key, UsedMB: row.UsedMB, LimitMB: row.LimitMB}) } return aggregates, nil } // LoadMainUsages 批量读取每个资产的主套餐使用记录。 // 主套餐为 master_usage_id 为空的记录,多条时按 priority ASC, activated_at ASC, id ASC 取第一条; // 分区键与聚合 GROUP BY 共用 assetKeyColumns(卡优先),保证同一卡下设备 ID 混绑的行归入同一资产键。 func (s *Scanner) LoadMainUsages(ctx context.Context, keys []app.AssetKey) (map[app.AssetKey]app.MainUsage, error) { result := make(map[app.AssetKey]app.MainUsage, len(keys)) if len(keys) == 0 { return result, nil } cardIDs, deviceIDs := splitAssetKeys(keys) var rows []struct { CardKey uint `gorm:"column:card_key"` DeviceKey uint `gorm:"column:device_key"` ID uint `gorm:"column:id"` PackageID uint `gorm:"column:package_id"` PackageName string `gorm:"column:package_name"` ExpiresAt *time.Time `gorm:"column:expires_at"` } cardKeyExpr := "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN COALESCE(pu.iot_card_id, 0) ELSE 0 END" deviceKeyExpr := "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN 0 ELSE COALESCE(pu.device_id, 0) END" inner := s.db.WithContext(ctx).Table("tb_package_usage AS pu"). Select("pu.id, pu.package_id, "+ cardKeyExpr+" AS card_key, "+ deviceKeyExpr+" AS device_key, "+ "COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name, pu.expires_at, "+ "ROW_NUMBER() OVER (PARTITION BY "+assetKeyColumns+ " ORDER BY pu.priority ASC, pu.activated_at ASC NULLS LAST, pu.id ASC) AS rn"). Joins("LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL"). Where("pu.deleted_at IS NULL AND pu.refund_id IS NULL"). Where("pu.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). Where("pu.master_usage_id IS NULL"). Where(s.db.Where(cardKeyExpr+" IN ? AND "+deviceKeyExpr+" = 0", cardIDs). Or(cardKeyExpr+" = 0 AND "+deviceKeyExpr+" IN ?", deviceIDs)) if err := s.db.WithContext(ctx).Table("(?) AS main_usage", inner). Where("main_usage.rn = 1").Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产主套餐使用记录失败") } for _, row := range rows { key, ok := assetKey(row.CardKey, row.DeviceKey) if !ok { continue } usage := app.MainUsage{ PackageUsageID: row.ID, PackageID: row.PackageID, PackageName: row.PackageName, ExpiresAt: row.ExpiresAt, } result[key] = usage } return result, nil } // LoadEnabledRules 批量读取套餐商品当前启用的预警规则。 func (s *Scanner) LoadEnabledRules(ctx context.Context, packageIDs []uint) (map[uint]app.EnabledRule, error) { result := make(map[uint]app.EnabledRule, len(packageIDs)) if len(packageIDs) == 0 { return result, nil } var rows []struct { ID uint `gorm:"column:id"` PackageID uint `gorm:"column:package_id"` ThresholdPercent float64 `gorm:"column:threshold_percent"` } err := s.db.WithContext(ctx).Table("tb_package_traffic_alert_rule"). Select("id, package_id, threshold_percent"). Where("package_id IN ?", packageIDs). Where("enabled = ?", constants.StatusEnabled). Scan(&rows).Error if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则失败") } for _, row := range rows { result[row.PackageID] = app.EnabledRule{ID: row.ID, PackageID: row.PackageID, ThresholdPercent: row.ThresholdPercent} } return result, nil } // LoadAssetFacts 批量读取资产展示事实与触发时归属。 func (s *Scanner) LoadAssetFacts(ctx context.Context, keys []app.AssetKey) (map[app.AssetKey]app.AssetFacts, error) { result := make(map[app.AssetKey]app.AssetFacts, len(keys)) if len(keys) == 0 { return result, nil } cardIDs, deviceIDs := splitAssetKeys(keys) shopIDs := make(map[uint]struct{}) ownerIDs := make(map[uint]struct{}) if err := s.loadCardFacts(ctx, cardIDs, result, shopIDs); err != nil { return nil, err } if err := s.loadDeviceFacts(ctx, deviceIDs, result, shopIDs); err != nil { return nil, err } if err := s.fillShops(ctx, shopIDs, ownerIDs, result); err != nil { return nil, err } if err := s.fillBusinessOwners(ctx, ownerIDs, result); err != nil { return nil, err } return result, nil } type cardFactRow struct { ID uint `gorm:"column:id"` ICCID string `gorm:"column:iccid"` ShopID *uint `gorm:"column:shop_id"` DeviceID *uint `gorm:"column:bound_device_id"` VirtualNo string `gorm:"column:bound_device_virtual_no"` IMEI string `gorm:"column:bound_device_imei"` SN string `gorm:"column:bound_device_sn"` DeviceType string `gorm:"column:bound_device_type"` DeviceModel string `gorm:"column:bound_device_model"` } func (s *Scanner) loadCardFacts(ctx context.Context, cardIDs []uint, result map[app.AssetKey]app.AssetFacts, shopIDs map[uint]struct{}) error { if len(cardIDs) == 0 { return nil } var rows []cardFactRow err := s.db.WithContext(ctx).Table("tb_iot_card AS c"). Select(`c.id, c.iccid, c.shop_id, dev.id AS bound_device_id, dev.virtual_no AS bound_device_virtual_no, dev.imei AS bound_device_imei, dev.sn AS bound_device_sn, dev.device_type AS bound_device_type, dev.device_model AS bound_device_model`). Joins(`LEFT JOIN LATERAL ( SELECT d.id, d.virtual_no, d.imei, d.sn, d.device_type, d.device_model FROM tb_device_sim_binding AS b JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL WHERE b.iot_card_id = c.id AND b.bind_status = ? AND b.deleted_at IS NULL ORDER BY b.is_current DESC, b.id DESC LIMIT 1 ) AS dev ON TRUE`, constants.BindStatusBound). Where("c.deleted_at IS NULL"). Where("c.id IN ?", cardIDs). Scan(&rows).Error if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询卡资产展示事实失败") } for _, row := range rows { facts := app.AssetFacts{ AssetIdentifier: row.ICCID, CardIdentifier: row.ICCID, CounterpartIdentifier: deviceIdentifier(row.VirtualNo, row.IMEI, row.SN), DeviceType: row.DeviceType, DeviceModel: row.DeviceModel, } if row.ShopID != nil && *row.ShopID > 0 { facts.ShopID = *row.ShopID shopIDs[*row.ShopID] = struct{}{} } result[app.AssetKey{AssetType: constants.AssetTypeIotCard, AssetID: row.ID}] = facts } return nil } type deviceFactRow struct { ID uint `gorm:"column:id"` VirtualNo string `gorm:"column:virtual_no"` IMEI string `gorm:"column:imei"` SN string `gorm:"column:sn"` DeviceType string `gorm:"column:device_type"` DeviceModel string `gorm:"column:device_model"` ShopID *uint `gorm:"column:shop_id"` BoundICCID string `gorm:"column:bound_card_iccid"` } func (s *Scanner) loadDeviceFacts(ctx context.Context, deviceIDs []uint, result map[app.AssetKey]app.AssetFacts, shopIDs map[uint]struct{}) error { if len(deviceIDs) == 0 { return nil } var rows []deviceFactRow err := s.db.WithContext(ctx).Table("tb_device AS d"). Select(`d.id, d.virtual_no, d.imei, d.sn, d.device_type, d.device_model, d.shop_id, card.iccid AS bound_card_iccid`). Joins(`LEFT JOIN LATERAL ( SELECT c.iccid FROM tb_device_sim_binding AS b JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL WHERE b.device_id = d.id AND b.bind_status = ? AND b.deleted_at IS NULL ORDER BY b.is_current DESC, b.id DESC LIMIT 1 ) AS card ON TRUE`, constants.BindStatusBound). Where("d.deleted_at IS NULL"). Where("d.id IN ?", deviceIDs). Scan(&rows).Error if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询设备资产展示事实失败") } for _, row := range rows { facts := app.AssetFacts{ AssetIdentifier: deviceIdentifier(row.VirtualNo, row.IMEI, row.SN), CardIdentifier: row.BoundICCID, CounterpartIdentifier: row.BoundICCID, DeviceType: row.DeviceType, DeviceModel: row.DeviceModel, } if row.ShopID != nil && *row.ShopID > 0 { facts.ShopID = *row.ShopID shopIDs[*row.ShopID] = struct{}{} } result[app.AssetKey{AssetType: constants.AssetTypeDevice, AssetID: row.ID}] = facts } return nil } // fillShops 批量回填触发时店铺名称与业务员账号。 func (s *Scanner) fillShops(ctx context.Context, shopIDs map[uint]struct{}, ownerIDs map[uint]struct{}, result map[app.AssetKey]app.AssetFacts) error { if len(shopIDs) == 0 { return nil } ids := mapKeys(shopIDs) var shops []struct { ID uint `gorm:"column:id"` ShopName string `gorm:"column:shop_name"` BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"` } if err := s.db.WithContext(ctx).Table("tb_shop AS sh"). Select("sh.id, sh.shop_name, sh.business_owner_account_id"). Where("sh.id IN ?", ids). Where("sh.deleted_at IS NULL"). Scan(&shops).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺归属失败") } shopByID := make(map[uint]struct { Name string OwnerID *uint }, len(shops)) for _, shop := range shops { shopByID[shop.ID] = struct { Name string OwnerID *uint }{Name: shop.ShopName, OwnerID: shop.BusinessOwnerAccountID} if shop.BusinessOwnerAccountID != nil && *shop.BusinessOwnerAccountID > 0 { ownerIDs[*shop.BusinessOwnerAccountID] = struct{}{} } } for key, facts := range result { shop, ok := shopByID[facts.ShopID] if !ok { // 店铺已软删:按无店铺处理,不解析业务员,预警行保留快照兜底。 facts.ShopID = 0 facts.ShopName = "" facts.BusinessOwnerID = nil facts.BusinessOwnerName = "" result[key] = facts continue } facts.ShopName = shop.Name facts.BusinessOwnerID = shop.OwnerID result[key] = facts } return nil } // fillBusinessOwners 只保留「仅业务员」解析路径认可的有效账号。 // 判定为 tb_account.user_type = platform 且 status 启用且未软删;指向店铺代理账号或停用账号一律视为无有效业务员。 func (s *Scanner) fillBusinessOwners(ctx context.Context, ownerIDs map[uint]struct{}, result map[app.AssetKey]app.AssetFacts) error { if len(ownerIDs) == 0 { return nil } ids := mapKeys(ownerIDs) var accounts []struct { ID uint `gorm:"column:id"` Username string `gorm:"column:username"` } if err := s.db.WithContext(ctx).Table("tb_account AS a"). Select("a.id, a.username"). Where("a.id IN ?", ids). Where("a.user_type = ?", constants.UserTypePlatform). Where("a.status = ?", constants.StatusEnabled). Where("a.deleted_at IS NULL"). Scan(&accounts).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺业务员账号失败") } accountsByID := make(map[uint]string, len(accounts)) for _, account := range accounts { accountsByID[account.ID] = account.Username } for key, facts := range result { if facts.BusinessOwnerID == nil { continue } name, ok := accountsByID[*facts.BusinessOwnerID] if !ok { facts.BusinessOwnerID = nil facts.BusinessOwnerName = "" result[key] = facts continue } facts.BusinessOwnerName = name result[key] = facts } return nil } // assetKey 按卡优先的互斥规则生成资产键;两列都无值时返回 false。 func assetKey(iotCardID, deviceID uint) (app.AssetKey, bool) { if iotCardID > 0 { return app.AssetKey{AssetType: constants.AssetTypeIotCard, AssetID: iotCardID}, true } if deviceID > 0 { return app.AssetKey{AssetType: constants.AssetTypeDevice, AssetID: deviceID}, true } return app.AssetKey{}, false } // splitAssetKeys 拆分出卡 ID 与设备 ID 集合。 func splitAssetKeys(keys []app.AssetKey) ([]uint, []uint) { cardIDs := make([]uint, 0, len(keys)) deviceIDs := make([]uint, 0, len(keys)) for _, key := range keys { if key.AssetID == 0 { continue } if key.AssetType == constants.AssetTypeDevice { deviceIDs = append(deviceIDs, key.AssetID) continue } cardIDs = append(cardIDs, key.AssetID) } return cardIDs, deviceIDs } // deviceIdentifier 按虚拟号→IMEI→SN 的稳定优先级生成设备标识。 func deviceIdentifier(virtualNo, imei, sn string) string { if virtualNo != "" { return virtualNo } if imei != "" { return imei } return sn } // mapKeys 返回集合的键切片。 func mapKeys(values map[uint]struct{}) []uint { keys := make([]uint, 0, len(values)) for key := range values { keys = append(keys, key) } return keys }