Files
邹进 25c081fe60
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8h37m36s
fix: 第1批 M2、M3、M16、M23 导入与展示类缺陷
- M2 批量换绑表头改为“平台登录账号”,只认手机号,细分格式、不存在、多匹配、非平台、已禁用失败原因
- M3 后台订单列表与详情返回 C 端在线购买的交易流水号、支付渠道、商户名称,详情新增全部支付单 payments
- M16 预警规则不再返回 real_data_mb,预警列表/详情新增 threshold_mb,导出末列追加“阈值流量(MB)”
- M23 设备导入 IMEI 必填,空 IMEI 行记为失败
- OpenSpec Change fix-august-iteration-follow-up-gaps:13/18,导入任务、导出任务的 Worker 端到端验证与前端/运营通知待部署后完成
2026-10-11 23:49:29 +09:00

546 lines
22 KiB
Go
Raw Permalink 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 提供套餐真流量预警规则与达量预警的只读投影。
// 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").
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,
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"`
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,
ThresholdMB: domainpackagetrafficalert.ThresholdMB(alert.LimitMBSnapshot, 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
}