package packagetrafficalert import ( "context" "fmt" "sort" "strconv" "time" "go.uber.org/zap" "github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // AssetKey 是扫描的资产聚合键:卡按 iot_card_id、设备按 device_id,二者互斥非零。 type AssetKey struct { AssetType string AssetID uint } // AssetAggregate 是同一资产全部当前有效套餐使用记录的真流量汇总。 // UsedMB 汇总真已用量,LimitMB 汇总真总量快照;虚流量、展示量、卡级累计与通道累计一律不参与。 type AssetAggregate struct { Key AssetKey UsedMB int64 LimitMB int64 } // MainUsage 是资产的主套餐使用记录(阈值来源与预警锚点)。 type MainUsage struct { PackageUsageID uint PackageID uint PackageName string ExpiresAt *time.Time } // EnabledRule 是主套餐对应的当前启用预警规则。 type EnabledRule struct { ID uint PackageID uint ThresholdPercent float64 } // AssetFacts 是触发时必须冻结的资产与归属展示事实。 // 资产标识、卡标识、对端标识、设备类型与型号取自触发时的卡与设备绑定; // 归属只包含触发时店铺与「仅业务员」解析出的有效平台业务员。 type AssetFacts struct { AssetIdentifier string CardIdentifier string CounterpartIdentifier string DeviceType string DeviceModel string ShopID uint ShopName string BusinessOwnerID *uint BusinessOwnerName string } // ScanReader 读取扫描所需的只读事实。 type ScanReader interface { // LoadAssetAggregates 按资产汇总当前有效套餐的真已用量与真总量快照。 LoadAssetAggregates(ctx context.Context) ([]AssetAggregate, error) // LoadMainUsages 批量读取每个资产的主套餐使用记录(master_usage_id 为空,按优先级/生效时间/编号取第一条)。 LoadMainUsages(ctx context.Context, keys []AssetKey) (map[AssetKey]MainUsage, error) // LoadEnabledRules 批量读取套餐商品当前启用的预警规则。 LoadEnabledRules(ctx context.Context, packageIDs []uint) (map[uint]EnabledRule, error) // LoadAssetFacts 批量读取资产展示事实与触发时归属。 LoadAssetFacts(ctx context.Context, keys []AssetKey) (map[AssetKey]AssetFacts, error) } // AlertCandidate 是一次命中要原子落库的完整事实。 type AlertCandidate struct { Alert model.PackageTrafficAlert // Notification 为空表示触发时店铺无有效业务员或到期时间不可推算,只保存预警不写通知事件。 Notification *NotificationRequest } // NotificationRequest 是一次可靠通知事件的最小输入。 // 接收人是触发时冻结的业务员账号,投递期不再重新解析店铺业务员,避免向未来业务员补发。 type NotificationRequest struct { RecipientAccountID uint ShopID uint TemplateData map[string]string ExpiresAt time.Time } // AlertWriter 在同一事务内写入预警事实、可靠通知事件与审计。 type AlertWriter interface { // SaveAlert 幂等创建预警;返回 false 表示唯一键冲突(视为已处理,不写事件与审计)。 SaveAlert(ctx context.Context, candidate AlertCandidate) (bool, error) } // ScanService 执行每日套餐真流量达量扫描。 type ScanService struct { reader ScanReader writer AlertWriter logger *zap.Logger // now 可在验证时替换,默认使用系统时间。 now func() time.Time } // NewScanService 创建套餐真流量达量扫描用例。 func NewScanService(reader ScanReader, writer AlertWriter, logger *zap.Logger) *ScanService { return &ScanService{reader: reader, writer: writer, logger: logger, now: func() time.Time { return time.Now().UTC() }} } // ScanResult 汇总一次扫描的可观察结果。 type ScanResult struct { Assets int Hits int Created int Duplicates int Skipped int } // Run 执行一次可重跑扫描:按资产汇总真流量,按主套餐规则阈值判定,命中即原子落库。 func (s *ScanService) Run(ctx context.Context) error { if s == nil || s.reader == nil || s.writer == nil { return errors.New(errors.CodeInternalError, "套餐真流量达量扫描用例未配置") } aggregates, err := s.reader.LoadAssetAggregates(ctx) if err != nil { return err } result := &ScanResult{Assets: len(aggregates)} if len(aggregates) == 0 { s.logScan(result) return nil } keys := make([]AssetKey, 0, len(aggregates)) for _, aggregate := range aggregates { keys = append(keys, aggregate.Key) } mainUsages, err := s.reader.LoadMainUsages(ctx, keys) if err != nil { return err } rules, err := s.loadRulesForUsages(ctx, mainUsages) if err != nil { return err } facts, err := s.reader.LoadAssetFacts(ctx, keys) if err != nil { return err } triggeredAt := s.now() for _, aggregate := range aggregates { main, hasMain := mainUsages[aggregate.Key] if !hasMain { // 全是加油包、没有主套餐的资产没有阈值来源,直接跳过。 result.Skipped++ continue } rule, hasRule := rules[main.PackageID] if !hasRule { result.Skipped++ continue } if aggregate.LimitMB <= 0 { // 汇总分母不是正数的资产不可判定,跳过而不是写入不可用的预警。 result.Skipped++ continue } thresholdBasisPoints := packagetrafficalert.ThresholdBasisPoints(rule.ThresholdPercent) hit, ratioBasisPoints := packagetrafficalert.Decide(aggregate.UsedMB, aggregate.LimitMB, thresholdBasisPoints) if !hit { result.Skipped++ continue } result.Hits++ candidate := s.buildCandidate(aggregate, main, rule, ratioBasisPoints, facts[aggregate.Key], triggeredAt) created, saveErr := s.writer.SaveAlert(ctx, candidate) if saveErr != nil { s.logger.Error("套餐真流量达量预警写入失败", zap.String("asset_type", aggregate.Key.AssetType), zap.Uint("asset_id", aggregate.Key.AssetID), zap.Error(saveErr)) return saveErr } if created { result.Created++ } else { result.Duplicates++ } } s.logScan(result) return nil } // loadRulesForUsages 批量读取主套餐对应的启用规则。 func (s *ScanService) loadRulesForUsages(ctx context.Context, usages map[AssetKey]MainUsage) (map[uint]EnabledRule, error) { seen := make(map[uint]struct{}, len(usages)) packageIDs := make([]uint, 0, len(usages)) for _, usage := range usages { if usage.PackageID == 0 { continue } if _, ok := seen[usage.PackageID]; ok { continue } seen[usage.PackageID] = struct{}{} packageIDs = append(packageIDs, usage.PackageID) } if len(packageIDs) == 0 { return map[uint]EnabledRule{}, nil } sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] }) return s.reader.LoadEnabledRules(ctx, packageIDs) } // buildCandidate 组装唯一的资产级预警事实与可选通知请求。 func (s *ScanService) buildCandidate(aggregate AssetAggregate, main MainUsage, rule EnabledRule, ratioBasisPoints int64, facts AssetFacts, triggeredAt time.Time) AlertCandidate { packageName := main.PackageName if packageName == "" { packageName = "套餐#" + strconv.FormatUint(uint64(main.PackageID), 10) } assetIdentifier := facts.AssetIdentifier if assetIdentifier == "" { // 回落值同步写入快照,保证快照、列表与通知正文一致。 assetIdentifier = "资产#" + strconv.FormatUint(uint64(aggregate.Key.AssetID), 10) } alert := model.PackageTrafficAlert{ PackageUsageID: main.PackageUsageID, PackageID: main.PackageID, RuleID: rule.ID, AssetType: aggregate.Key.AssetType, AssetID: aggregate.Key.AssetID, AssetIdentifierSnapshot: assetIdentifier, CardIdentifierSnapshot: facts.CardIdentifier, CounterpartIdentifierSnapshot: facts.CounterpartIdentifier, DeviceTypeSnapshot: facts.DeviceType, DeviceModelSnapshot: facts.DeviceModel, PackageNameSnapshot: packageName, UsedMBSnapshot: aggregate.UsedMB, LimitMBSnapshot: aggregate.LimitMB, UsagePercentSnapshot: packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints), ThresholdPercentSnapshot: packagetrafficalert.NormalizeThresholdPercent(rule.ThresholdPercent), ExpiresAtSnapshot: main.ExpiresAt, TriggeredAt: triggeredAt, ShopIDSnapshot: facts.ShopID, ShopNameSnapshot: facts.ShopName, BusinessOwnerAccountIDSnapshot: facts.BusinessOwnerID, BusinessOwnerNameSnapshot: facts.BusinessOwnerName, } candidate := AlertCandidate{Alert: alert} if facts.BusinessOwnerID == nil || *facts.BusinessOwnerID == 0 { // 无有效业务员:只保存预警,不写通知事件,也不在未来补发。 return candidate } candidate.Notification = &NotificationRequest{ RecipientAccountID: *facts.BusinessOwnerID, ShopID: facts.ShopID, ExpiresAt: notificationExpiresAt(main.ExpiresAt, triggeredAt), TemplateData: map[string]string{ "asset_identifier": assetIdentifier, "package_name": packageName, "usage_percent": formatPercent(packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints)), "threshold_percent": formatPercent(alert.ThresholdPercentSnapshot), }, } return candidate } // notificationExpiresAt 计算站内通知的展示期结束时间。 // 优先使用主套餐到期时间快照;快照为空时沿用既有默认展示期常量兜底, // 预警行的到期时间快照保持为空,不伪造业务到期时间。 func notificationExpiresAt(snapshot *time.Time, triggeredAt time.Time) time.Time { if snapshot != nil { return snapshot.UTC() } return triggeredAt.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays).UTC() } // formatPercent 把百分比格式化为最多两位小数、去掉无意义尾零的展示文本。 func formatPercent(value float64) string { return strconv.FormatFloat(packagetrafficalert.NormalizeThresholdPercent(value), 'f', -1, 64) } // logScan 输出一次扫描的结构化结果,供维护者按日志核对。 func (s *ScanService) logScan(result *ScanResult) { if s.logger == nil { return } s.logger.Info("套餐真流量达量扫描完成", zap.Int("assets", result.Assets), zap.Int("hits", result.Hits), zap.Int("created", result.Created), zap.Int("duplicates", result.Duplicates), zap.Int("skipped", result.Skipped)) } // EventIDFor 返回预警通知事件的稳定ID:内嵌主套餐使用记录与阈值快照(万分比)。 func EventIDFor(packageUsageID uint, thresholdPercent float64) string { return fmt.Sprintf("%s:%d:%d", constants.PackageTrafficAlertEventIDPrefix, packageUsageID, packagetrafficalert.ThresholdBasisPoints(thresholdPercent)) }