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:
@@ -221,6 +221,11 @@ func NewRegistry() *Registry {
|
||||
businessUserGroupDisabled := businessUserGroupAction(constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup)
|
||||
businessUserGroupDeleted := businessUserGroupAction(constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", constants.AuditRiskHigh, constants.AuditResourceBusinessUserGroup)
|
||||
businessUserGroupMembersUpdated := businessUserGroupAction(constants.AuditActionBusinessUserGroupMembersUpdated, "批量维护业务用户组成员", constants.AuditRiskNormal, constants.AuditResourceAccount)
|
||||
packageTrafficAlertRuleCreated := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleCreated, "创建套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
packageTrafficAlertRuleUpdated := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleUpdated, "更新套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
packageTrafficAlertRuleEnabled := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
packageTrafficAlertRuleDisabled := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
packageTrafficAlertTriggered := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertTriggered, "创建套餐真流量达量预警", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlert, constants.AuditActorSystemTask, constants.AuditSourceWorker)
|
||||
shopBusinessOwnerBatchUpdated := batchRootAction(constants.AuditActionShopBusinessOwnerBatchUpdated, "批量交接店铺负责人", constants.AuditResourceShopBusinessOwnerBatch)
|
||||
shopBusinessOwnerImported := taskAction(constants.AuditActionShopBusinessOwnerImported, "导入变更店铺负责人", constants.AuditResourceShop, constants.AuditActorSystemTask, constants.AuditSourceWorker)
|
||||
shopBusinessOwnerImportTaskCreated := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCreated, "创建店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
@@ -594,6 +599,11 @@ func NewRegistry() *Registry {
|
||||
constants.AuditActionBusinessUserGroupDisabled: businessUserGroupDisabled,
|
||||
constants.AuditActionBusinessUserGroupDeleted: businessUserGroupDeleted,
|
||||
constants.AuditActionBusinessUserGroupMembersUpdated: businessUserGroupMembersUpdated,
|
||||
constants.AuditActionPackageTrafficAlertRuleCreated: packageTrafficAlertRuleCreated,
|
||||
constants.AuditActionPackageTrafficAlertRuleUpdated: packageTrafficAlertRuleUpdated,
|
||||
constants.AuditActionPackageTrafficAlertRuleEnabled: packageTrafficAlertRuleEnabled,
|
||||
constants.AuditActionPackageTrafficAlertRuleDisabled: packageTrafficAlertRuleDisabled,
|
||||
constants.AuditActionPackageTrafficAlertTriggered: packageTrafficAlertTriggered,
|
||||
constants.AuditActionShopBusinessOwnerBatchUpdated: shopBusinessOwnerBatchUpdated,
|
||||
constants.AuditActionShopBusinessOwnerImported: shopBusinessOwnerImported,
|
||||
constants.AuditActionShopBusinessOwnerImportTaskCreated: shopBusinessOwnerImportTaskCreated,
|
||||
@@ -841,6 +851,14 @@ func NewRegistry() *Registry {
|
||||
Type: constants.AuditResourcePhoneAssetUnbindImportTask, Name: "手机号资产解绑导入任务",
|
||||
IdentityFields: []string{"id", "task_no", "file_name"},
|
||||
},
|
||||
constants.AuditResourcePackageTrafficAlertRule: {
|
||||
Type: constants.AuditResourcePackageTrafficAlertRule, Name: "套餐真流量预警规则",
|
||||
IdentityFields: []string{"id", "package_id", "threshold_percent", "enabled", "remark"},
|
||||
},
|
||||
constants.AuditResourcePackageTrafficAlert: {
|
||||
Type: constants.AuditResourcePackageTrafficAlert, Name: "套餐真流量达量预警",
|
||||
IdentityFields: []string{"id", "package_usage_id", "package_id", "asset_type", "asset_id", "threshold_percent_snapshot", "used_mb_snapshot", "limit_mb_snapshot", "usage_percent_snapshot", "shop_id", "business_owner_account_id"},
|
||||
},
|
||||
constants.AuditResourceNotification: {
|
||||
Type: constants.AuditResourceNotification, Name: "站内通知",
|
||||
IdentityFields: []string{"id", "event_id", "recipient_kind", "recipient_id", "category", "type", "severity", "ref_type", "ref_id", "ref_key"},
|
||||
@@ -1403,6 +1421,17 @@ func businessUserGroupAction(code, name, risk, primaryResource string) ActionDef
|
||||
}
|
||||
}
|
||||
|
||||
// packageTrafficAlertAction 定义套餐真流量预警规则维护与达量触发动作。
|
||||
// 规则维护由后台 API 写入;达量触发由扫描任务在业务事务内写入,两者通过 actor/source 区分。
|
||||
func packageTrafficAlertAction(code, name, risk, primaryResource, actor, source string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: risk,
|
||||
PrimaryResource: primaryResource, AllowedActor: actor, Source: source, RequireTransaction: true,
|
||||
DefaultVisibility: constants.AuditSubjectInternalOnly,
|
||||
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
|
||||
}
|
||||
}
|
||||
|
||||
// batchRootAction 定义同步后台批次根动作;子事件自带店铺或资源作用域。
|
||||
func batchRootAction(code, name, primaryResource string) ActionDefinition {
|
||||
return ActionDefinition{
|
||||
|
||||
@@ -142,6 +142,21 @@ func NewRegistry() *Registry {
|
||||
constants.NotificationRefTypeAsset: {},
|
||||
},
|
||||
},
|
||||
// 套餐真流量达量预警:类别沿用 expiry,接收人只允许触发时冻结的平台业务员账号,
|
||||
// 资源引用只指向预警详情,正文不含任何 URL 或前端路由。
|
||||
constants.NotificationTypePackageTrafficAlert: {
|
||||
Type: constants.NotificationTypePackageTrafficAlert, Category: constants.NotificationCategoryExpiry,
|
||||
Severity: constants.NotificationSeverityWarning,
|
||||
TitleTemplate: "套餐真流量达量预警",
|
||||
BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 真流量已用 {{.usage_percent}}%,达到预警阈值 {{.threshold_percent}}%。",
|
||||
TemplateFields: map[string]struct{}{
|
||||
"asset_identifier": {}, "package_name": {}, "usage_percent": {}, "threshold_percent": {},
|
||||
},
|
||||
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
|
||||
AllowedRefTypes: map[string]struct{}{
|
||||
constants.NotificationRefTypePackageTrafficAlert: {},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
411
internal/infrastructure/packagetrafficalert/scanner.go
Normal file
411
internal/infrastructure/packagetrafficalert/scanner.go
Normal 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
|
||||
}
|
||||
185
internal/infrastructure/packagetrafficalert/writer.go
Normal file
185
internal/infrastructure/packagetrafficalert/writer.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user