新增 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。
186 lines
8.3 KiB
Go
186 lines
8.3 KiB
Go
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
|
||
}
|