Files
junhong_cmp_fiber/internal/application/packagetrafficalert/scan.go
break d5bcda94fe feat(套餐真流量预警): AUG26-004 真流量预警规则、达量扫描通知与导出
新增 000228 迁移:规则表 tb_package_traffic_alert_rule(每套餐商品至多一条,无软删除,package_id
非部分唯一约束)、达量预警快照表 tb_package_traffic_alert(以主套餐使用记录 + 阈值快照为唯一键,
触发时冻结用量、额度、比例、阈值、到期时间、归属与资产快照),并为 tb_package_usage 新增扫描
范围部分索引 idx_package_usage_alert_scope;down 在预警表存在数据时阻断回滚。

新增规则维护接口 GET/POST/PUT /api/admin/package-traffic-alert-rules(仅超级管理员与平台账号):
创建校验套餐存在且真流量额度大于零,阈值为 1%~100% 的两位小数;修改只影响后续扫描,不回填也
不改写既有预警快照;全部写操作记录操作者、前后值与时间。

新增每日 06:00(Asia/Shanghai)扫描任务 package:traffic:alert:scan,与套餐临期扫描共用 data_cleanup
队列:按资产汇总当前有效套餐的真流量,分子取使用记录真已用量、分母取使用记录真总量快照,命中
主套餐规则阈值时在同一事务创建预警与可靠通知事件;重复执行以唯一冲突视为已处理,不重复投递,
不建停机锁、不调用运营商。

新增预警列表、详情与异步导出 GET /api/admin/package-traffic-alerts、GET /api/admin/package-traffic-alerts/:id、
POST /api/admin/package-traffic-alerts/export,列表与详情一律读冻结快照;新增通知类型
package.traffic.alert 与受控目标 package_traffic_alert_detail,目标解析仅对超级管理员与平台账号
返回可跳转,越权与不存在统一按资源不可见处理。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)、审计动作与资源注册、上下文
健康检查证据;归档变更并同步 package-traffic-alert 主 Spec。
2026-09-16 17:05:55 +08:00

303 lines
11 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 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))
}