Files
junhong_cmp_fiber/internal/application/packagetrafficalert/rule.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

300 lines
12 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 收口套餐真流量预警的规则维护事务脚本与每日扫描用例。
//
// 规则维护是简单写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 "停用"
}