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。
This commit is contained in:
299
internal/application/packagetrafficalert/rule.go
Normal file
299
internal/application/packagetrafficalert/rule.go
Normal file
@@ -0,0 +1,299 @@
|
||||
// Package packagetrafficalert 收口套餐真流量预警的规则维护事务脚本与每日扫描用例。
|
||||
//
|
||||
// 规则维护是简单写:Handler → Application 事务脚本 → Persistence,事实与审计同事务。
|
||||
// 每日扫描是复杂写:Application 编排 → Domain 判定 → Port/Infrastructure 原子写入预警事实、
|
||||
// 可靠通知事件与审计;判定只使用套餐使用记录的真流量快照,不读取虚流量、展示量、卡级累计或通道累计。
|
||||
package packagetrafficalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// RuleService 套餐真流量预警规则维护事务脚本。
|
||||
type RuleService struct {
|
||||
db *gorm.DB
|
||||
store *postgres.PackageTrafficAlertStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// NewRuleService 创建套餐真流量预警规则事务脚本。
|
||||
func NewRuleService(db *gorm.DB, store *postgres.PackageTrafficAlertStore, auditWriters ...*audit.Writer) *RuleService {
|
||||
service := &RuleService{db: db, store: store}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Create 为套餐商品创建唯一预警规则。
|
||||
// 创建一律校验套餐存在且商品真流量额度大于零:商品 real_data_mb 只用于配置合法性,
|
||||
// 不作为扫描分母(分母取使用记录的真总量快照)。
|
||||
func (s *RuleService) Create(ctx context.Context, request *dto.CreatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
|
||||
operatorID, err := requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request == nil || request.PackageID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐商品ID不能为空")
|
||||
}
|
||||
if !packagetrafficalert.IsValidThresholdPercent(request.ThresholdPercent) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100,允许两位小数")
|
||||
}
|
||||
enabled := constants.StatusEnabled
|
||||
if request.Enabled != nil && !*request.Enabled {
|
||||
enabled = constants.StatusDisabled
|
||||
}
|
||||
rule := &model.PackageTrafficAlertRule{
|
||||
PackageID: request.PackageID,
|
||||
ThresholdPercent: packagetrafficalert.NormalizeThresholdPercent(request.ThresholdPercent),
|
||||
Enabled: enabled,
|
||||
Remark: request.Remark,
|
||||
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
}
|
||||
var response *dto.PackageTrafficAlertRuleItem
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
pkg, loadErr := s.loadPackage(ctx, tx, request.PackageID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if pkg.RealDataMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
|
||||
}
|
||||
store := s.store.WithTx(tx)
|
||||
exists, existsErr := store.ExistsRuleByPackageID(ctx, request.PackageID)
|
||||
if existsErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, existsErr, "校验套餐预警规则失败")
|
||||
}
|
||||
if exists {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
|
||||
}
|
||||
if createErr := store.CreateRule(ctx, rule); createErr != nil {
|
||||
if isDuplicateKey(createErr) {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐真流量预警规则失败")
|
||||
}
|
||||
item := toRuleItem(rule, pkg.PackageName, pkg.RealDataMB)
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleCreated,
|
||||
"创建套餐真流量预警规则", rule, pkg.PackageName, nil, ruleAuditSnapshot(rule, pkg.PackageName)); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
response = item
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Update 修改阈值、启停与备注。
|
||||
// 修改不回填既有预警,也不改写已冻结的预警快照;结果状态为启用时同样校验商品真流量额度大于零。
|
||||
func (s *RuleService) Update(ctx context.Context, ruleID uint, request *dto.UpdatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
|
||||
operatorID, err := requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request == nil || ruleID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "预警规则ID不能为空")
|
||||
}
|
||||
if request.ThresholdPercent != nil && !packagetrafficalert.IsValidThresholdPercent(*request.ThresholdPercent) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100,允许两位小数")
|
||||
}
|
||||
var response *dto.PackageTrafficAlertRuleItem
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.store.WithTx(tx)
|
||||
rule, lockErr := store.LockRuleByID(ctx, ruleID)
|
||||
if lockErr != nil {
|
||||
return ruleLookupError(lockErr)
|
||||
}
|
||||
before := ruleAuditSnapshot(rule, "")
|
||||
if request.ThresholdPercent != nil {
|
||||
rule.ThresholdPercent = packagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent)
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
rule.Enabled = constants.StatusEnabled
|
||||
if !*request.Enabled {
|
||||
rule.Enabled = constants.StatusDisabled
|
||||
}
|
||||
}
|
||||
if request.Remark != nil {
|
||||
rule.Remark = *request.Remark
|
||||
}
|
||||
pkg, pkgErr := s.loadPackage(ctx, tx, rule.PackageID)
|
||||
if pkgErr != nil {
|
||||
return pkgErr
|
||||
}
|
||||
if rule.Enabled == constants.StatusEnabled && pkg.RealDataMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
|
||||
}
|
||||
packageName := pkg.PackageName
|
||||
if updateErr := store.UpdateRule(ctx, rule, operatorID); updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐真流量预警规则失败")
|
||||
}
|
||||
after := ruleAuditSnapshot(rule, packageName)
|
||||
if before["enabled"] != after["enabled"] {
|
||||
action, summary := constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则"
|
||||
if rule.Enabled != constants.StatusEnabled {
|
||||
action, summary = constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则"
|
||||
}
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, action, summary, rule, packageName,
|
||||
map[string]any{"enabled": before["enabled"]}, map[string]any{"enabled": after["enabled"]}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
if before["threshold_percent"] != after["threshold_percent"] || before["remark"] != after["remark"] {
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleUpdated,
|
||||
"更新套餐真流量预警规则", rule, packageName, before, after); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
response = toRuleItem(rule, packageName, pkg.RealDataMB)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// loadPackage 查询套餐商品;不存在时按参数错误返回。
|
||||
func (s *RuleService) loadPackage(ctx context.Context, tx *gorm.DB, packageID uint) (*model.Package, error) {
|
||||
var pkg model.Package
|
||||
if err := tx.WithContext(ctx).Where("id = ?", packageID).First(&pkg).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐商品不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐商品失败")
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
// appendRuleAudit 在业务事务内追加预警规则事件。
|
||||
func (s *RuleService) appendRuleAudit(ctx context.Context, tx *gorm.DB, action, summary string,
|
||||
rule *model.PackageTrafficAlertRule, packageName string, before, after map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "套餐真流量预警规则统一审计接缝未配置")
|
||||
}
|
||||
var resourceID *string
|
||||
if rule.ID != 0 {
|
||||
value := strconv.FormatUint(uint64(rule.ID), 10)
|
||||
resourceID = &value
|
||||
}
|
||||
displayName := packageName
|
||||
if displayName == "" {
|
||||
displayName = "套餐 " + strconv.FormatUint(uint64(rule.PackageID), 10)
|
||||
}
|
||||
// 使用 AppendAndGet:预警规则属于关键配置,「要求成功必达」的审计失败必须回滚事务(ENG-TX-001)。
|
||||
if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
|
||||
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10)},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourcePackageTrafficAlertRule, ID: resourceID,
|
||||
Key: strconv.FormatUint(uint64(rule.PackageID), 10), DisplayName: displayName,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageTrafficAlertRuleTarget,
|
||||
IdentitySnapshot: ruleAuditIdentity(rule, packageName), BeforeData: before, AfterData: after,
|
||||
}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireOperator 要求调用方已通过后台鉴权,否则拒绝写入。
|
||||
func requireOperator(ctx context.Context) (uint, error) {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// ruleLookupError 把规则不存在映射为统一资源不可见错误。
|
||||
func ruleLookupError(err error) error {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则失败")
|
||||
}
|
||||
|
||||
// isDuplicateKey 判断数据库错误是否为唯一键冲突。
|
||||
func isDuplicateKey(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := err.Error()
|
||||
return strings.Contains(text, "23505") || strings.Contains(text, "duplicate key") || strings.Contains(text, "SQLSTATE 23505")
|
||||
}
|
||||
|
||||
// ruleAuditSnapshot 返回预警规则可审计的可变字段快照。
|
||||
func ruleAuditSnapshot(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
snapshot := map[string]any{
|
||||
"package_id": rule.PackageID,
|
||||
"threshold_percent": rule.ThresholdPercent,
|
||||
"enabled": rule.Enabled,
|
||||
"remark": rule.Remark,
|
||||
}
|
||||
if packageName != "" {
|
||||
snapshot["package_name"] = packageName
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// ruleAuditIdentity 返回预警规则审计身份快照,字段落在注册表白名单内。
|
||||
func ruleAuditIdentity(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
identity := map[string]any{
|
||||
"id": rule.ID, "package_id": rule.PackageID, "threshold_percent": rule.ThresholdPercent,
|
||||
"enabled": rule.Enabled, "remark": rule.Remark,
|
||||
}
|
||||
if packageName != "" {
|
||||
identity["package_name"] = packageName
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// toRuleItem 把规则投影为对外响应项。
|
||||
func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string, realDataMB int64) *dto.PackageTrafficAlertRuleItem {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.PackageTrafficAlertRuleItem{
|
||||
ID: rule.ID,
|
||||
PackageID: rule.PackageID,
|
||||
PackageName: packageName,
|
||||
RealDataMB: realDataMB,
|
||||
ThresholdPercent: rule.ThresholdPercent,
|
||||
Enabled: rule.Enabled == constants.StatusEnabled,
|
||||
EnabledName: enabledName(rule.Enabled),
|
||||
Remark: rule.Remark,
|
||||
UpdatedAt: rule.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// enabledName 返回启停状态的中文名称。
|
||||
func enabledName(enabled int) string {
|
||||
if enabled == constants.StatusEnabled {
|
||||
return "启用"
|
||||
}
|
||||
return "停用"
|
||||
}
|
||||
302
internal/application/packagetrafficalert/scan.go
Normal file
302
internal/application/packagetrafficalert/scan.go
Normal file
@@ -0,0 +1,302 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user