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