新增 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。
351 lines
14 KiB
Go
351 lines
14 KiB
Go
package exporter
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
)
|
|
|
|
// PackageTrafficAlertDataSource 套餐真流量达量预警导出数据源。
|
|
//
|
|
// 粒度为一条预警记录。套餐、用量、总量、阈值、到期时间与资产标识类列一律读预警行冻结的触发快照;
|
|
// 店铺、业务员与用户组按导出执行时当前归属补充,用户组按既有实时推导,不写入店铺表。
|
|
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再校验一次,
|
|
// 阻止通过通用导出入口以代理身份创建本场景任务后读到预警数据。
|
|
type PackageTrafficAlertDataSource struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewPackageTrafficAlertDataSource 创建套餐真流量达量预警导出数据源。
|
|
func NewPackageTrafficAlertDataSource(db *gorm.DB) *PackageTrafficAlertDataSource {
|
|
return &PackageTrafficAlertDataSource{db: db}
|
|
}
|
|
|
|
// Scene 返回导出场景编码。
|
|
func (s *PackageTrafficAlertDataSource) Scene() string {
|
|
return constants.ExportTaskScenePackageTrafficAlert
|
|
}
|
|
|
|
// Count 统计导出预警行数。
|
|
func (s *PackageTrafficAlertDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
|
|
if err := ensurePackageTrafficAlertExportAllowed(params); err != nil {
|
|
return 0, err
|
|
}
|
|
var total int64
|
|
if err := s.applyFilters(s.baseQuery(ctx, params), params).Count(&total).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return int(total), nil
|
|
}
|
|
|
|
// Headers 返回套餐真流量达量预警导出表头。
|
|
// 表头在 dispatch 阶段冻结,历史任务重导出沿用同一列序;不含任何运营商通道列。
|
|
func (s *PackageTrafficAlertDataSource) Headers(context.Context, ExportParams) ([]string, error) {
|
|
return []string{
|
|
"资产类型", "资产标识", "对应标识符", "卡标识", "设备类型", "设备型号",
|
|
"套餐名称", "真流量已用量(MB)", "真流量额度(MB)", "比例(%)", "阈值快照(%)",
|
|
"到期时间", "剩余天数", "触发时间", "店铺", "业务员", "用户组", "通知投递结果",
|
|
}, nil
|
|
}
|
|
|
|
// Fetch 按 offset/limit 查询预警导出数据。
|
|
func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
|
|
if limit <= 0 {
|
|
return [][]string{}, nil
|
|
}
|
|
if err := ensurePackageTrafficAlertExportAllowed(params); err != nil {
|
|
return nil, err
|
|
}
|
|
var items []packageTrafficAlertExportRow
|
|
query := s.applyFilters(s.baseQuery(ctx, params), params).
|
|
Select(`
|
|
a.asset_type,
|
|
a.asset_identifier_snapshot,
|
|
a.counterpart_identifier_snapshot,
|
|
a.card_identifier_snapshot,
|
|
a.device_type_snapshot,
|
|
a.device_model_snapshot,
|
|
a.package_name_snapshot,
|
|
a.used_mb_snapshot,
|
|
a.limit_mb_snapshot,
|
|
a.usage_percent_snapshot,
|
|
a.threshold_percent_snapshot,
|
|
a.expires_at_snapshot,
|
|
a.triggered_at,
|
|
a.shop_id_snapshot,
|
|
a.shop_name_snapshot,
|
|
a.business_owner_account_id_snapshot,
|
|
a.business_owner_name_snapshot,
|
|
a.notification_event_id,
|
|
sh.id AS current_shop_id,
|
|
COALESCE(sh.shop_name, '') AS current_shop_name,
|
|
owner.id AS current_owner_id,
|
|
COALESCE(owner.username, '') AS current_owner_name,
|
|
oe.status AS outbox_status,
|
|
n.id AS notification_id
|
|
`).
|
|
Order("a.triggered_at DESC").Order("a.id DESC").
|
|
Limit(limit).Offset(offset)
|
|
if err := query.Scan(&items).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警导出数据失败")
|
|
}
|
|
groupNames, err := s.loadBusinessUserGroupNames(ctx, items)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
rows := make([][]string, 0, len(items))
|
|
for _, item := range items {
|
|
rows = append(rows, []string{
|
|
assetTypeName(item.AssetType),
|
|
item.AssetIdentifier,
|
|
item.CounterpartIdentifier,
|
|
item.CardIdentifier,
|
|
item.DeviceType,
|
|
item.DeviceModel,
|
|
item.PackageName,
|
|
strconv.FormatInt(item.UsedMB, 10),
|
|
strconv.FormatInt(item.LimitMB, 10),
|
|
formatPercentValue(item.UsagePercent),
|
|
formatPercentValue(item.ThresholdPercent),
|
|
formatOptionalTime(item.ExpiresAt),
|
|
formatRemainingDays(item.ExpiresAt, now),
|
|
item.TriggeredAt.Format(exportTimeLayout),
|
|
item.CurrentShopName,
|
|
item.CurrentOwnerName,
|
|
currentOwnerGroupName(groupNames, item.CurrentOwnerID),
|
|
constants.GetPackageTrafficAlertNotifyStatusName(resolveAlertNotifyStatus(item)),
|
|
})
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// baseQuery 构造预警导出基础查询。
|
|
// 归属展示列按执行时当前归属补充:资产 → 当前店铺 → 店铺当前业务员;用户组随后按业务员账号实时推导。
|
|
func (s *PackageTrafficAlertDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB {
|
|
query := s.db.WithContext(ctx).Table("tb_package_traffic_alert AS a").
|
|
Joins("LEFT JOIN tb_iot_card AS c ON a.asset_type = ? AND c.id = a.asset_id AND c.deleted_at IS NULL",
|
|
constants.AssetTypeIotCard).
|
|
Joins("LEFT JOIN tb_device AS d ON a.asset_type = ? AND d.id = a.asset_id AND d.deleted_at IS NULL",
|
|
constants.AssetTypeDevice).
|
|
Joins("LEFT JOIN tb_shop AS sh ON sh.id = COALESCE(c.shop_id, d.shop_id) AND sh.deleted_at IS NULL").
|
|
Joins("LEFT JOIN tb_account AS owner ON owner.id = sh.business_owner_account_id AND owner.deleted_at IS NULL").
|
|
Joins("LEFT JOIN tb_outbox_event AS oe ON oe.event_id = a.notification_event_id").
|
|
Joins("LEFT JOIN tb_notification AS n ON n.event_id = a.notification_event_id")
|
|
// 数据范围使用导出侧范围过滤(空范围拒绝),不得使用请求上下文版过滤(空范围语义相反)。
|
|
return applyExportShopScope(query, params, "a.shop_id_snapshot")
|
|
}
|
|
|
|
// applyFilters 应用导出筛选快照。
|
|
// 筛选口径与列表一致,都作用在触发快照列上;时间范围按触发时间的闭区间解析。
|
|
func (s *PackageTrafficAlertDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
|
|
if packageID, ok := filterUint(params.Filters, "package_id"); ok {
|
|
query = query.Where("a.package_id = ?", packageID)
|
|
}
|
|
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
|
|
query = query.Where("a.shop_id_snapshot = ?", shopID)
|
|
}
|
|
if ownerID, ok := filterUint(params.Filters, "business_owner_account_id"); ok {
|
|
query = query.Where("a.business_owner_account_id_snapshot = ?", ownerID)
|
|
}
|
|
if assetType, ok := filterString(params.Filters, "asset_type"); ok {
|
|
query = query.Where("a.asset_type = ?", assetType)
|
|
}
|
|
if identifier, ok := filterString(params.Filters, "asset_identifier"); ok {
|
|
pattern := "%" + identifier + "%"
|
|
query = query.Where("(a.asset_identifier_snapshot ILIKE ? OR a.card_identifier_snapshot ILIKE ? "+
|
|
"OR a.counterpart_identifier_snapshot ILIKE ?)", pattern, pattern, pattern)
|
|
}
|
|
if threshold, ok := alertFilterFloat(params.Filters, "threshold_percent"); ok {
|
|
query = query.Where("a.threshold_percent_snapshot = ?",
|
|
packagetrafficalert.NormalizeThresholdPercent(threshold))
|
|
}
|
|
if startTime, ok := filterTime(params.Filters, "start_time"); ok {
|
|
query = query.Where("a.triggered_at >= ?", startTime.UTC())
|
|
}
|
|
if endTime, ok := filterTime(params.Filters, "end_time"); ok {
|
|
query = query.Where("a.triggered_at <= ?", endTime.UTC())
|
|
}
|
|
if status, ok := filterInt(params.Filters, "notification_status"); ok {
|
|
query = applyAlertNotificationStatusFilter(query, status)
|
|
}
|
|
return query
|
|
}
|
|
|
|
// applyAlertNotificationStatusFilter 按通知投递结果筛选,口径与读侧列表一致。
|
|
func applyAlertNotificationStatusFilter(query *gorm.DB, status int) *gorm.DB {
|
|
const hasEvent = "a.notification_event_id <> ''"
|
|
const hasNotification = "n.id IS NOT NULL"
|
|
switch status {
|
|
case constants.PackageTrafficAlertNotifyNoBusinessOwner:
|
|
return query.Where("a.notification_event_id = ''")
|
|
case constants.PackageTrafficAlertNotifyNotified:
|
|
return query.Where(hasEvent).Where(hasNotification)
|
|
case constants.PackageTrafficAlertNotifyPending:
|
|
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
|
|
Where("oe.status IN ?", []int{constants.OutboxStatusPending, constants.OutboxStatusDelivering})
|
|
case constants.PackageTrafficAlertNotifyFailed:
|
|
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
|
|
Where("oe.status = ?", constants.OutboxStatusFailed)
|
|
case constants.PackageTrafficAlertNotifyRecipientGone:
|
|
return query.Where(hasEvent).Where("NOT ("+hasNotification+")").
|
|
Where("oe.status = ?", constants.OutboxStatusDelivered)
|
|
default:
|
|
return query
|
|
}
|
|
}
|
|
|
|
// loadBusinessUserGroupNames 按执行时当前业务员账号批量推导业务用户组名称。
|
|
// 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接。
|
|
func (s *PackageTrafficAlertDataSource) loadBusinessUserGroupNames(ctx context.Context,
|
|
items []packageTrafficAlertExportRow) (map[uint]string, error) {
|
|
result := make(map[uint]string)
|
|
ownerIDs := make([]uint, 0, len(items))
|
|
seen := make(map[uint]struct{}, len(items))
|
|
for _, item := range items {
|
|
if item.CurrentOwnerID == nil || *item.CurrentOwnerID == 0 {
|
|
continue
|
|
}
|
|
if _, exists := seen[*item.CurrentOwnerID]; exists {
|
|
continue
|
|
}
|
|
seen[*item.CurrentOwnerID] = struct{}{}
|
|
ownerIDs = append(ownerIDs, *item.CurrentOwnerID)
|
|
}
|
|
if len(ownerIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
var rows []struct {
|
|
AccountID uint `gorm:"column:account_id"`
|
|
GroupName string `gorm:"column:group_name"`
|
|
}
|
|
if err := s.db.WithContext(ctx).Table("tb_business_user_group_member AS m").
|
|
Select("m.account_id, g.name AS group_name").
|
|
Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL").
|
|
Where("m.account_id IN ? AND m.deleted_at IS NULL", ownerIDs).
|
|
Order("m.account_id ASC, g.sort_order ASC, g.id ASC").
|
|
Scan(&rows).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败")
|
|
}
|
|
for _, row := range rows {
|
|
if existing := result[row.AccountID]; existing != "" {
|
|
result[row.AccountID] = existing + "、" + row.GroupName
|
|
continue
|
|
}
|
|
result[row.AccountID] = row.GroupName
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// packageTrafficAlertExportRow 是预警导出的一行原始投影。
|
|
type packageTrafficAlertExportRow struct {
|
|
AssetType string `gorm:"column:asset_type"`
|
|
AssetIdentifier string `gorm:"column:asset_identifier_snapshot"`
|
|
CounterpartIdentifier string `gorm:"column:counterpart_identifier_snapshot"`
|
|
CardIdentifier string `gorm:"column:card_identifier_snapshot"`
|
|
DeviceType string `gorm:"column:device_type_snapshot"`
|
|
DeviceModel string `gorm:"column:device_model_snapshot"`
|
|
PackageName string `gorm:"column:package_name_snapshot"`
|
|
UsedMB int64 `gorm:"column:used_mb_snapshot"`
|
|
LimitMB int64 `gorm:"column:limit_mb_snapshot"`
|
|
UsagePercent float64 `gorm:"column:usage_percent_snapshot"`
|
|
ThresholdPercent float64 `gorm:"column:threshold_percent_snapshot"`
|
|
ExpiresAt *time.Time `gorm:"column:expires_at_snapshot"`
|
|
TriggeredAt time.Time `gorm:"column:triggered_at"`
|
|
ShopIDSnapshot uint `gorm:"column:shop_id_snapshot"`
|
|
ShopNameSnapshot string `gorm:"column:shop_name_snapshot"`
|
|
BusinessOwnerID *uint `gorm:"column:business_owner_account_id_snapshot"`
|
|
BusinessOwnerName string `gorm:"column:business_owner_name_snapshot"`
|
|
NotificationEventID string `gorm:"column:notification_event_id"`
|
|
CurrentShopID *uint `gorm:"column:current_shop_id"`
|
|
CurrentShopName string `gorm:"column:current_shop_name"`
|
|
CurrentOwnerID *uint `gorm:"column:current_owner_id"`
|
|
CurrentOwnerName string `gorm:"column:current_owner_name"`
|
|
OutboxStatus *int `gorm:"column:outbox_status"`
|
|
NotificationID *uint `gorm:"column:notification_id"`
|
|
}
|
|
|
|
// resolveAlertNotifyStatus 推导导出行的通知投递结果,与列表、详情同口径。
|
|
func resolveAlertNotifyStatus(item packageTrafficAlertExportRow) int {
|
|
return constants.ResolvePackageTrafficAlertNotifyStatus(
|
|
item.NotificationEventID != "", item.OutboxStatus, item.NotificationID != nil)
|
|
}
|
|
|
|
// ensurePackageTrafficAlertExportAllowed 只允许超级管理员与平台账号使用本场景。
|
|
// 通用导出入口不做场景级角色校验,因此这一层门禁是防止代理越权读取预警数据的必要防线。
|
|
func ensurePackageTrafficAlertExportAllowed(params ExportParams) error {
|
|
if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform {
|
|
return nil
|
|
}
|
|
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
|
}
|
|
|
|
// alertFilterFloat 解析导出筛选中的小数百分比。
|
|
// 阈值筛选只在预警导出使用,为避免改动既有共享筛选助手文件,这里就地解析。
|
|
func alertFilterFloat(filters map[string]any, key string) (float64, bool) {
|
|
value, ok := filters[key]
|
|
if !ok || value == nil {
|
|
return 0, false
|
|
}
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
return typed, true
|
|
case float32:
|
|
return float64(typed), true
|
|
case int:
|
|
return float64(typed), true
|
|
case int64:
|
|
return float64(typed), true
|
|
case string:
|
|
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return parsed, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// currentOwnerGroupName 返回执行时当前业务员的用户组名称,无有效业务员时为空。
|
|
func currentOwnerGroupName(groupNames map[uint]string, ownerID *uint) string {
|
|
if ownerID == nil {
|
|
return ""
|
|
}
|
|
return groupNames[*ownerID]
|
|
}
|
|
|
|
// assetTypeName 返回资产类型的中文名称。
|
|
func assetTypeName(assetType string) string {
|
|
if assetType == constants.AssetTypeDevice {
|
|
return "设备"
|
|
}
|
|
return "物联网卡"
|
|
}
|
|
|
|
// formatPercentValue 输出保留两位小数的百分比。
|
|
func formatPercentValue(value float64) string {
|
|
return strconv.FormatFloat(value, 'f', 2, 64)
|
|
}
|
|
|
|
// formatRemainingDays 按上海自然日推算剩余天数;无到期时间时输出空字符串。
|
|
func formatRemainingDays(expiresAt *time.Time, now time.Time) string {
|
|
if expiresAt == nil {
|
|
return ""
|
|
}
|
|
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
|
localExpires := expiresAt.In(location)
|
|
localNow := now.In(location)
|
|
expiresDate := time.Date(localExpires.Year(), localExpires.Month(), localExpires.Day(), 0, 0, 0, 0, location)
|
|
nowDate := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location)
|
|
days := int(expiresDate.Sub(nowDate).Hours() / 24)
|
|
return strconv.Itoa(days)
|
|
}
|