新增 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。
300 lines
12 KiB
Go
300 lines
12 KiB
Go
// 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 "停用"
|
||
}
|