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:
2026-09-16 17:05:55 +08:00
parent ef4d3696d4
commit d5bcda94fe
46 changed files with 3679 additions and 100 deletions

View 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 "停用"
}

View 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))
}