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,411 @@
// Package packagetrafficalert 提供套餐真流量达量扫描的 PostgreSQL 只读 Adapter
// 与预警落库(事实 + 可靠通知事件 + 审计)的写 Adapter。
package packagetrafficalert
import (
"context"
"time"
"gorm.io/gorm"
app "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Scanner 按资产汇总真流量并提供主套餐、规则与资产事实。
//
// 真流量口径固定为 tb_package_usage.data_usage_mb分子与 data_limit_mb分母快照
// 不读取虚流量、展示量、卡级累计或运营商通道累计。有效集合为
// status IN (1,2) AND refund_id IS NULL AND deleted_at IS NULL只按状态判定过期不引入到期时间判断。
// iot_card_id 与 device_id 在历史数据中同时存在 NULL 与 0 两种「无值」写法,
// 因此分组与过滤一律使用 COALESCE(..., 0),禁止只写 > 0。
type Scanner struct {
db *gorm.DB
}
// NewScanner 创建套餐真流量达量扫描只读 Adapter。
func NewScanner(db *gorm.DB) *Scanner {
return &Scanner{db: db}
}
type aggregateRow struct {
CardKey uint `gorm:"column:card_key"`
DeviceKey uint `gorm:"column:device_key"`
UsedMB int64 `gorm:"column:used_mb"`
LimitMB int64 `gorm:"column:limit_mb"`
}
// assetKeyColumns 是资产键的权威 SQL 表达式:卡优先,仅当行无卡归属时才取设备 ID。
// 同一卡下设备 ID 混绑的多行必须归入同一资产键,因此键的两列都由 iot_card_id 主导,
// 聚合 GROUP BY 与主套餐 ROW_NUMBER 分区必须使用同一表达式,避免 used/limit 汇总分裂。
const assetKeyColumns = "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN COALESCE(pu.iot_card_id, 0) ELSE 0 END, " +
"CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN 0 ELSE COALESCE(pu.device_id, 0) END"
// LoadAssetAggregates 按资产汇总当前有效套餐的真已用量与真总量快照。
func (s *Scanner) LoadAssetAggregates(ctx context.Context) ([]app.AssetAggregate, error) {
var rows []aggregateRow
err := s.db.WithContext(ctx).Table("tb_package_usage AS pu").
Select("CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN COALESCE(pu.iot_card_id, 0) ELSE 0 END AS card_key, "+
"CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN 0 ELSE COALESCE(pu.device_id, 0) END AS device_key, "+
"SUM(pu.data_usage_mb) AS used_mb, SUM(pu.data_limit_mb) AS limit_mb").
Where("pu.deleted_at IS NULL").
Where("pu.refund_id IS NULL").
Where("pu.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("COALESCE(pu.iot_card_id, 0) > 0 OR COALESCE(pu.device_id, 0) > 0").
Group(assetKeyColumns).
Scan(&rows).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "按资产汇总套餐真流量失败")
}
aggregates := make([]app.AssetAggregate, 0, len(rows))
for _, row := range rows {
key, ok := assetKey(row.CardKey, row.DeviceKey)
if !ok {
continue
}
aggregates = append(aggregates, app.AssetAggregate{Key: key, UsedMB: row.UsedMB, LimitMB: row.LimitMB})
}
return aggregates, nil
}
// LoadMainUsages 批量读取每个资产的主套餐使用记录。
// 主套餐为 master_usage_id 为空的记录,多条时按 priority ASC, activated_at ASC, id ASC 取第一条;
// 分区键与聚合 GROUP BY 共用 assetKeyColumns卡优先保证同一卡下设备 ID 混绑的行归入同一资产键。
func (s *Scanner) LoadMainUsages(ctx context.Context, keys []app.AssetKey) (map[app.AssetKey]app.MainUsage, error) {
result := make(map[app.AssetKey]app.MainUsage, len(keys))
if len(keys) == 0 {
return result, nil
}
cardIDs, deviceIDs := splitAssetKeys(keys)
var rows []struct {
CardKey uint `gorm:"column:card_key"`
DeviceKey uint `gorm:"column:device_key"`
ID uint `gorm:"column:id"`
PackageID uint `gorm:"column:package_id"`
PackageName string `gorm:"column:package_name"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
}
cardKeyExpr := "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN COALESCE(pu.iot_card_id, 0) ELSE 0 END"
deviceKeyExpr := "CASE WHEN COALESCE(pu.iot_card_id, 0) > 0 THEN 0 ELSE COALESCE(pu.device_id, 0) END"
inner := s.db.WithContext(ctx).Table("tb_package_usage AS pu").
Select("pu.id, pu.package_id, "+
cardKeyExpr+" AS card_key, "+
deviceKeyExpr+" AS device_key, "+
"COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name, pu.expires_at, "+
"ROW_NUMBER() OVER (PARTITION BY "+assetKeyColumns+
" ORDER BY pu.priority ASC, pu.activated_at ASC NULLS LAST, pu.id ASC) AS rn").
Joins("LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL").
Where("pu.deleted_at IS NULL AND pu.refund_id IS NULL").
Where("pu.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("pu.master_usage_id IS NULL").
Where(s.db.Where(cardKeyExpr+" IN ? AND "+deviceKeyExpr+" = 0", cardIDs).
Or(cardKeyExpr+" = 0 AND "+deviceKeyExpr+" IN ?", deviceIDs))
if err := s.db.WithContext(ctx).Table("(?) AS main_usage", inner).
Where("main_usage.rn = 1").Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产主套餐使用记录失败")
}
for _, row := range rows {
key, ok := assetKey(row.CardKey, row.DeviceKey)
if !ok {
continue
}
usage := app.MainUsage{
PackageUsageID: row.ID,
PackageID: row.PackageID,
PackageName: row.PackageName,
ExpiresAt: row.ExpiresAt,
}
result[key] = usage
}
return result, nil
}
// LoadEnabledRules 批量读取套餐商品当前启用的预警规则。
func (s *Scanner) LoadEnabledRules(ctx context.Context, packageIDs []uint) (map[uint]app.EnabledRule, error) {
result := make(map[uint]app.EnabledRule, len(packageIDs))
if len(packageIDs) == 0 {
return result, nil
}
var rows []struct {
ID uint `gorm:"column:id"`
PackageID uint `gorm:"column:package_id"`
ThresholdPercent float64 `gorm:"column:threshold_percent"`
}
err := s.db.WithContext(ctx).Table("tb_package_traffic_alert_rule").
Select("id, package_id, threshold_percent").
Where("package_id IN ?", packageIDs).
Where("enabled = ?", constants.StatusEnabled).
Scan(&rows).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则失败")
}
for _, row := range rows {
result[row.PackageID] = app.EnabledRule{ID: row.ID, PackageID: row.PackageID, ThresholdPercent: row.ThresholdPercent}
}
return result, nil
}
// LoadAssetFacts 批量读取资产展示事实与触发时归属。
func (s *Scanner) LoadAssetFacts(ctx context.Context, keys []app.AssetKey) (map[app.AssetKey]app.AssetFacts, error) {
result := make(map[app.AssetKey]app.AssetFacts, len(keys))
if len(keys) == 0 {
return result, nil
}
cardIDs, deviceIDs := splitAssetKeys(keys)
shopIDs := make(map[uint]struct{})
ownerIDs := make(map[uint]struct{})
if err := s.loadCardFacts(ctx, cardIDs, result, shopIDs); err != nil {
return nil, err
}
if err := s.loadDeviceFacts(ctx, deviceIDs, result, shopIDs); err != nil {
return nil, err
}
if err := s.fillShops(ctx, shopIDs, ownerIDs, result); err != nil {
return nil, err
}
if err := s.fillBusinessOwners(ctx, ownerIDs, result); err != nil {
return nil, err
}
return result, nil
}
type cardFactRow struct {
ID uint `gorm:"column:id"`
ICCID string `gorm:"column:iccid"`
ShopID *uint `gorm:"column:shop_id"`
DeviceID *uint `gorm:"column:bound_device_id"`
VirtualNo string `gorm:"column:bound_device_virtual_no"`
IMEI string `gorm:"column:bound_device_imei"`
SN string `gorm:"column:bound_device_sn"`
DeviceType string `gorm:"column:bound_device_type"`
DeviceModel string `gorm:"column:bound_device_model"`
}
func (s *Scanner) loadCardFacts(ctx context.Context, cardIDs []uint, result map[app.AssetKey]app.AssetFacts, shopIDs map[uint]struct{}) error {
if len(cardIDs) == 0 {
return nil
}
var rows []cardFactRow
err := s.db.WithContext(ctx).Table("tb_iot_card AS c").
Select(`c.id, c.iccid, c.shop_id,
dev.id AS bound_device_id, dev.virtual_no AS bound_device_virtual_no, dev.imei AS bound_device_imei,
dev.sn AS bound_device_sn, dev.device_type AS bound_device_type, dev.device_model AS bound_device_model`).
Joins(`LEFT JOIN LATERAL (
SELECT d.id, d.virtual_no, d.imei, d.sn, d.device_type, d.device_model
FROM tb_device_sim_binding AS b
JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL
WHERE b.iot_card_id = c.id AND b.bind_status = ? AND b.deleted_at IS NULL
ORDER BY b.is_current DESC, b.id DESC
LIMIT 1
) AS dev ON TRUE`, constants.BindStatusBound).
Where("c.deleted_at IS NULL").
Where("c.id IN ?", cardIDs).
Scan(&rows).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡资产展示事实失败")
}
for _, row := range rows {
facts := app.AssetFacts{
AssetIdentifier: row.ICCID,
CardIdentifier: row.ICCID,
CounterpartIdentifier: deviceIdentifier(row.VirtualNo, row.IMEI, row.SN),
DeviceType: row.DeviceType,
DeviceModel: row.DeviceModel,
}
if row.ShopID != nil && *row.ShopID > 0 {
facts.ShopID = *row.ShopID
shopIDs[*row.ShopID] = struct{}{}
}
result[app.AssetKey{AssetType: constants.AssetTypeIotCard, AssetID: row.ID}] = facts
}
return nil
}
type deviceFactRow struct {
ID uint `gorm:"column:id"`
VirtualNo string `gorm:"column:virtual_no"`
IMEI string `gorm:"column:imei"`
SN string `gorm:"column:sn"`
DeviceType string `gorm:"column:device_type"`
DeviceModel string `gorm:"column:device_model"`
ShopID *uint `gorm:"column:shop_id"`
BoundICCID string `gorm:"column:bound_card_iccid"`
}
func (s *Scanner) loadDeviceFacts(ctx context.Context, deviceIDs []uint, result map[app.AssetKey]app.AssetFacts, shopIDs map[uint]struct{}) error {
if len(deviceIDs) == 0 {
return nil
}
var rows []deviceFactRow
err := s.db.WithContext(ctx).Table("tb_device AS d").
Select(`d.id, d.virtual_no, d.imei, d.sn, d.device_type, d.device_model, d.shop_id,
card.iccid AS bound_card_iccid`).
Joins(`LEFT JOIN LATERAL (
SELECT c.iccid
FROM tb_device_sim_binding AS b
JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL
WHERE b.device_id = d.id AND b.bind_status = ? AND b.deleted_at IS NULL
ORDER BY b.is_current DESC, b.id DESC
LIMIT 1
) AS card ON TRUE`, constants.BindStatusBound).
Where("d.deleted_at IS NULL").
Where("d.id IN ?", deviceIDs).
Scan(&rows).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备资产展示事实失败")
}
for _, row := range rows {
facts := app.AssetFacts{
AssetIdentifier: deviceIdentifier(row.VirtualNo, row.IMEI, row.SN),
CardIdentifier: row.BoundICCID,
CounterpartIdentifier: row.BoundICCID,
DeviceType: row.DeviceType,
DeviceModel: row.DeviceModel,
}
if row.ShopID != nil && *row.ShopID > 0 {
facts.ShopID = *row.ShopID
shopIDs[*row.ShopID] = struct{}{}
}
result[app.AssetKey{AssetType: constants.AssetTypeDevice, AssetID: row.ID}] = facts
}
return nil
}
// fillShops 批量回填触发时店铺名称与业务员账号。
func (s *Scanner) fillShops(ctx context.Context, shopIDs map[uint]struct{}, ownerIDs map[uint]struct{}, result map[app.AssetKey]app.AssetFacts) error {
if len(shopIDs) == 0 {
return nil
}
ids := mapKeys(shopIDs)
var shops []struct {
ID uint `gorm:"column:id"`
ShopName string `gorm:"column:shop_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
}
if err := s.db.WithContext(ctx).Table("tb_shop AS sh").
Select("sh.id, sh.shop_name, sh.business_owner_account_id").
Where("sh.id IN ?", ids).
Where("sh.deleted_at IS NULL").
Scan(&shops).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺归属失败")
}
shopByID := make(map[uint]struct {
Name string
OwnerID *uint
}, len(shops))
for _, shop := range shops {
shopByID[shop.ID] = struct {
Name string
OwnerID *uint
}{Name: shop.ShopName, OwnerID: shop.BusinessOwnerAccountID}
if shop.BusinessOwnerAccountID != nil && *shop.BusinessOwnerAccountID > 0 {
ownerIDs[*shop.BusinessOwnerAccountID] = struct{}{}
}
}
for key, facts := range result {
shop, ok := shopByID[facts.ShopID]
if !ok {
// 店铺已软删:按无店铺处理,不解析业务员,预警行保留快照兜底。
facts.ShopID = 0
facts.ShopName = ""
facts.BusinessOwnerID = nil
facts.BusinessOwnerName = ""
result[key] = facts
continue
}
facts.ShopName = shop.Name
facts.BusinessOwnerID = shop.OwnerID
result[key] = facts
}
return nil
}
// fillBusinessOwners 只保留「仅业务员」解析路径认可的有效账号。
// 判定为 tb_account.user_type = platform 且 status 启用且未软删;指向店铺代理账号或停用账号一律视为无有效业务员。
func (s *Scanner) fillBusinessOwners(ctx context.Context, ownerIDs map[uint]struct{}, result map[app.AssetKey]app.AssetFacts) error {
if len(ownerIDs) == 0 {
return nil
}
ids := mapKeys(ownerIDs)
var accounts []struct {
ID uint `gorm:"column:id"`
Username string `gorm:"column:username"`
}
if err := s.db.WithContext(ctx).Table("tb_account AS a").
Select("a.id, a.username").
Where("a.id IN ?", ids).
Where("a.user_type = ?", constants.UserTypePlatform).
Where("a.status = ?", constants.StatusEnabled).
Where("a.deleted_at IS NULL").
Scan(&accounts).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺业务员账号失败")
}
accountsByID := make(map[uint]string, len(accounts))
for _, account := range accounts {
accountsByID[account.ID] = account.Username
}
for key, facts := range result {
if facts.BusinessOwnerID == nil {
continue
}
name, ok := accountsByID[*facts.BusinessOwnerID]
if !ok {
facts.BusinessOwnerID = nil
facts.BusinessOwnerName = ""
result[key] = facts
continue
}
facts.BusinessOwnerName = name
result[key] = facts
}
return nil
}
// assetKey 按卡优先的互斥规则生成资产键;两列都无值时返回 false。
func assetKey(iotCardID, deviceID uint) (app.AssetKey, bool) {
if iotCardID > 0 {
return app.AssetKey{AssetType: constants.AssetTypeIotCard, AssetID: iotCardID}, true
}
if deviceID > 0 {
return app.AssetKey{AssetType: constants.AssetTypeDevice, AssetID: deviceID}, true
}
return app.AssetKey{}, false
}
// splitAssetKeys 拆分出卡 ID 与设备 ID 集合。
func splitAssetKeys(keys []app.AssetKey) ([]uint, []uint) {
cardIDs := make([]uint, 0, len(keys))
deviceIDs := make([]uint, 0, len(keys))
for _, key := range keys {
if key.AssetID == 0 {
continue
}
if key.AssetType == constants.AssetTypeDevice {
deviceIDs = append(deviceIDs, key.AssetID)
continue
}
cardIDs = append(cardIDs, key.AssetID)
}
return cardIDs, deviceIDs
}
// deviceIdentifier 按虚拟号→IMEI→SN 的稳定优先级生成设备标识。
func deviceIdentifier(virtualNo, imei, sn string) string {
if virtualNo != "" {
return virtualNo
}
if imei != "" {
return imei
}
return sn
}
// mapKeys 返回集合的键切片。
func mapKeys(values map[uint]struct{}) []uint {
keys := make([]uint, 0, len(values))
for key := range values {
keys = append(keys, key)
}
return keys
}

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
}