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:
546
internal/query/packagetrafficalert/query.go
Normal file
546
internal/query/packagetrafficalert/query.go
Normal file
@@ -0,0 +1,546 @@
|
||||
// Package packagetrafficalert 提供套餐真流量预警规则与达量预警的只读投影。
|
||||
// Query 只做筛选、分页与 DTO 投影,不修改任何状态;越权与不存在统一按资源不可见处理。
|
||||
package packagetrafficalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
domainpackagetrafficalert "github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// shanghaiLocation 是剩余天数推算使用的上海时区。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// Query 查询套餐真流量预警规则与达量预警。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
// now 可在验证时替换,默认使用系统时间。
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewQuery 创建套餐真流量预警查询。
|
||||
func NewQuery(db *gorm.DB) *Query {
|
||||
return &Query{db: db, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// ListRules 分页查询套餐真流量预警规则。
|
||||
// 规则列表返回套餐名称与商品当前真流量额度,供维护页核对配置合法性;商品额度不作为预警分母。
|
||||
func (q *Query) ListRules(ctx context.Context, request dto.ListPackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleListResponse, error) {
|
||||
if q == nil || q.db == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置")
|
||||
}
|
||||
if err := requirePlatformOperator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
var rows []ruleRow
|
||||
query := q.db.WithContext(ctx).Table("tb_package_traffic_alert_rule AS r").
|
||||
Joins("LEFT JOIN tb_package AS p ON p.id = r.package_id AND p.deleted_at IS NULL")
|
||||
if request.PackageID != nil {
|
||||
query = query.Where("r.package_id = ?", *request.PackageID)
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
query = query.Where("r.enabled = ?", boolToStatus(*request.Enabled))
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则总数失败")
|
||||
}
|
||||
if err := query.Select("r.id, r.package_id, r.threshold_percent, r.enabled, r.remark, r.updated_at, " +
|
||||
"COALESCE(p.package_name, '') AS package_name, COALESCE(p.real_data_mb, 0) AS real_data_mb").
|
||||
Order("r.updated_at DESC, r.id DESC").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则列表失败")
|
||||
}
|
||||
items := make([]dto.PackageTrafficAlertRuleItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, dto.PackageTrafficAlertRuleItem{
|
||||
ID: row.ID,
|
||||
PackageID: row.PackageID,
|
||||
PackageName: row.PackageName,
|
||||
RealDataMB: row.RealDataMB,
|
||||
ThresholdPercent: row.ThresholdPercent,
|
||||
Enabled: row.Enabled == constants.StatusEnabled,
|
||||
EnabledName: enabledStatusName(row.Enabled),
|
||||
Remark: row.Remark,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return &dto.PackageTrafficAlertRuleListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil
|
||||
}
|
||||
|
||||
type ruleRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
PackageID uint `gorm:"column:package_id"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb"`
|
||||
ThresholdPercent float64 `gorm:"column:threshold_percent"`
|
||||
Enabled int `gorm:"column:enabled"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// ListAlerts 分页查询套餐真流量达量预警。
|
||||
// 先应用既有资产数据范围(当前对超级管理员与平台无实际过滤,保留为冻结语义与未来放开的前置),
|
||||
// 再按套餐、店铺、业务员、资产/卡标识、阈值、触发时间与通知投递结果筛选。
|
||||
func (q *Query) ListAlerts(ctx context.Context, request dto.ListPackageTrafficAlertRequest) (*dto.PackageTrafficAlertListResponse, error) {
|
||||
if q == nil || q.db == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置")
|
||||
}
|
||||
if err := requirePlatformOperator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
query := q.applyAlertFilters(ctx, q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}), request)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警总数失败")
|
||||
}
|
||||
var alerts []*model.PackageTrafficAlert
|
||||
if err := query.Order("triggered_at DESC, id DESC").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Find(&alerts).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警列表失败")
|
||||
}
|
||||
items, err := q.projectAlerts(ctx, alerts, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.PackageTrafficAlertListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil
|
||||
}
|
||||
|
||||
// GetAlert 查询单条达量预警详情。
|
||||
// 越权与不存在统一返回资源不可见错误,不形成可枚举差异。
|
||||
func (q *Query) GetAlert(ctx context.Context, alertID uint) (*dto.PackageTrafficAlertDetailResponse, error) {
|
||||
if q == nil || q.db == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置")
|
||||
}
|
||||
if alertID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := requirePlatformOperator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var alert model.PackageTrafficAlert
|
||||
err := q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}).
|
||||
Where("id = ?", alertID).
|
||||
Scopes(func(scopeQuery *gorm.DB) *gorm.DB {
|
||||
return applyAssetDataScope(ctx, scopeQuery, "tb_package_traffic_alert.shop_id_snapshot")
|
||||
}).
|
||||
First(&alert).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, invisibleAlertError()
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警详情失败")
|
||||
}
|
||||
items, projectErr := q.projectAlerts(ctx, []*model.PackageTrafficAlert{&alert}, true)
|
||||
if projectErr != nil {
|
||||
return nil, projectErr
|
||||
}
|
||||
detail := &dto.PackageTrafficAlertDetailResponse{
|
||||
PackageTrafficAlertItem: items[0],
|
||||
NotificationEventID: alert.NotificationEventID,
|
||||
}
|
||||
detail.NotificationSummary = notificationSummary(items[0].NotificationStatus)
|
||||
detail.ShopChangedSinceTrigger, detail.OwnerChangedSinceTrigger = q.detectOwnershipDrift(ctx, &alert)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// applyAlertFilters 应用数据范围与筛选条件。
|
||||
func (q *Query) applyAlertFilters(ctx context.Context, query *gorm.DB, request dto.ListPackageTrafficAlertRequest) *gorm.DB {
|
||||
query = applyAssetDataScope(ctx, query, "tb_package_traffic_alert.shop_id_snapshot")
|
||||
if request.PackageID != nil {
|
||||
query = query.Where("tb_package_traffic_alert.package_id = ?", *request.PackageID)
|
||||
}
|
||||
if request.ShopID != nil {
|
||||
query = query.Where("tb_package_traffic_alert.shop_id_snapshot = ?", *request.ShopID)
|
||||
}
|
||||
if request.BusinessOwnerAccountID != nil {
|
||||
query = query.Where("tb_package_traffic_alert.business_owner_account_id_snapshot = ?", *request.BusinessOwnerAccountID)
|
||||
}
|
||||
if request.AssetType != "" {
|
||||
query = query.Where("tb_package_traffic_alert.asset_type = ?", request.AssetType)
|
||||
}
|
||||
if keyword := strings.TrimSpace(request.AssetIdentifier); keyword != "" {
|
||||
pattern := "%" + keyword + "%"
|
||||
query = query.Where("(tb_package_traffic_alert.asset_identifier_snapshot ILIKE ? "+
|
||||
"OR tb_package_traffic_alert.card_identifier_snapshot ILIKE ? "+
|
||||
"OR tb_package_traffic_alert.counterpart_identifier_snapshot ILIKE ?)", pattern, pattern, pattern)
|
||||
}
|
||||
if request.ThresholdPercent != nil {
|
||||
query = query.Where("tb_package_traffic_alert.threshold_percent_snapshot = ?",
|
||||
domainpackagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent))
|
||||
}
|
||||
if request.StartTime != nil {
|
||||
query = query.Where("tb_package_traffic_alert.triggered_at >= ?", request.StartTime.UTC())
|
||||
}
|
||||
if request.EndTime != nil {
|
||||
query = query.Where("tb_package_traffic_alert.triggered_at <= ?", request.EndTime.UTC())
|
||||
}
|
||||
return applyNotificationStatusFilter(query, request.NotificationStatus)
|
||||
}
|
||||
|
||||
// projectAlerts 批量投影预警列表项。
|
||||
// 冻结快照直接读预警行;通知投递结果由可靠通知事件、Outbox 状态与站内通知事实交叉推导;
|
||||
// 用户组按冻结的业务员账号实时推导(账号不变则稳定),不写入店铺表。
|
||||
func (q *Query) projectAlerts(ctx context.Context, alerts []*model.PackageTrafficAlert, withReadState bool) ([]dto.PackageTrafficAlertItem, error) {
|
||||
items := make([]dto.PackageTrafficAlertItem, 0, len(alerts))
|
||||
if len(alerts) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
eventIDs := make([]string, 0, len(alerts))
|
||||
ownerIDs := make([]uint, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
if alert.NotificationEventID != "" {
|
||||
eventIDs = append(eventIDs, alert.NotificationEventID)
|
||||
}
|
||||
if alert.BusinessOwnerAccountIDSnapshot != nil && *alert.BusinessOwnerAccountIDSnapshot > 0 {
|
||||
ownerIDs = append(ownerIDs, *alert.BusinessOwnerAccountIDSnapshot)
|
||||
}
|
||||
}
|
||||
outboxStates, err := q.loadOutboxStates(ctx, eventIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notificationStates, err := q.loadNotificationStates(ctx, eventIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupNames, err := q.loadBusinessUserGroupNames(ctx, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := q.now()
|
||||
for _, alert := range alerts {
|
||||
item := dto.PackageTrafficAlertItem{
|
||||
ID: alert.ID,
|
||||
PackageUsageID: alert.PackageUsageID,
|
||||
PackageID: alert.PackageID,
|
||||
PackageName: alert.PackageNameSnapshot,
|
||||
AssetType: alert.AssetType,
|
||||
AssetID: alert.AssetID,
|
||||
AssetIdentifier: alert.AssetIdentifierSnapshot,
|
||||
CardIdentifier: alert.CardIdentifierSnapshot,
|
||||
CounterpartIdentifier: alert.CounterpartIdentifierSnapshot,
|
||||
DeviceType: alert.DeviceTypeSnapshot,
|
||||
DeviceModel: alert.DeviceModelSnapshot,
|
||||
UsedMB: alert.UsedMBSnapshot,
|
||||
LimitMB: alert.LimitMBSnapshot,
|
||||
UsagePercent: alert.UsagePercentSnapshot,
|
||||
ThresholdPercent: alert.ThresholdPercentSnapshot,
|
||||
ExpiresAt: alert.ExpiresAtSnapshot,
|
||||
TriggeredAt: alert.TriggeredAt,
|
||||
ShopName: alert.ShopNameSnapshot,
|
||||
BusinessOwnerName: alert.BusinessOwnerNameSnapshot,
|
||||
BusinessUserGroupNames: []string{},
|
||||
}
|
||||
if alert.ShopIDSnapshot > 0 {
|
||||
shopID := alert.ShopIDSnapshot
|
||||
item.ShopID = &shopID
|
||||
}
|
||||
if alert.BusinessOwnerAccountIDSnapshot != nil && *alert.BusinessOwnerAccountIDSnapshot > 0 {
|
||||
ownerID := *alert.BusinessOwnerAccountIDSnapshot
|
||||
item.BusinessOwnerAccountID = &ownerID
|
||||
if names, ok := groupNames[ownerID]; ok {
|
||||
item.BusinessUserGroupNames = names
|
||||
}
|
||||
}
|
||||
if alert.ExpiresAtSnapshot != nil {
|
||||
days := daysUntil(*alert.ExpiresAtSnapshot, now)
|
||||
item.DaysRemaining = &days
|
||||
}
|
||||
status, deliveredAt, readAt, expiresAt := resolveNotificationState(alert, outboxStates, notificationStates)
|
||||
item.NotificationStatus = status
|
||||
item.NotificationStatusName = constants.GetPackageTrafficAlertNotifyStatusName(status)
|
||||
item.NotificationDeliveredAt = deliveredAt
|
||||
if withReadState {
|
||||
item.NotificationReadAt = readAt
|
||||
item.NotificationExpiresAt = expiresAt
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type outboxState struct {
|
||||
Status int `gorm:"column:status"`
|
||||
DeliveredAt *time.Time `gorm:"column:delivered_at"`
|
||||
}
|
||||
|
||||
// loadOutboxStates 按事件ID批量读取 Outbox 状态。
|
||||
func (q *Query) loadOutboxStates(ctx context.Context, eventIDs []string) (map[string]outboxState, error) {
|
||||
result := make(map[string]outboxState, len(eventIDs))
|
||||
if len(eventIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
EventID string `gorm:"column:event_id"`
|
||||
Status int `gorm:"column:status"`
|
||||
DeliveredAt *time.Time `gorm:"column:delivered_at"`
|
||||
}
|
||||
if err := q.db.WithContext(ctx).Table("tb_outbox_event").
|
||||
Select("event_id, status, delivered_at").
|
||||
Where("event_id IN ?", eventIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询预警通知事件状态失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.EventID] = outboxState{Status: row.Status, DeliveredAt: row.DeliveredAt}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type notificationState struct {
|
||||
ReadAt *time.Time `gorm:"column:read_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
||||
}
|
||||
|
||||
// loadNotificationStates 按事件ID批量读取站内通知事实。
|
||||
func (q *Query) loadNotificationStates(ctx context.Context, eventIDs []string) (map[string]notificationState, error) {
|
||||
result := make(map[string]notificationState, len(eventIDs))
|
||||
if len(eventIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
EventID string `gorm:"column:event_id"`
|
||||
ReadAt *time.Time `gorm:"column:read_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at"`
|
||||
}
|
||||
if err := q.db.WithContext(ctx).Table("tb_notification").
|
||||
Select("event_id, read_at, expires_at").
|
||||
Where("event_id IN ?", eventIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询预警站内通知失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.EventID] = notificationState{ReadAt: row.ReadAt, ExpiresAt: row.ExpiresAt}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadBusinessUserGroupNames 按业务员账号批量推导当前所属业务用户组名称。
|
||||
func (q *Query) loadBusinessUserGroupNames(ctx context.Context, accountIDs []uint) (map[uint][]string, error) {
|
||||
result := make(map[uint][]string, len(accountIDs))
|
||||
if len(accountIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
AccountID uint `gorm:"column:account_id"`
|
||||
GroupName string `gorm:"column:group_name"`
|
||||
}
|
||||
if err := q.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", accountIDs).
|
||||
Order("g.sort_order ASC, g.id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.AccountID] = append(result[row.AccountID], row.GroupName)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// detectOwnershipDrift 判断资产当前归属店铺与店铺当前业务员是否已偏离触发快照。
|
||||
func (q *Query) detectOwnershipDrift(ctx context.Context, alert *model.PackageTrafficAlert) (bool, bool) {
|
||||
currentShopID, err := q.currentAssetShopID(ctx, alert.AssetType, alert.AssetID)
|
||||
if err != nil || currentShopID == 0 {
|
||||
return false, false
|
||||
}
|
||||
shopChanged := currentShopID != alert.ShopIDSnapshot
|
||||
var shopOwner struct {
|
||||
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
|
||||
}
|
||||
if err := q.db.WithContext(ctx).Table("tb_shop").
|
||||
Select("business_owner_account_id").
|
||||
Where("id = ?", currentShopID).
|
||||
Scan(&shopOwner).Error; err != nil {
|
||||
return shopChanged, false
|
||||
}
|
||||
ownerID := shopOwner.BusinessOwnerAccountID
|
||||
ownerChanged := !sameOptionalID(ownerID, alert.BusinessOwnerAccountIDSnapshot)
|
||||
return shopChanged, ownerChanged
|
||||
}
|
||||
|
||||
// currentAssetShopID 查询资产当前所属店铺ID。
|
||||
func (q *Query) currentAssetShopID(ctx context.Context, assetType string, assetID uint) (uint, error) {
|
||||
if assetID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
table := "tb_iot_card"
|
||||
if assetType == constants.AssetTypeDevice {
|
||||
table = "tb_device"
|
||||
}
|
||||
var shopID *uint
|
||||
if err := q.db.WithContext(ctx).Table(table).
|
||||
Select("shop_id").
|
||||
Where("id = ? AND deleted_at IS NULL", assetID).
|
||||
Scan(&shopID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if shopID == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return *shopID, nil
|
||||
}
|
||||
|
||||
// applyNotificationStatusFilter 按通知投递结果筛选。
|
||||
// 结果由通知事件、Outbox 状态与站内通知事实推导,因此筛选必须与投影同口径。
|
||||
func applyNotificationStatusFilter(query *gorm.DB, status *int) *gorm.DB {
|
||||
if status == nil {
|
||||
return query
|
||||
}
|
||||
const hasNotification = "EXISTS (SELECT 1 FROM tb_notification AS n WHERE n.event_id = tb_package_traffic_alert.notification_event_id)"
|
||||
const outboxStatusExpr = `(SELECT oe.status FROM tb_outbox_event AS oe WHERE oe.event_id = tb_package_traffic_alert.notification_event_id)`
|
||||
switch *status {
|
||||
case constants.PackageTrafficAlertNotifyNoBusinessOwner:
|
||||
return query.Where("tb_package_traffic_alert.notification_event_id = ''")
|
||||
case constants.PackageTrafficAlertNotifyNotified:
|
||||
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").Where(hasNotification)
|
||||
case constants.PackageTrafficAlertNotifyPending:
|
||||
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").
|
||||
Where("NOT "+hasNotification).
|
||||
Where(outboxStatusExpr+" IN ?", []int{constants.OutboxStatusPending, constants.OutboxStatusDelivering})
|
||||
case constants.PackageTrafficAlertNotifyFailed:
|
||||
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").
|
||||
Where("NOT "+hasNotification).
|
||||
Where(outboxStatusExpr+" = ?", constants.OutboxStatusFailed)
|
||||
case constants.PackageTrafficAlertNotifyRecipientGone:
|
||||
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").
|
||||
Where("NOT "+hasNotification).
|
||||
Where(outboxStatusExpr+" = ?", constants.OutboxStatusDelivered)
|
||||
default:
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
// resolveNotificationState 推导单条预警的通知投递结果,口径由 constants 统一定义。
|
||||
func resolveNotificationState(alert *model.PackageTrafficAlert, outboxStates map[string]outboxState,
|
||||
notificationStates map[string]notificationState) (int, *time.Time, *time.Time, *time.Time) {
|
||||
if alert.NotificationEventID == "" {
|
||||
return constants.ResolvePackageTrafficAlertNotifyStatus(false, nil, false), nil, nil, nil
|
||||
}
|
||||
state, hasNotification := notificationStates[alert.NotificationEventID]
|
||||
var outboxStatus *int
|
||||
var deliveredAt *time.Time
|
||||
if outbox, ok := outboxStates[alert.NotificationEventID]; ok {
|
||||
status := outbox.Status
|
||||
outboxStatus = &status
|
||||
deliveredAt = outbox.DeliveredAt
|
||||
}
|
||||
status := constants.ResolvePackageTrafficAlertNotifyStatus(true, outboxStatus, hasNotification)
|
||||
if status == constants.PackageTrafficAlertNotifyNotified {
|
||||
return status, deliveredAt, state.ReadAt, state.ExpiresAt
|
||||
}
|
||||
if status == constants.PackageTrafficAlertNotifyRecipientGone {
|
||||
return status, deliveredAt, nil, nil
|
||||
}
|
||||
return status, nil, nil, nil
|
||||
}
|
||||
|
||||
// notificationSummary 返回详情页的通知补充说明。
|
||||
func notificationSummary(status int) string {
|
||||
switch status {
|
||||
case constants.PackageTrafficAlertNotifyNoBusinessOwner:
|
||||
return "触发时店铺无有效业务员,只保存预警且不补发通知"
|
||||
case constants.PackageTrafficAlertNotifyRecipientGone:
|
||||
return "通知事件已投递,但接收人账号在投递时已失效,未生成站内通知"
|
||||
case constants.PackageTrafficAlertNotifyFailed:
|
||||
return "通知事件投递失败,已进入既有可靠投递恢复"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// requirePlatformOperator 要求调用者为超级管理员或平台账号;其他账号统一按资源不可见处理。
|
||||
func requirePlatformOperator(ctx context.Context) error {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return invisibleAlertError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyAssetDataScope 应用既有资产数据范围。
|
||||
// 范围为空表示不受限(超级管理员与平台账号当前无实际过滤),保留为冻结语义与未来放开的前置;
|
||||
// 列名必须显式给出,因为预警行冻结的是 shop_id_snapshot,而不是通用的 shop_id 列。
|
||||
func applyAssetDataScope(ctx context.Context, query *gorm.DB, column string) *gorm.DB {
|
||||
shopIDs := middleware.GetSubordinateShopIDs(ctx)
|
||||
if len(shopIDs) == 0 {
|
||||
return query
|
||||
}
|
||||
return query.Where(column+" IN ?", shopIDs)
|
||||
}
|
||||
|
||||
// invisibleAlertError 返回与既有资源不可见一致的统一错误,避免越权与不存在形成可枚举差异。
|
||||
func invisibleAlertError() error {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
|
||||
// daysUntil 按上海自然日计算剩余天数,负数表示已过期。
|
||||
func daysUntil(expiresAt time.Time, now time.Time) int {
|
||||
days := dateInShanghai(expiresAt).Sub(dateInShanghai(now))
|
||||
return int(days.Hours() / 24)
|
||||
}
|
||||
|
||||
// dateInShanghai 归一化到上海时区的自然日零点。
|
||||
func dateInShanghai(value time.Time) time.Time {
|
||||
local := value.In(shanghaiLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
|
||||
// normalizePage 归一化分页参数并执行默认值与上限。
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = constants.DefaultPage
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// boolToStatus 把对外启停布尔映射为既有整型状态。
|
||||
func boolToStatus(enabled bool) int {
|
||||
if enabled {
|
||||
return constants.StatusEnabled
|
||||
}
|
||||
return constants.StatusDisabled
|
||||
}
|
||||
|
||||
// enabledStatusName 返回启停状态的中文名称。
|
||||
func enabledStatusName(status int) string {
|
||||
if status == constants.StatusEnabled {
|
||||
return "启用"
|
||||
}
|
||||
return "停用"
|
||||
}
|
||||
|
||||
// sameOptionalID 判断两个可空账号ID是否指向同一非空账号。
|
||||
func sameOptionalID(left, right *uint) bool {
|
||||
leftID, rightID := uint(0), uint(0)
|
||||
if left != nil {
|
||||
leftID = *left
|
||||
}
|
||||
if right != nil {
|
||||
rightID = *right
|
||||
}
|
||||
return leftID == rightID
|
||||
}
|
||||
Reference in New Issue
Block a user