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
}