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,185 @@
package packagetrafficalert
import (
"context"
"strconv"
"gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
app "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
)
// AlertWriter 在同一 GORM 事务内写入预警事实、可靠通知事件与成功审计。
//
// 依据 ENG-OUTBOX-001业务事实与可靠异步副作用必须同事务写 Outbox
// 依据 ENG-TX-001成功必达的审计与关键状态事实同事务且事务内不持有不可回滚的外部 I/O。
// 事务粒度为一个命中资产:单资产失败不阻塞其余资产,锁持有时间可控。
type AlertWriter struct {
db *gorm.DB
store *postgres.PackageTrafficAlertStore
outbox *outbox.Repository
auditWriter *audit.Writer
}
// NewAlertWriter 创建套餐真流量预警写入 Adapter。
func NewAlertWriter(db *gorm.DB, store *postgres.PackageTrafficAlertStore, repository *outbox.Repository, auditWriter *audit.Writer) *AlertWriter {
return &AlertWriter{db: db, store: store, outbox: repository, auditWriter: auditWriter}
}
// SaveAlert 幂等创建预警,并在同一事务内写入通知事件与审计。
// 唯一键冲突(同一使用记录 + 同一阈值快照)视为已处理:不写事件、不写审计,直接提交。
func (w *AlertWriter) SaveAlert(ctx context.Context, candidate app.AlertCandidate) (bool, error) {
if w == nil || w.db == nil || w.store == nil {
return false, errors.New(errors.CodeInternalError, "套餐真流量达量预警写入 Adapter 未配置")
}
if w.auditWriter == nil {
return false, errors.New(errors.CodeInvalidStatus, "套餐真流量达量预警统一审计接缝未配置")
}
alert := candidate.Alert
created := false
err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := w.store.WithTx(tx)
inserted, insertErr := store.CreateAlertIdempotent(ctx, &alert)
if insertErr != nil {
return errors.Wrap(errors.CodeDatabaseError, insertErr, "创建套餐真流量达量预警失败")
}
if !inserted {
return nil
}
created = true
if candidate.Notification != nil {
eventID, appendErr := w.appendNotificationEvent(ctx, tx, alert, *candidate.Notification)
if appendErr != nil {
return appendErr
}
if updateErr := tx.WithContext(ctx).Model(&model.PackageTrafficAlert{}).
Where("id = ?", alert.ID).Update("notification_event_id", eventID).Error; updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "回填预警通知事件ID失败")
}
alert.NotificationEventID = eventID
}
return w.appendTriggerAudit(ctx, tx, alert)
})
if err != nil {
return false, err
}
return created, nil
}
// appendNotificationEvent 幂等追加动态通知事件。
// 接收人是触发时冻结的业务员账号TargetKind=account投递期不再重新解析店铺业务员
// 因此业务员变更不会把通知送给不属于它的新账号,也不会在触发时无业务员的情况下补发。
func (w *AlertWriter) appendNotificationEvent(ctx context.Context, tx *gorm.DB, alert model.PackageTrafficAlert, request app.NotificationRequest) (string, error) {
if w.outbox == nil {
return "", errors.New(errors.CodeInternalError, "套餐真流量达量预警 Outbox 仓储未配置")
}
eventID := app.EventIDFor(alert.PackageUsageID, alert.ThresholdPercentSnapshot)
alertID := strconv.FormatUint(uint64(alert.ID), 10)
expiresAt := request.ExpiresAt
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID,
EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: constants.PackageTrafficAlertScanAggregateType,
AggregateID: alertID,
ResourceType: constants.NotificationRefTypePackageTrafficAlert,
ResourceID: alertID,
BusinessKey: eventID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindAccount,
TargetID: request.RecipientAccountID,
NotificationType: constants.NotificationTypePackageTrafficAlert,
TemplateData: request.TemplateData,
RefType: constants.NotificationRefTypePackageTrafficAlert,
RefID: alertID,
RefKey: alert.AssetIdentifierSnapshot,
ExpiresAt: &expiresAt,
},
})
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "写入套餐真流量达量预警通知事件失败")
}
return eventID, nil
}
// appendTriggerAudit 在业务事务内追加达量预警审计事实。
// 审计动作的 actor/source 固定为系统任务与 Worker与注册表声明一致。
func (w *AlertWriter) appendTriggerAudit(ctx context.Context, tx *gorm.DB, alert model.PackageTrafficAlert) error {
alertID := strconv.FormatUint(uint64(alert.ID), 10)
usageID := strconv.FormatUint(uint64(alert.PackageUsageID), 10)
summary := "创建套餐真流量达量预警"
if alert.NotificationEventID == "" {
summary = "创建套餐真流量达量预警(触发时无有效业务员,未生成通知)"
}
// 使用 AppendAndGet达量预警审计属于「要求成功必达」的事实失败必须回滚整个事务ENG-TX-001
if _, err := w.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourcePackageTrafficAlert, alert.ID, "trigger"),
ActionCode: constants.AuditActionPackageTrafficAlertTriggered, Summary: summary,
// Actor.ID 必须非空且稳定:审计写入要求操作者标识,缺省会静默失败。
Actor: audit.ActorInput{
Kind: constants.AuditActorSystemTask,
ID: constants.TaskTypePackageTrafficAlertScan,
Name: "套餐真流量达量扫描",
},
Source: constants.AuditSourceWorker, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourcePackageTrafficAlert, ID: &alertID, Key: usageID,
DisplayName: alert.PackageNameSnapshot,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageTrafficAlertTarget,
IdentitySnapshot: alertAuditIdentity(alert), AfterData: alertAuditSnapshot(alert),
}},
}); err != nil {
return err
}
return nil
}
// alertAuditIdentity 返回预警审计身份快照,字段落在注册表白名单内。
func alertAuditIdentity(alert model.PackageTrafficAlert) map[string]any {
identity := map[string]any{
"id": alert.ID, "package_usage_id": alert.PackageUsageID, "package_id": alert.PackageID,
"asset_type": alert.AssetType, "asset_id": alert.AssetID,
"threshold_percent_snapshot": alert.ThresholdPercentSnapshot,
"used_mb_snapshot": alert.UsedMBSnapshot, "limit_mb_snapshot": alert.LimitMBSnapshot,
"usage_percent_snapshot": alert.UsagePercentSnapshot,
"shop_id": alert.ShopIDSnapshot,
}
if alert.BusinessOwnerAccountIDSnapshot != nil {
identity["business_owner_account_id"] = *alert.BusinessOwnerAccountIDSnapshot
} else {
identity["business_owner_account_id"] = nil
}
return identity
}
// alertAuditSnapshot 返回预警审计的 after 快照,用于忠实还原触发时的冻结事实。
func alertAuditSnapshot(alert model.PackageTrafficAlert) map[string]any {
snapshot := alertAuditIdentity(alert)
snapshot["rule_id"] = alert.RuleID
snapshot["asset_identifier"] = alert.AssetIdentifierSnapshot
snapshot["card_identifier"] = alert.CardIdentifierSnapshot
snapshot["counterpart_identifier"] = alert.CounterpartIdentifierSnapshot
snapshot["package_name"] = alert.PackageNameSnapshot
snapshot["shop_name"] = alert.ShopNameSnapshot
snapshot["notification_event_id"] = alert.NotificationEventID
if alert.BusinessOwnerAccountIDSnapshot != nil {
snapshot["business_owner_account_id"] = *alert.BusinessOwnerAccountIDSnapshot
} else {
snapshot["business_owner_account_id"] = nil
}
if alert.ExpiresAtSnapshot != nil {
snapshot["expires_at"] = alert.ExpiresAtSnapshot.UTC()
} else {
snapshot["expires_at"] = nil
}
snapshot["triggered_at"] = alert.TriggeredAt.UTC()
return snapshot
}