新增 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。
303 lines
11 KiB
Go
303 lines
11 KiB
Go
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))
|
||
}
|