新增 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。
412 lines
15 KiB
Go
412 lines
15 KiB
Go
// 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
|
||
}
|