Files
junhong_cmp_fiber/internal/infrastructure/packagetrafficalert/scanner.go
break d5bcda94fe 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。
2026-09-16 17:05:55 +08:00

412 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}