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:
2026-09-16 17:05:55 +08:00
parent ef4d3696d4
commit d5bcda94fe
46 changed files with 3679 additions and 100 deletions

View File

@@ -0,0 +1,299 @@
// Package packagetrafficalert 收口套餐真流量预警的规则维护事务脚本与每日扫描用例。
//
// 规则维护是简单写Handler → Application 事务脚本 → Persistence事实与审计同事务。
// 每日扫描是复杂写Application 编排 → Domain 判定 → Port/Infrastructure 原子写入预警事实、
// 可靠通知事件与审计;判定只使用套餐使用记录的真流量快照,不读取虚流量、展示量、卡级累计或通道累计。
package packagetrafficalert
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// RuleService 套餐真流量预警规则维护事务脚本。
type RuleService struct {
db *gorm.DB
store *postgres.PackageTrafficAlertStore
auditWriter *audit.Writer
}
// NewRuleService 创建套餐真流量预警规则事务脚本。
func NewRuleService(db *gorm.DB, store *postgres.PackageTrafficAlertStore, auditWriters ...*audit.Writer) *RuleService {
service := &RuleService{db: db, store: store}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Create 为套餐商品创建唯一预警规则。
// 创建一律校验套餐存在且商品真流量额度大于零:商品 real_data_mb 只用于配置合法性,
// 不作为扫描分母(分母取使用记录的真总量快照)。
func (s *RuleService) Create(ctx context.Context, request *dto.CreatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
operatorID, err := requireOperator(ctx)
if err != nil {
return nil, err
}
if request == nil || request.PackageID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "套餐商品ID不能为空")
}
if !packagetrafficalert.IsValidThresholdPercent(request.ThresholdPercent) {
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100允许两位小数")
}
enabled := constants.StatusEnabled
if request.Enabled != nil && !*request.Enabled {
enabled = constants.StatusDisabled
}
rule := &model.PackageTrafficAlertRule{
PackageID: request.PackageID,
ThresholdPercent: packagetrafficalert.NormalizeThresholdPercent(request.ThresholdPercent),
Enabled: enabled,
Remark: request.Remark,
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
}
var response *dto.PackageTrafficAlertRuleItem
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
pkg, loadErr := s.loadPackage(ctx, tx, request.PackageID)
if loadErr != nil {
return loadErr
}
if pkg.RealDataMB <= 0 {
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
}
store := s.store.WithTx(tx)
exists, existsErr := store.ExistsRuleByPackageID(ctx, request.PackageID)
if existsErr != nil {
return errors.Wrap(errors.CodeDatabaseError, existsErr, "校验套餐预警规则失败")
}
if exists {
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
}
if createErr := store.CreateRule(ctx, rule); createErr != nil {
if isDuplicateKey(createErr) {
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
}
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐真流量预警规则失败")
}
item := toRuleItem(rule, pkg.PackageName, pkg.RealDataMB)
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleCreated,
"创建套餐真流量预警规则", rule, pkg.PackageName, nil, ruleAuditSnapshot(rule, pkg.PackageName)); auditErr != nil {
return auditErr
}
response = item
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
// Update 修改阈值、启停与备注。
// 修改不回填既有预警,也不改写已冻结的预警快照;结果状态为启用时同样校验商品真流量额度大于零。
func (s *RuleService) Update(ctx context.Context, ruleID uint, request *dto.UpdatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
operatorID, err := requireOperator(ctx)
if err != nil {
return nil, err
}
if request == nil || ruleID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "预警规则ID不能为空")
}
if request.ThresholdPercent != nil && !packagetrafficalert.IsValidThresholdPercent(*request.ThresholdPercent) {
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100允许两位小数")
}
var response *dto.PackageTrafficAlertRuleItem
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := s.store.WithTx(tx)
rule, lockErr := store.LockRuleByID(ctx, ruleID)
if lockErr != nil {
return ruleLookupError(lockErr)
}
before := ruleAuditSnapshot(rule, "")
if request.ThresholdPercent != nil {
rule.ThresholdPercent = packagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent)
}
if request.Enabled != nil {
rule.Enabled = constants.StatusEnabled
if !*request.Enabled {
rule.Enabled = constants.StatusDisabled
}
}
if request.Remark != nil {
rule.Remark = *request.Remark
}
pkg, pkgErr := s.loadPackage(ctx, tx, rule.PackageID)
if pkgErr != nil {
return pkgErr
}
if rule.Enabled == constants.StatusEnabled && pkg.RealDataMB <= 0 {
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
}
packageName := pkg.PackageName
if updateErr := store.UpdateRule(ctx, rule, operatorID); updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐真流量预警规则失败")
}
after := ruleAuditSnapshot(rule, packageName)
if before["enabled"] != after["enabled"] {
action, summary := constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则"
if rule.Enabled != constants.StatusEnabled {
action, summary = constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则"
}
if auditErr := s.appendRuleAudit(ctx, tx, action, summary, rule, packageName,
map[string]any{"enabled": before["enabled"]}, map[string]any{"enabled": after["enabled"]}); auditErr != nil {
return auditErr
}
}
if before["threshold_percent"] != after["threshold_percent"] || before["remark"] != after["remark"] {
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleUpdated,
"更新套餐真流量预警规则", rule, packageName, before, after); auditErr != nil {
return auditErr
}
}
response = toRuleItem(rule, packageName, pkg.RealDataMB)
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
// loadPackage 查询套餐商品;不存在时按参数错误返回。
func (s *RuleService) loadPackage(ctx context.Context, tx *gorm.DB, packageID uint) (*model.Package, error) {
var pkg model.Package
if err := tx.WithContext(ctx).Where("id = ?", packageID).First(&pkg).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "套餐商品不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐商品失败")
}
return &pkg, nil
}
// appendRuleAudit 在业务事务内追加预警规则事件。
func (s *RuleService) appendRuleAudit(ctx context.Context, tx *gorm.DB, action, summary string,
rule *model.PackageTrafficAlertRule, packageName string, before, after map[string]any) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "套餐真流量预警规则统一审计接缝未配置")
}
var resourceID *string
if rule.ID != 0 {
value := strconv.FormatUint(uint64(rule.ID), 10)
resourceID = &value
}
displayName := packageName
if displayName == "" {
displayName = "套餐 " + strconv.FormatUint(uint64(rule.PackageID), 10)
}
// 使用 AppendAndGet预警规则属于关键配置「要求成功必达」的审计失败必须回滚事务ENG-TX-001
if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10)},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourcePackageTrafficAlertRule, ID: resourceID,
Key: strconv.FormatUint(uint64(rule.PackageID), 10), DisplayName: displayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageTrafficAlertRuleTarget,
IdentitySnapshot: ruleAuditIdentity(rule, packageName), BeforeData: before, AfterData: after,
}},
}); err != nil {
return err
}
return nil
}
// requireOperator 要求调用方已通过后台鉴权,否则拒绝写入。
func requireOperator(ctx context.Context) (uint, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized)
}
return operatorID, nil
}
// ruleLookupError 把规则不存在映射为统一资源不可见错误。
func ruleLookupError(err error) error {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则失败")
}
// isDuplicateKey 判断数据库错误是否为唯一键冲突。
func isDuplicateKey(err error) bool {
if err == nil {
return false
}
text := err.Error()
return strings.Contains(text, "23505") || strings.Contains(text, "duplicate key") || strings.Contains(text, "SQLSTATE 23505")
}
// ruleAuditSnapshot 返回预警规则可审计的可变字段快照。
func ruleAuditSnapshot(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
if rule == nil {
return nil
}
snapshot := map[string]any{
"package_id": rule.PackageID,
"threshold_percent": rule.ThresholdPercent,
"enabled": rule.Enabled,
"remark": rule.Remark,
}
if packageName != "" {
snapshot["package_name"] = packageName
}
return snapshot
}
// ruleAuditIdentity 返回预警规则审计身份快照,字段落在注册表白名单内。
func ruleAuditIdentity(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
if rule == nil {
return nil
}
identity := map[string]any{
"id": rule.ID, "package_id": rule.PackageID, "threshold_percent": rule.ThresholdPercent,
"enabled": rule.Enabled, "remark": rule.Remark,
}
if packageName != "" {
identity["package_name"] = packageName
}
return identity
}
// toRuleItem 把规则投影为对外响应项。
func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string, realDataMB int64) *dto.PackageTrafficAlertRuleItem {
if rule == nil {
return nil
}
return &dto.PackageTrafficAlertRuleItem{
ID: rule.ID,
PackageID: rule.PackageID,
PackageName: packageName,
RealDataMB: realDataMB,
ThresholdPercent: rule.ThresholdPercent,
Enabled: rule.Enabled == constants.StatusEnabled,
EnabledName: enabledName(rule.Enabled),
Remark: rule.Remark,
UpdatedAt: rule.UpdatedAt,
}
}
// enabledName 返回启停状态的中文名称。
func enabledName(enabled int) string {
if enabled == constants.StatusEnabled {
return "启用"
}
return "停用"
}

View File

@@ -0,0 +1,302 @@
package packagetrafficalert
import (
"context"
"fmt"
"sort"
"strconv"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AssetKey 是扫描的资产聚合键:卡按 iot_card_id、设备按 device_id二者互斥非零。
type AssetKey struct {
AssetType string
AssetID uint
}
// AssetAggregate 是同一资产全部当前有效套餐使用记录的真流量汇总。
// UsedMB 汇总真已用量LimitMB 汇总真总量快照;虚流量、展示量、卡级累计与通道累计一律不参与。
type AssetAggregate struct {
Key AssetKey
UsedMB int64
LimitMB int64
}
// MainUsage 是资产的主套餐使用记录(阈值来源与预警锚点)。
type MainUsage struct {
PackageUsageID uint
PackageID uint
PackageName string
ExpiresAt *time.Time
}
// EnabledRule 是主套餐对应的当前启用预警规则。
type EnabledRule struct {
ID uint
PackageID uint
ThresholdPercent float64
}
// AssetFacts 是触发时必须冻结的资产与归属展示事实。
// 资产标识、卡标识、对端标识、设备类型与型号取自触发时的卡与设备绑定;
// 归属只包含触发时店铺与「仅业务员」解析出的有效平台业务员。
type AssetFacts struct {
AssetIdentifier string
CardIdentifier string
CounterpartIdentifier string
DeviceType string
DeviceModel string
ShopID uint
ShopName string
BusinessOwnerID *uint
BusinessOwnerName string
}
// ScanReader 读取扫描所需的只读事实。
type ScanReader interface {
// LoadAssetAggregates 按资产汇总当前有效套餐的真已用量与真总量快照。
LoadAssetAggregates(ctx context.Context) ([]AssetAggregate, error)
// LoadMainUsages 批量读取每个资产的主套餐使用记录master_usage_id 为空,按优先级/生效时间/编号取第一条)。
LoadMainUsages(ctx context.Context, keys []AssetKey) (map[AssetKey]MainUsage, error)
// LoadEnabledRules 批量读取套餐商品当前启用的预警规则。
LoadEnabledRules(ctx context.Context, packageIDs []uint) (map[uint]EnabledRule, error)
// LoadAssetFacts 批量读取资产展示事实与触发时归属。
LoadAssetFacts(ctx context.Context, keys []AssetKey) (map[AssetKey]AssetFacts, error)
}
// AlertCandidate 是一次命中要原子落库的完整事实。
type AlertCandidate struct {
Alert model.PackageTrafficAlert
// Notification 为空表示触发时店铺无有效业务员或到期时间不可推算,只保存预警不写通知事件。
Notification *NotificationRequest
}
// NotificationRequest 是一次可靠通知事件的最小输入。
// 接收人是触发时冻结的业务员账号,投递期不再重新解析店铺业务员,避免向未来业务员补发。
type NotificationRequest struct {
RecipientAccountID uint
ShopID uint
TemplateData map[string]string
ExpiresAt time.Time
}
// AlertWriter 在同一事务内写入预警事实、可靠通知事件与审计。
type AlertWriter interface {
// SaveAlert 幂等创建预警;返回 false 表示唯一键冲突(视为已处理,不写事件与审计)。
SaveAlert(ctx context.Context, candidate AlertCandidate) (bool, error)
}
// ScanService 执行每日套餐真流量达量扫描。
type ScanService struct {
reader ScanReader
writer AlertWriter
logger *zap.Logger
// now 可在验证时替换,默认使用系统时间。
now func() time.Time
}
// NewScanService 创建套餐真流量达量扫描用例。
func NewScanService(reader ScanReader, writer AlertWriter, logger *zap.Logger) *ScanService {
return &ScanService{reader: reader, writer: writer, logger: logger, now: func() time.Time { return time.Now().UTC() }}
}
// ScanResult 汇总一次扫描的可观察结果。
type ScanResult struct {
Assets int
Hits int
Created int
Duplicates int
Skipped int
}
// Run 执行一次可重跑扫描:按资产汇总真流量,按主套餐规则阈值判定,命中即原子落库。
func (s *ScanService) Run(ctx context.Context) error {
if s == nil || s.reader == nil || s.writer == nil {
return errors.New(errors.CodeInternalError, "套餐真流量达量扫描用例未配置")
}
aggregates, err := s.reader.LoadAssetAggregates(ctx)
if err != nil {
return err
}
result := &ScanResult{Assets: len(aggregates)}
if len(aggregates) == 0 {
s.logScan(result)
return nil
}
keys := make([]AssetKey, 0, len(aggregates))
for _, aggregate := range aggregates {
keys = append(keys, aggregate.Key)
}
mainUsages, err := s.reader.LoadMainUsages(ctx, keys)
if err != nil {
return err
}
rules, err := s.loadRulesForUsages(ctx, mainUsages)
if err != nil {
return err
}
facts, err := s.reader.LoadAssetFacts(ctx, keys)
if err != nil {
return err
}
triggeredAt := s.now()
for _, aggregate := range aggregates {
main, hasMain := mainUsages[aggregate.Key]
if !hasMain {
// 全是加油包、没有主套餐的资产没有阈值来源,直接跳过。
result.Skipped++
continue
}
rule, hasRule := rules[main.PackageID]
if !hasRule {
result.Skipped++
continue
}
if aggregate.LimitMB <= 0 {
// 汇总分母不是正数的资产不可判定,跳过而不是写入不可用的预警。
result.Skipped++
continue
}
thresholdBasisPoints := packagetrafficalert.ThresholdBasisPoints(rule.ThresholdPercent)
hit, ratioBasisPoints := packagetrafficalert.Decide(aggregate.UsedMB, aggregate.LimitMB, thresholdBasisPoints)
if !hit {
result.Skipped++
continue
}
result.Hits++
candidate := s.buildCandidate(aggregate, main, rule, ratioBasisPoints, facts[aggregate.Key], triggeredAt)
created, saveErr := s.writer.SaveAlert(ctx, candidate)
if saveErr != nil {
s.logger.Error("套餐真流量达量预警写入失败",
zap.String("asset_type", aggregate.Key.AssetType),
zap.Uint("asset_id", aggregate.Key.AssetID),
zap.Error(saveErr))
return saveErr
}
if created {
result.Created++
} else {
result.Duplicates++
}
}
s.logScan(result)
return nil
}
// loadRulesForUsages 批量读取主套餐对应的启用规则。
func (s *ScanService) loadRulesForUsages(ctx context.Context, usages map[AssetKey]MainUsage) (map[uint]EnabledRule, error) {
seen := make(map[uint]struct{}, len(usages))
packageIDs := make([]uint, 0, len(usages))
for _, usage := range usages {
if usage.PackageID == 0 {
continue
}
if _, ok := seen[usage.PackageID]; ok {
continue
}
seen[usage.PackageID] = struct{}{}
packageIDs = append(packageIDs, usage.PackageID)
}
if len(packageIDs) == 0 {
return map[uint]EnabledRule{}, nil
}
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
return s.reader.LoadEnabledRules(ctx, packageIDs)
}
// buildCandidate 组装唯一的资产级预警事实与可选通知请求。
func (s *ScanService) buildCandidate(aggregate AssetAggregate, main MainUsage, rule EnabledRule,
ratioBasisPoints int64, facts AssetFacts, triggeredAt time.Time) AlertCandidate {
packageName := main.PackageName
if packageName == "" {
packageName = "套餐#" + strconv.FormatUint(uint64(main.PackageID), 10)
}
assetIdentifier := facts.AssetIdentifier
if assetIdentifier == "" {
// 回落值同步写入快照,保证快照、列表与通知正文一致。
assetIdentifier = "资产#" + strconv.FormatUint(uint64(aggregate.Key.AssetID), 10)
}
alert := model.PackageTrafficAlert{
PackageUsageID: main.PackageUsageID,
PackageID: main.PackageID,
RuleID: rule.ID,
AssetType: aggregate.Key.AssetType,
AssetID: aggregate.Key.AssetID,
AssetIdentifierSnapshot: assetIdentifier,
CardIdentifierSnapshot: facts.CardIdentifier,
CounterpartIdentifierSnapshot: facts.CounterpartIdentifier,
DeviceTypeSnapshot: facts.DeviceType,
DeviceModelSnapshot: facts.DeviceModel,
PackageNameSnapshot: packageName,
UsedMBSnapshot: aggregate.UsedMB,
LimitMBSnapshot: aggregate.LimitMB,
UsagePercentSnapshot: packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints),
ThresholdPercentSnapshot: packagetrafficalert.NormalizeThresholdPercent(rule.ThresholdPercent),
ExpiresAtSnapshot: main.ExpiresAt,
TriggeredAt: triggeredAt,
ShopIDSnapshot: facts.ShopID,
ShopNameSnapshot: facts.ShopName,
BusinessOwnerAccountIDSnapshot: facts.BusinessOwnerID,
BusinessOwnerNameSnapshot: facts.BusinessOwnerName,
}
candidate := AlertCandidate{Alert: alert}
if facts.BusinessOwnerID == nil || *facts.BusinessOwnerID == 0 {
// 无有效业务员:只保存预警,不写通知事件,也不在未来补发。
return candidate
}
candidate.Notification = &NotificationRequest{
RecipientAccountID: *facts.BusinessOwnerID,
ShopID: facts.ShopID,
ExpiresAt: notificationExpiresAt(main.ExpiresAt, triggeredAt),
TemplateData: map[string]string{
"asset_identifier": assetIdentifier,
"package_name": packageName,
"usage_percent": formatPercent(packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints)),
"threshold_percent": formatPercent(alert.ThresholdPercentSnapshot),
},
}
return candidate
}
// notificationExpiresAt 计算站内通知的展示期结束时间。
// 优先使用主套餐到期时间快照;快照为空时沿用既有默认展示期常量兜底,
// 预警行的到期时间快照保持为空,不伪造业务到期时间。
func notificationExpiresAt(snapshot *time.Time, triggeredAt time.Time) time.Time {
if snapshot != nil {
return snapshot.UTC()
}
return triggeredAt.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays).UTC()
}
// formatPercent 把百分比格式化为最多两位小数、去掉无意义尾零的展示文本。
func formatPercent(value float64) string {
return strconv.FormatFloat(packagetrafficalert.NormalizeThresholdPercent(value), 'f', -1, 64)
}
// logScan 输出一次扫描的结构化结果,供维护者按日志核对。
func (s *ScanService) logScan(result *ScanResult) {
if s.logger == nil {
return
}
s.logger.Info("套餐真流量达量扫描完成",
zap.Int("assets", result.Assets),
zap.Int("hits", result.Hits),
zap.Int("created", result.Created),
zap.Int("duplicates", result.Duplicates),
zap.Int("skipped", result.Skipped))
}
// EventIDFor 返回预警通知事件的稳定ID内嵌主套餐使用记录与阈值快照万分比
func EventIDFor(packageUsageID uint, thresholdPercent float64) string {
return fmt.Sprintf("%s:%d:%d", constants.PackageTrafficAlertEventIDPrefix, packageUsageID,
packagetrafficalert.ThresholdBasisPoints(thresholdPercent))
}

View File

@@ -35,6 +35,7 @@ import (
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
@@ -185,6 +186,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit)
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
@@ -282,6 +284,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),

View File

@@ -13,6 +13,7 @@ import (
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
@@ -115,6 +116,7 @@ type services struct {
Package *packageSvc.Service
PackageDailyRecord *packageSvc.DailyRecordService
PackageCustomerView *packageSvc.CustomerViewService
PackageTrafficAlertRule *packagetrafficalertapp.RuleService
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
ShopSeriesGrant *shopSeriesGrantSvc.Service
@@ -494,6 +496,7 @@ func initServices(s *stores, deps *Dependencies) *services {
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),

View File

@@ -77,6 +77,8 @@ type stores struct {
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
// 流量系统
CardDailyUsage *postgres.CardDailyUsageStore
// 套餐真流量预警规则与达量预警事实
PackageTrafficAlert *postgres.PackageTrafficAlertStore
// 资产标识符注册表
AssetIdentifier *postgres.AssetIdentifierStore
}
@@ -148,5 +150,6 @@ func initStores(deps *Dependencies) *stores {
BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB),
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
PackageTrafficAlert: postgres.NewPackageTrafficAlertStore(deps.DB),
}
}

View File

@@ -81,6 +81,7 @@ type Handlers struct {
BusinessUserGroup *admin.BusinessUserGroupHandler
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
PackageTrafficAlert *admin.PackageTrafficAlertHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler

View File

@@ -0,0 +1,52 @@
// Package packagetrafficalert 提供套餐真流量达量预警的领域判定规则。
//
// 判定口径固定为「真流量」:分子取套餐使用记录的真已用量,分母取套餐使用记录的真总量快照,
// 二者按资产汇总后再与主套餐规则阈值比较;全部使用整数万分比比较,不使用浮点判定,
// 避免边界(例如恰好等于阈值)因二进制浮点误差产生错误结论。
package packagetrafficalert
import "math"
// ratioScale 是万分比刻度1% = 1000.01% = 1。
const ratioScale = 10000
// MinThresholdPercent 与 MaxThresholdPercent 是可配置阈值百分比的闭区间端点。
const (
MinThresholdPercent = 1.0
MaxThresholdPercent = 100.0
)
// ThresholdBasisPoints 把百分比阈值换算为整数万分比0.01% = 1
// 数据库以 NUMERIC(5,2) 保存两位小数,读取后先四舍五入到两位再换算,保证 1.25% 恒等于 125。
func ThresholdBasisPoints(percent float64) int64 {
return int64(math.Round(NormalizeThresholdPercent(percent) * 100))
}
// NormalizeThresholdPercent 把百分比四舍五入到两位小数,与 NUMERIC(5,2) 的存储精度一致。
func NormalizeThresholdPercent(percent float64) float64 {
return math.Round(percent*100) / 100
}
// IsValidThresholdPercent 判断百分比是否落在 1%100% 闭区间内。
func IsValidThresholdPercent(percent float64) bool {
normalized := NormalizeThresholdPercent(percent)
return normalized >= MinThresholdPercent && normalized <= MaxThresholdPercent
}
// Decide 按资产的汇总真流量判定是否达到阈值,并返回向下取整的汇总比例万分比。
//
// usedMB 为该资产全部当前有效套餐的真已用量之和limitMB 为同集合的真总量快照之和。
// 分母不大于零属于不可判定资产,调用方必须先跳过;此处返回未命中以避免除零。
func Decide(usedMB, limitMB, thresholdBasisPoints int64) (bool, int64) {
if limitMB <= 0 || thresholdBasisPoints <= 0 {
return false, 0
}
ratioBasisPoints := usedMB * ratioScale / limitMB
hit := usedMB*ratioScale >= thresholdBasisPoints*limitMB
return hit, ratioBasisPoints
}
// PercentFromBasisPoints 把万分比换算为保留两位小数的百分比展示值。
func PercentFromBasisPoints(basisPoints int64) float64 {
return float64(basisPoints) / 100
}

View File

@@ -0,0 +1,350 @@
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)
}

View File

@@ -37,6 +37,7 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewRefundDataSource(db),
NewExchangeDataSource(db),
NewCommissionRecordDataSource(db),
NewPackageTrafficAlertDataSource(db),
)
}
@@ -73,7 +74,8 @@ func IsSupportedScene(scene string) bool {
constants.ExportTaskSceneAgentRecharge,
constants.ExportTaskSceneRefund,
constants.ExportTaskSceneExchange,
constants.ExportTaskSceneCommissionRecord:
constants.ExportTaskSceneCommissionRecord,
constants.ExportTaskScenePackageTrafficAlert:
return true
default:
return false

View File

@@ -0,0 +1,183 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PackageTrafficAlertHandler 套餐真流量预警 Handler。
// 规则维护与预警读取/导出只对超级管理员与平台账号开放,路由组已有角色门禁,
// Handler 仍不做任何跳过业务校验的分支。
type PackageTrafficAlertHandler struct {
ruleService *packagetrafficalertapp.RuleService
query *packagetrafficalertquery.Query
exportService *exportTaskService.Service
validator *validator.Validate
}
// NewPackageTrafficAlertHandler 创建套餐真流量预警 Handler。
func NewPackageTrafficAlertHandler(ruleService *packagetrafficalertapp.RuleService,
query *packagetrafficalertquery.Query, exportService *exportTaskService.Service,
validator *validator.Validate) *PackageTrafficAlertHandler {
return &PackageTrafficAlertHandler{ruleService: ruleService, query: query, exportService: exportService, validator: validator}
}
// CreateRule 创建套餐真流量预警规则。
// POST /api/admin/package-traffic-alert-rules
func (h *PackageTrafficAlertHandler) CreateRule(c *fiber.Ctx) error {
var req dto.CreatePackageTrafficAlertRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("创建套餐真流量预警规则参数不合法", &req, err))
}
result, err := h.ruleService.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateRule 修改套餐真流量预警规则的阈值、启停与备注。
// PUT /api/admin/package-traffic-alert-rules/:id
func (h *PackageTrafficAlertHandler) UpdateRule(c *fiber.Ctx) error {
ruleID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || ruleID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的预警规则 ID")
}
var req dto.UpdatePackageTrafficAlertRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("修改套餐真流量预警规则参数不合法", &req, err))
}
result, err := h.ruleService.Update(c.UserContext(), uint(ruleID), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListRules 查询套餐真流量预警规则列表。
// GET /api/admin/package-traffic-alert-rules
func (h *PackageTrafficAlertHandler) ListRules(c *fiber.Ctx) error {
var req dto.ListPackageTrafficAlertRuleRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐真流量预警规则参数不合法", &req, err))
}
result, err := h.query.ListRules(c.UserContext(), req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// ListAlerts 查询套餐真流量达量预警列表。
// GET /api/admin/package-traffic-alerts
func (h *PackageTrafficAlertHandler) ListAlerts(c *fiber.Ctx) error {
var req dto.ListPackageTrafficAlertRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐真流量达量预警参数不合法", &req, err))
}
if req.NotificationStatus != nil && !constants.IsValidPackageTrafficAlertNotifyStatus(*req.NotificationStatus) {
return errors.New(errors.CodeInvalidParam, "通知投递结果必须为 1/2/3/4/5 之一")
}
result, err := h.query.ListAlerts(c.UserContext(), req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// GetAlert 查询套餐真流量达量预警详情。
// GET /api/admin/package-traffic-alerts/:id
func (h *PackageTrafficAlertHandler) GetAlert(c *fiber.Ctx) error {
alertID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || alertID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的预警 ID")
}
result, err := h.query.GetAlert(c.UserContext(), uint(alertID))
if err != nil {
return err
}
return response.Success(c, result)
}
// ExportAlerts 创建套餐真流量达量预警异步导出任务。
// POST /api/admin/package-traffic-alerts/export
// 只暴露受控入口;导出任务创建时冻结操作者、筛选、时间范围与可见资产范围。
func (h *PackageTrafficAlertHandler) ExportAlerts(c *fiber.Ctx) error {
var req dto.ExportPackageTrafficAlertRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("导出套餐真流量达量预警参数不合法", &req, err))
}
if req.NotificationStatus != nil && !constants.IsValidPackageTrafficAlertNotifyStatus(*req.NotificationStatus) {
return errors.New(errors.CodeInvalidParam, "通知投递结果必须为 1/2/3/4/5 之一")
}
createRequest := dto.CreateExportTaskRequest{
Scene: constants.ExportTaskScenePackageTrafficAlert,
Format: req.Format,
Query: map[string]interface{}{"filters": exportFilters(req)},
}
result, err := h.exportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// exportFilters 把导出请求转换为导出任务的筛选快照。
// 时间范围在创建时冻结为 RFC3339 字符串,执行期按触发时间解析为闭区间。
func exportFilters(req dto.ExportPackageTrafficAlertRequest) map[string]interface{} {
filters := make(map[string]interface{})
if req.PackageID != nil {
filters["package_id"] = *req.PackageID
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if req.BusinessOwnerAccountID != nil {
filters["business_owner_account_id"] = *req.BusinessOwnerAccountID
}
if req.AssetType != "" {
filters["asset_type"] = req.AssetType
}
if req.AssetIdentifier != "" {
filters["asset_identifier"] = req.AssetIdentifier
}
if req.ThresholdPercent != nil {
filters["threshold_percent"] = *req.ThresholdPercent
}
if req.StartTime != nil {
filters["start_time"] = req.StartTime.UTC().Format("2006-01-02T15:04:05Z07:00")
}
if req.EndTime != nil {
filters["end_time"] = req.EndTime.UTC().Format("2006-01-02T15:04:05Z07:00")
}
if req.NotificationStatus != nil {
filters["notification_status"] = *req.NotificationStatus
}
return filters
}

View File

@@ -221,6 +221,11 @@ func NewRegistry() *Registry {
businessUserGroupDisabled := businessUserGroupAction(constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup)
businessUserGroupDeleted := businessUserGroupAction(constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", constants.AuditRiskHigh, constants.AuditResourceBusinessUserGroup)
businessUserGroupMembersUpdated := businessUserGroupAction(constants.AuditActionBusinessUserGroupMembersUpdated, "批量维护业务用户组成员", constants.AuditRiskNormal, constants.AuditResourceAccount)
packageTrafficAlertRuleCreated := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleCreated, "创建套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
packageTrafficAlertRuleUpdated := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleUpdated, "更新套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
packageTrafficAlertRuleEnabled := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
packageTrafficAlertRuleDisabled := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
packageTrafficAlertTriggered := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertTriggered, "创建套餐真流量达量预警", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlert, constants.AuditActorSystemTask, constants.AuditSourceWorker)
shopBusinessOwnerBatchUpdated := batchRootAction(constants.AuditActionShopBusinessOwnerBatchUpdated, "批量交接店铺负责人", constants.AuditResourceShopBusinessOwnerBatch)
shopBusinessOwnerImported := taskAction(constants.AuditActionShopBusinessOwnerImported, "导入变更店铺负责人", constants.AuditResourceShop, constants.AuditActorSystemTask, constants.AuditSourceWorker)
shopBusinessOwnerImportTaskCreated := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCreated, "创建店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
@@ -594,6 +599,11 @@ func NewRegistry() *Registry {
constants.AuditActionBusinessUserGroupDisabled: businessUserGroupDisabled,
constants.AuditActionBusinessUserGroupDeleted: businessUserGroupDeleted,
constants.AuditActionBusinessUserGroupMembersUpdated: businessUserGroupMembersUpdated,
constants.AuditActionPackageTrafficAlertRuleCreated: packageTrafficAlertRuleCreated,
constants.AuditActionPackageTrafficAlertRuleUpdated: packageTrafficAlertRuleUpdated,
constants.AuditActionPackageTrafficAlertRuleEnabled: packageTrafficAlertRuleEnabled,
constants.AuditActionPackageTrafficAlertRuleDisabled: packageTrafficAlertRuleDisabled,
constants.AuditActionPackageTrafficAlertTriggered: packageTrafficAlertTriggered,
constants.AuditActionShopBusinessOwnerBatchUpdated: shopBusinessOwnerBatchUpdated,
constants.AuditActionShopBusinessOwnerImported: shopBusinessOwnerImported,
constants.AuditActionShopBusinessOwnerImportTaskCreated: shopBusinessOwnerImportTaskCreated,
@@ -841,6 +851,14 @@ func NewRegistry() *Registry {
Type: constants.AuditResourcePhoneAssetUnbindImportTask, Name: "手机号资产解绑导入任务",
IdentityFields: []string{"id", "task_no", "file_name"},
},
constants.AuditResourcePackageTrafficAlertRule: {
Type: constants.AuditResourcePackageTrafficAlertRule, Name: "套餐真流量预警规则",
IdentityFields: []string{"id", "package_id", "threshold_percent", "enabled", "remark"},
},
constants.AuditResourcePackageTrafficAlert: {
Type: constants.AuditResourcePackageTrafficAlert, Name: "套餐真流量达量预警",
IdentityFields: []string{"id", "package_usage_id", "package_id", "asset_type", "asset_id", "threshold_percent_snapshot", "used_mb_snapshot", "limit_mb_snapshot", "usage_percent_snapshot", "shop_id", "business_owner_account_id"},
},
constants.AuditResourceNotification: {
Type: constants.AuditResourceNotification, Name: "站内通知",
IdentityFields: []string{"id", "event_id", "recipient_kind", "recipient_id", "category", "type", "severity", "ref_type", "ref_id", "ref_key"},
@@ -1403,6 +1421,17 @@ func businessUserGroupAction(code, name, risk, primaryResource string) ActionDef
}
}
// packageTrafficAlertAction 定义套餐真流量预警规则维护与达量触发动作。
// 规则维护由后台 API 写入;达量触发由扫描任务在业务事务内写入,两者通过 actor/source 区分。
func packageTrafficAlertAction(code, name, risk, primaryResource, actor, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: risk,
PrimaryResource: primaryResource, AllowedActor: actor, Source: source, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
// batchRootAction 定义同步后台批次根动作;子事件自带店铺或资源作用域。
func batchRootAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{

View File

@@ -142,6 +142,21 @@ func NewRegistry() *Registry {
constants.NotificationRefTypeAsset: {},
},
},
// 套餐真流量达量预警:类别沿用 expiry接收人只允许触发时冻结的平台业务员账号
// 资源引用只指向预警详情,正文不含任何 URL 或前端路由。
constants.NotificationTypePackageTrafficAlert: {
Type: constants.NotificationTypePackageTrafficAlert, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐真流量达量预警",
BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 真流量已用 {{.usage_percent}}%,达到预警阈值 {{.threshold_percent}}%。",
TemplateFields: map[string]struct{}{
"asset_identifier": {}, "package_name": {}, "usage_percent": {}, "threshold_percent": {},
},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypePackageTrafficAlert: {},
},
},
}}
}

View File

@@ -0,0 +1,411 @@
// 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
}

View File

@@ -0,0 +1,185 @@
package packagetrafficalert
import (
"context"
"strconv"
"gorm.io/gorm"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
app "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AlertWriter 在同一 GORM 事务内写入预警事实、可靠通知事件与成功审计。
//
// 依据 ENG-OUTBOX-001业务事实与可靠异步副作用必须同事务写 Outbox
// 依据 ENG-TX-001成功必达的审计与关键状态事实同事务且事务内不持有不可回滚的外部 I/O。
// 事务粒度为一个命中资产:单资产失败不阻塞其余资产,锁持有时间可控。
type AlertWriter struct {
db *gorm.DB
store *postgres.PackageTrafficAlertStore
outbox *outbox.Repository
auditWriter *audit.Writer
}
// NewAlertWriter 创建套餐真流量预警写入 Adapter。
func NewAlertWriter(db *gorm.DB, store *postgres.PackageTrafficAlertStore, repository *outbox.Repository, auditWriter *audit.Writer) *AlertWriter {
return &AlertWriter{db: db, store: store, outbox: repository, auditWriter: auditWriter}
}
// SaveAlert 幂等创建预警,并在同一事务内写入通知事件与审计。
// 唯一键冲突(同一使用记录 + 同一阈值快照)视为已处理:不写事件、不写审计,直接提交。
func (w *AlertWriter) SaveAlert(ctx context.Context, candidate app.AlertCandidate) (bool, error) {
if w == nil || w.db == nil || w.store == nil {
return false, errors.New(errors.CodeInternalError, "套餐真流量达量预警写入 Adapter 未配置")
}
if w.auditWriter == nil {
return false, errors.New(errors.CodeInvalidStatus, "套餐真流量达量预警统一审计接缝未配置")
}
alert := candidate.Alert
created := false
err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := w.store.WithTx(tx)
inserted, insertErr := store.CreateAlertIdempotent(ctx, &alert)
if insertErr != nil {
return errors.Wrap(errors.CodeDatabaseError, insertErr, "创建套餐真流量达量预警失败")
}
if !inserted {
return nil
}
created = true
if candidate.Notification != nil {
eventID, appendErr := w.appendNotificationEvent(ctx, tx, alert, *candidate.Notification)
if appendErr != nil {
return appendErr
}
if updateErr := tx.WithContext(ctx).Model(&model.PackageTrafficAlert{}).
Where("id = ?", alert.ID).Update("notification_event_id", eventID).Error; updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "回填预警通知事件ID失败")
}
alert.NotificationEventID = eventID
}
return w.appendTriggerAudit(ctx, tx, alert)
})
if err != nil {
return false, err
}
return created, nil
}
// appendNotificationEvent 幂等追加动态通知事件。
// 接收人是触发时冻结的业务员账号TargetKind=account投递期不再重新解析店铺业务员
// 因此业务员变更不会把通知送给不属于它的新账号,也不会在触发时无业务员的情况下补发。
func (w *AlertWriter) appendNotificationEvent(ctx context.Context, tx *gorm.DB, alert model.PackageTrafficAlert, request app.NotificationRequest) (string, error) {
if w.outbox == nil {
return "", errors.New(errors.CodeInternalError, "套餐真流量达量预警 Outbox 仓储未配置")
}
eventID := app.EventIDFor(alert.PackageUsageID, alert.ThresholdPercentSnapshot)
alertID := strconv.FormatUint(uint64(alert.ID), 10)
expiresAt := request.ExpiresAt
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID,
EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: constants.PackageTrafficAlertScanAggregateType,
AggregateID: alertID,
ResourceType: constants.NotificationRefTypePackageTrafficAlert,
ResourceID: alertID,
BusinessKey: eventID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindAccount,
TargetID: request.RecipientAccountID,
NotificationType: constants.NotificationTypePackageTrafficAlert,
TemplateData: request.TemplateData,
RefType: constants.NotificationRefTypePackageTrafficAlert,
RefID: alertID,
RefKey: alert.AssetIdentifierSnapshot,
ExpiresAt: &expiresAt,
},
})
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "写入套餐真流量达量预警通知事件失败")
}
return eventID, nil
}
// appendTriggerAudit 在业务事务内追加达量预警审计事实。
// 审计动作的 actor/source 固定为系统任务与 Worker与注册表声明一致。
func (w *AlertWriter) appendTriggerAudit(ctx context.Context, tx *gorm.DB, alert model.PackageTrafficAlert) error {
alertID := strconv.FormatUint(uint64(alert.ID), 10)
usageID := strconv.FormatUint(uint64(alert.PackageUsageID), 10)
summary := "创建套餐真流量达量预警"
if alert.NotificationEventID == "" {
summary = "创建套餐真流量达量预警(触发时无有效业务员,未生成通知)"
}
// 使用 AppendAndGet达量预警审计属于「要求成功必达」的事实失败必须回滚整个事务ENG-TX-001
if _, err := w.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourcePackageTrafficAlert, alert.ID, "trigger"),
ActionCode: constants.AuditActionPackageTrafficAlertTriggered, Summary: summary,
// Actor.ID 必须非空且稳定:审计写入要求操作者标识,缺省会静默失败。
Actor: audit.ActorInput{
Kind: constants.AuditActorSystemTask,
ID: constants.TaskTypePackageTrafficAlertScan,
Name: "套餐真流量达量扫描",
},
Source: constants.AuditSourceWorker, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourcePackageTrafficAlert, ID: &alertID, Key: usageID,
DisplayName: alert.PackageNameSnapshot,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageTrafficAlertTarget,
IdentitySnapshot: alertAuditIdentity(alert), AfterData: alertAuditSnapshot(alert),
}},
}); err != nil {
return err
}
return nil
}
// alertAuditIdentity 返回预警审计身份快照,字段落在注册表白名单内。
func alertAuditIdentity(alert model.PackageTrafficAlert) map[string]any {
identity := map[string]any{
"id": alert.ID, "package_usage_id": alert.PackageUsageID, "package_id": alert.PackageID,
"asset_type": alert.AssetType, "asset_id": alert.AssetID,
"threshold_percent_snapshot": alert.ThresholdPercentSnapshot,
"used_mb_snapshot": alert.UsedMBSnapshot, "limit_mb_snapshot": alert.LimitMBSnapshot,
"usage_percent_snapshot": alert.UsagePercentSnapshot,
"shop_id": alert.ShopIDSnapshot,
}
if alert.BusinessOwnerAccountIDSnapshot != nil {
identity["business_owner_account_id"] = *alert.BusinessOwnerAccountIDSnapshot
} else {
identity["business_owner_account_id"] = nil
}
return identity
}
// alertAuditSnapshot 返回预警审计的 after 快照,用于忠实还原触发时的冻结事实。
func alertAuditSnapshot(alert model.PackageTrafficAlert) map[string]any {
snapshot := alertAuditIdentity(alert)
snapshot["rule_id"] = alert.RuleID
snapshot["asset_identifier"] = alert.AssetIdentifierSnapshot
snapshot["card_identifier"] = alert.CardIdentifierSnapshot
snapshot["counterpart_identifier"] = alert.CounterpartIdentifierSnapshot
snapshot["package_name"] = alert.PackageNameSnapshot
snapshot["shop_name"] = alert.ShopNameSnapshot
snapshot["notification_event_id"] = alert.NotificationEventID
if alert.BusinessOwnerAccountIDSnapshot != nil {
snapshot["business_owner_account_id"] = *alert.BusinessOwnerAccountIDSnapshot
} else {
snapshot["business_owner_account_id"] = nil
}
if alert.ExpiresAtSnapshot != nil {
snapshot["expires_at"] = alert.ExpiresAtSnapshot.UTC()
} else {
snapshot["expires_at"] = nil
}
snapshot["triggered_at"] = alert.TriggeredAt.UTC()
return snapshot
}

View File

@@ -4,7 +4,7 @@ import "time"
// CreateExportTaskRequest 创建导出任务请求。
type CreateExportTaskRequest struct {
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"`
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"`
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象可选)"`
}
@@ -22,7 +22,7 @@ type CreateExportTaskResponse struct {
type ListExportTaskRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"`
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
@@ -33,7 +33,7 @@ type ExportTaskItem struct {
ID uint `json:"id" description:"任务ID"`
TaskID uint `json:"task_id" description:"任务ID"`
TaskNo string `json:"task_no" description:"任务编号"`
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"`
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"`
Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"`
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StatusName string `json:"status_name" description:"任务状态名称(中文)"`

View File

@@ -11,7 +11,7 @@ type NotificationUnreadCountResponse struct {
// NotificationListRequest 是后台通知基础分页参数。
type NotificationListRequest struct {
Category string `json:"category" query:"category" validate:"omitempty,oneof=approval expiry sync system" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" query:"type" validate:"omitempty,oneof=system.notice package.expiring agent.recharge.completed refund.completed exchange.shipping.created agent.main_wallet.low_balance h5.popup.risk_exchange h5.popup.operation" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗)"`
Type string `json:"type" query:"type" validate:"omitempty,oneof=system.notice package.expiring agent.recharge.completed refund.completed exchange.shipping.created agent.main_wallet.low_balance h5.popup.risk_exchange h5.popup.operation package.traffic.alert" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation,package.traffic.alert" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗, package.traffic.alert:套餐真流量达量预警)"`
Severity string `json:"severity" query:"severity" validate:"omitempty,oneof=info warning error critical" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
IsRead *bool `json:"is_read" query:"is_read" description:"已读状态;不传时查询全部"`
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1最大 10000"`
@@ -22,11 +22,11 @@ type NotificationListRequest struct {
type NotificationItem struct {
ID uint `json:"id" description:"通知ID"`
Category string `json:"category" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗)"`
Type string `json:"type" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation,package.traffic.alert" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗, package.traffic.alert:套餐真流量达量预警)"`
Severity string `json:"severity" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
Title string `json:"title" description:"纯文本标题"`
Body string `json:"body" description:"纯文本正文"`
RefType string `json:"ref_type" description:"受控资源类型可能为空。可选值及含义system_config:系统配置, integration_log:外部集成日志, package:套餐, asset:C端资产, refund:退款, agent_recharge:代理充值, wecom_approval:企微审批, iot_card:物联网卡, device:设备, expiring_asset:临期资产列表, shop_fund:店铺资金概况, card_sync:卡同步记录。后台点击通知应调用目标解析接口,不得直接拼接路由"`
RefType string `json:"ref_type" description:"受控资源类型可能为空。可选值及含义system_config:系统配置, integration_log:外部集成日志, package:套餐, asset:C端资产, refund:退款, agent_recharge:代理充值, wecom_approval:企微审批, iot_card:物联网卡, device:设备, expiring_asset:临期资产列表, shop_fund:店铺资金概况, card_sync:卡同步记录, package_traffic_alert:套餐真流量达量预警。后台点击通知应调用目标解析接口,不得直接拼接路由"`
RefID string `json:"ref_id" description:"受控资源数字ID的十进制字符串可能为空。refund、agent_recharge、wecom_approval、iot_card、device、expiring_asset、shop_fund、asset 等类型使用仅用于资源定位不是前端URL"`
RefKey string `json:"ref_key" description:"受控资源稳定Key或展示快照可能为空。system_config 为配置Keyintegration_log/card_sync 为集成标识asset 为资产标识快照仅用于定位或展示不是前端URL"`
IsRead bool `json:"is_read" description:"是否已读"`
@@ -65,7 +65,7 @@ type NotificationReadResponse struct {
// NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。
type NotificationTargetResponse struct {
TargetType string `json:"target_type" description:"前端白名单目标类型空表示不支持跳转。可选值refund_detail、agent_recharge_detail、wecom_approval_detail、iot_card_detail、device_detail、expiring_asset_list、shop_fund_summary、integration_log、system_config"`
TargetType string `json:"target_type" description:"前端白名单目标类型空表示不支持跳转。可选值refund_detail、agent_recharge_detail、wecom_approval_detail、iot_card_detail、device_detail、expiring_asset_list、shop_fund_summary、integration_log、system_config、package_traffic_alert_detail"`
TargetID *uint `json:"target_id,omitempty" description:"ID型目标的业务主键前端按 target_type 映射受控页面不得自行拼接任意URL"`
TargetKey string `json:"target_key,omitempty" description:"Key型目标的稳定定位值仅用于 integration_log 或 system_config 等白名单目标"`
Available bool `json:"available" description:"当前账号是否仍可访问目标false 时只展示通知正文,不执行跳转"`

View File

@@ -0,0 +1,136 @@
package dto
import "time"
// CreatePackageTrafficAlertRuleRequest 创建套餐真流量预警规则请求。
// 每个套餐商品至多一条当前规则;创建即校验商品真流量额度大于零。
type CreatePackageTrafficAlertRuleRequest struct {
PackageID uint `json:"package_id" validate:"required,min=1" required:"true" minimum:"1" description:"套餐商品ID必须存在且真流量额度大于零"`
ThresholdPercent float64 `json:"threshold_percent" validate:"required,gt=0" required:"true" description:"真流量预警阈值百分比,取值 1 至 100允许两位小数"`
Enabled *bool `json:"enabled" description:"是否启用(默认 true停用后扫描不再创建新预警"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"`
}
// UpdatePackageTrafficAlertRuleRequest 修改套餐真流量预警规则请求。
// 只允许修改阈值、启停与备注;修改不回填既有预警,也不改写已冻结的预警快照。
type UpdatePackageTrafficAlertRuleRequest struct {
ThresholdPercent *float64 `json:"threshold_percent" validate:"omitempty,gt=0" description:"新的真流量预警阈值百分比,取值 1 至 100允许两位小数"`
Enabled *bool `json:"enabled" description:"是否启用;停用后扫描不再创建新预警,既有预警保留"`
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"`
}
// UpdatePackageTrafficAlertRuleParams 修改预警规则的路径参数与请求体(用于文档生成)。
type UpdatePackageTrafficAlertRuleParams struct {
ID uint `path:"id" description:"预警规则ID" required:"true"`
UpdatePackageTrafficAlertRuleRequest
}
// ListPackageTrafficAlertRuleRequest 预警规则分页查询参数。
type ListPackageTrafficAlertRuleRequest struct {
PackageID *uint `json:"package_id" query:"package_id" validate:"omitempty,gt=0" description:"按套餐商品ID过滤"`
Enabled *bool `json:"enabled" query:"enabled" description:"按启用状态过滤;不传时查询全部"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
}
// PackageTrafficAlertRuleItem 预警规则列表项。
type PackageTrafficAlertRuleItem struct {
ID uint `json:"id" description:"预警规则ID"`
PackageID uint `json:"package_id" description:"套餐商品ID"`
PackageName string `json:"package_name" description:"套餐名称"`
RealDataMB int64 `json:"real_data_mb" description:"套餐商品当前真流量额度(MB),仅用于配置校验展示,不作为预警分母"`
ThresholdPercent float64 `json:"threshold_percent" description:"真流量预警阈值百分比"`
Enabled bool `json:"enabled" description:"是否启用"`
EnabledName string `json:"enabled_name" description:"启用状态名称(中文)"`
Remark string `json:"remark" description:"备注"`
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
}
// PackageTrafficAlertRuleListResponse 预警规则分页响应。
type PackageTrafficAlertRuleListResponse struct {
Items []PackageTrafficAlertRuleItem `json:"items" description:"预警规则列表"`
Total int64 `json:"total" description:"符合条件的规则总数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// ListPackageTrafficAlertRequest 套餐真流量达量预警分页查询参数。
// 时间范围为带时区的 RFC3339 秒级闭区间,按触发时间筛选,任一端可省略。
type ListPackageTrafficAlertRequest struct {
PackageID *uint `json:"package_id" query:"package_id" validate:"omitempty,gt=0" description:"按阈值来源套餐商品ID过滤"`
ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty,gt=0" description:"按触发时所属店铺ID过滤"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" query:"business_owner_account_id" validate:"omitempty,gt=0" description:"按触发时店铺业务员账号ID过滤"`
AssetType string `json:"asset_type" query:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产或卡标识关键词,匹配资产标识、卡标识与对应标识符快照"`
ThresholdPercent *float64 `json:"threshold_percent" query:"threshold_percent" description:"按触发阈值快照精确过滤,允许两位小数"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"触发时间起始RFC3339含该时刻"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"触发时间截止RFC3339含该时刻"`
NotificationStatus *int `json:"notification_status" query:"notification_status" enum:"1,2,3,4,5" description:"通知投递结果过滤 (1:已通知, 2:待投递, 3:投递失败, 4:未通知(接收人已失效), 5:未通知(无有效业务员))"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20最大 100"`
}
// PackageTrafficAlertItem 套餐真流量达量预警列表项。
// 资产、套餐、用量、阈值、到期时间与触发时归属均为触发快照;用户组按快照业务员账号实时推导。
type PackageTrafficAlertItem struct {
ID uint `json:"id" description:"预警ID"`
PackageUsageID uint `json:"package_usage_id" description:"主套餐使用记录ID"`
PackageID uint `json:"package_id" description:"阈值来源套餐商品ID"`
PackageName string `json:"package_name" description:"套餐名称快照"`
AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetID uint `json:"asset_id" description:"资产ID"`
AssetIdentifier string `json:"asset_identifier" description:"资产标识快照(卡为 ICCID设备为虚拟号/IMEI/SN"`
CardIdentifier string `json:"card_identifier" description:"卡标识快照(卡资产为自身 ICCID设备资产为触发时绑定卡 ICCID"`
CounterpartIdentifier string `json:"counterpart_identifier" description:"对应标识符快照(卡→触发时绑定设备标识,设备→触发时绑定卡 ICCID"`
DeviceType string `json:"device_type" description:"设备类型快照"`
DeviceModel string `json:"device_model" description:"设备型号快照"`
UsedMB int64 `json:"used_mb" description:"触发时真已用量汇总快照(MB)"`
LimitMB int64 `json:"limit_mb" description:"触发时真总量快照汇总(MB)"`
UsagePercent float64 `json:"usage_percent" description:"触发时汇总比例快照(%),可能大于 100"`
ThresholdPercent float64 `json:"threshold_percent" description:"触发阈值快照(%)"`
ExpiresAt *time.Time `json:"expires_at" description:"主套餐到期时间快照,无法推算时为 null"`
DaysRemaining *int `json:"days_remaining" description:"按到期时间快照推算的剩余上海自然日天数,负数表示已过期;无到期时间时为 null"`
TriggeredAt time.Time `json:"triggered_at" description:"触发时间"`
ShopID *uint `json:"shop_id" description:"触发时所属店铺ID快照平台库存为 null"`
ShopName string `json:"shop_name" description:"触发时所属店铺名称快照"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" description:"触发时店铺业务员账号ID快照无有效业务员时为 null"`
BusinessOwnerName string `json:"business_owner_name" description:"触发时业务员名称快照"`
BusinessUserGroupNames []string `json:"business_user_group_names" description:"按快照业务员账号实时推导的业务用户组名称,可能为空"`
NotificationStatus int `json:"notification_status" description:"通知投递结果 (1:已通知, 2:待投递, 3:投递失败, 4:未通知(接收人已失效), 5:未通知(无有效业务员))"`
NotificationStatusName string `json:"notification_status_name" description:"通知投递结果名称(中文)"`
NotificationDeliveredAt *time.Time `json:"notification_delivered_at" description:"通知事件投递完成时间,未完成时为 null"`
NotificationReadAt *time.Time `json:"notification_read_at,omitempty" description:"接收人首次已读时间,仅详情返回"`
NotificationExpiresAt *time.Time `json:"notification_expires_at,omitempty" description:"通知展示期结束时间,仅详情返回"`
ShopChangedSinceTrigger bool `json:"shop_changed_since_trigger,omitempty" description:"资产当前归属店铺是否已不同于触发快照,仅详情返回"`
OwnerChangedSinceTrigger bool `json:"owner_changed_since_trigger,omitempty" description:"店铺当前业务员是否已不同于触发快照,仅详情返回"`
}
// PackageTrafficAlertDetailResponse 套餐真流量达量预警详情响应。
type PackageTrafficAlertDetailResponse struct {
PackageTrafficAlertItem
NotificationEventID string `json:"notification_event_id,omitempty" description:"可靠通知事件ID无有效业务员时为空"`
NotificationSummary string `json:"notification_summary,omitempty" description:"通知投递补充说明,例如接收人已失效或未生成通知"`
}
// PackageTrafficAlertListResponse 套餐真流量达量预警分页响应。
type PackageTrafficAlertListResponse struct {
Items []PackageTrafficAlertItem `json:"items" description:"预警列表"`
Total int64 `json:"total" description:"符合条件的预警总数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// ExportPackageTrafficAlertRequest 套餐真流量达量预警导出请求。
// 筛选与列表一致,创建时冻结操作者、筛选、时间范围与可见资产范围。
type ExportPackageTrafficAlertRequest struct {
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" enum:"xlsx,csv" description:"导出格式 (xlsx:Excel, csv:CSV)"`
PackageID *uint `json:"package_id" validate:"omitempty,gt=0" description:"按阈值来源套餐商品ID过滤"`
ShopID *uint `json:"shop_id" validate:"omitempty,gt=0" description:"按触发时所属店铺ID过滤"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" validate:"omitempty,gt=0" description:"按触发时店铺业务员账号ID过滤"`
AssetType string `json:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetIdentifier string `json:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产或卡标识关键词,匹配资产标识、卡标识与对应标识符快照"`
ThresholdPercent *float64 `json:"threshold_percent" description:"按触发阈值快照精确过滤,允许两位小数"`
StartTime *time.Time `json:"start_time" description:"触发时间起始RFC3339含该时刻"`
EndTime *time.Time `json:"end_time" description:"触发时间截止RFC3339含该时刻"`
NotificationStatus *int `json:"notification_status" enum:"1,2,3,4,5" description:"通知投递结果过滤 (1:已通知, 2:待投递, 3:投递失败, 4:未通知(接收人已失效), 5:未通知(无有效业务员))"`
}

View File

@@ -0,0 +1,94 @@
package model
import (
"time"
)
// PackageTrafficAlertRule 套餐真流量预警规则模型。
// 每个套餐商品至多一条当前规则package_id 唯一),只提供创建、修改阈值与启停,不提供删除;
// 规则变更只影响后续扫描,已产生的预警快照不被改写。
// 表内不设软删除列:停用由 enabled 表达,唯一约束因此可用非部分索引,规避部分索引与 OnConflict 的谓词问题。
type PackageTrafficAlertRule struct {
// ID 主键。
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
BaseModel `gorm:"embedded"`
// PackageID 套餐商品ID唯一约束保证每个商品至多一条当前规则。
PackageID uint `gorm:"column:package_id;type:bigint;not null;uniqueIndex:uq_package_traffic_alert_rule_package;comment:套餐商品ID" json:"package_id"`
// ThresholdPercent 真流量预警阈值百分比,取值 1100允许两位小数。
ThresholdPercent float64 `gorm:"column:threshold_percent;type:numeric(5,2);not null;comment:真流量预警阈值百分比 1100" json:"threshold_percent"`
// Enabled 状态 0-禁用 1-启用;停用后扫描不再创建新预警。
Enabled int `gorm:"column:enabled;type:smallint;not null;default:0;comment:状态 0-禁用 1-启用" json:"enabled"`
// Remark 备注,最多 500 字符。
Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:备注" json:"remark"`
// CreatedAt 创建时间。
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
// UpdatedAt 最近更新时间,阈值修改与启停均刷新。
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回套餐真流量预警规则表名。
func (PackageTrafficAlertRule) TableName() string {
return "tb_package_traffic_alert_rule"
}
// PackageTrafficAlert 套餐真流量达量预警事实模型。
// 唯一键为「主套餐使用记录 + 阈值快照」;触发时的阈值、汇总用量、资产与归属快照一律冻结,
// 后续归属或绑定变化不得改写本行,导出与列表只读这些快照列。
type PackageTrafficAlert struct {
// ID 主键。
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
BaseModel `gorm:"embedded"`
// PackageUsageID 主套餐使用记录IDmaster_usage_id 为空且按优先级/生效时间/编号取第一条)。
PackageUsageID uint `gorm:"column:package_usage_id;type:bigint;not null;uniqueIndex:uq_package_traffic_alert_usage_threshold,priority:1;comment:主套餐使用记录ID" json:"package_usage_id"`
// PackageID 阈值来源的套餐商品ID快照。
PackageID uint `gorm:"column:package_id;type:bigint;not null;comment:阈值来源套餐商品ID" json:"package_id"`
// RuleID 触发时的规则ID快照。
RuleID uint `gorm:"column:rule_id;type:bigint;not null;comment:触发时规则ID" json:"rule_id"`
// AssetType 资产类型 iot_card/device。
AssetType string `gorm:"column:asset_type;type:varchar(16);not null;comment:资产类型 iot_card-物联网卡 device-设备" json:"asset_type"`
// AssetID 资产ID取自使用记录的绑定资产。
AssetID uint `gorm:"column:asset_id;type:bigint;not null;comment:资产ID" json:"asset_id"`
// AssetIdentifierSnapshot 资产标识快照。
AssetIdentifierSnapshot string `gorm:"column:asset_identifier_snapshot;type:varchar(100);not null;default:'';comment:资产标识快照" json:"asset_identifier_snapshot"`
// CardIdentifierSnapshot 卡标识快照。
CardIdentifierSnapshot string `gorm:"column:card_identifier_snapshot;type:varchar(100);not null;default:'';comment:卡标识快照" json:"card_identifier_snapshot"`
// CounterpartIdentifierSnapshot 对应标识符快照(卡→当前设备标识,设备→当前卡标识)。
CounterpartIdentifierSnapshot string `gorm:"column:counterpart_identifier_snapshot;type:varchar(100);not null;default:'';comment:对应标识符快照" json:"counterpart_identifier_snapshot"`
// DeviceTypeSnapshot 设备类型快照。
DeviceTypeSnapshot string `gorm:"column:device_type_snapshot;type:varchar(50);not null;default:'';comment:设备类型快照" json:"device_type_snapshot"`
// DeviceModelSnapshot 设备型号快照。
DeviceModelSnapshot string `gorm:"column:device_model_snapshot;type:varchar(100);not null;default:'';comment:设备型号快照" json:"device_model_snapshot"`
// PackageNameSnapshot 套餐名称快照。
PackageNameSnapshot string `gorm:"column:package_name_snapshot;type:varchar(255);not null;default:'';comment:套餐名称快照" json:"package_name_snapshot"`
// UsedMBSnapshot 触发时该资产全部当前有效套餐的真已用量汇总(MB)。
UsedMBSnapshot int64 `gorm:"column:used_mb_snapshot;type:bigint;not null;default:0;comment:真已用量汇总快照(MB)" json:"used_mb_snapshot"`
// LimitMBSnapshot 触发时该资产全部当前有效套餐的真总量快照汇总(MB)。
LimitMBSnapshot int64 `gorm:"column:limit_mb_snapshot;type:bigint;not null;comment:真总量快照汇总(MB)" json:"limit_mb_snapshot"`
// UsagePercentSnapshot 汇总比例快照,单位百分比。
UsagePercentSnapshot float64 `gorm:"column:usage_percent_snapshot;type:numeric(9,2);not null;default:0;comment:汇总比例快照(%)" json:"usage_percent_snapshot"`
// ThresholdPercentSnapshot 触发阈值快照,与使用记录组成唯一键。
ThresholdPercentSnapshot float64 `gorm:"column:threshold_percent_snapshot;type:numeric(5,2);not null;uniqueIndex:uq_package_traffic_alert_usage_threshold,priority:2;comment:触发阈值快照(%)" json:"threshold_percent_snapshot"`
// ExpiresAtSnapshot 主套餐使用记录到期时间快照;为空表示无法推算剩余天数。
ExpiresAtSnapshot *time.Time `gorm:"column:expires_at_snapshot;type:timestamptz;comment:主套餐到期时间快照" json:"expires_at_snapshot,omitempty"`
// TriggeredAt 触发时间。
TriggeredAt time.Time `gorm:"column:triggered_at;type:timestamptz;not null;comment:触发时间" json:"triggered_at"`
// ShopIDSnapshot 触发时资产所属店铺ID快照。
ShopIDSnapshot uint `gorm:"column:shop_id_snapshot;type:bigint;not null;default:0;comment:触发时所属店铺ID快照" json:"shop_id_snapshot"`
// ShopNameSnapshot 触发时店铺名称快照。
ShopNameSnapshot string `gorm:"column:shop_name_snapshot;type:varchar(100);not null;default:'';comment:触发时店铺名称快照" json:"shop_name_snapshot"`
// BusinessOwnerAccountIDSnapshot 触发时店铺业务员账号ID快照。
BusinessOwnerAccountIDSnapshot *uint `gorm:"column:business_owner_account_id_snapshot;type:bigint;comment:触发时业务员账号ID快照" json:"business_owner_account_id_snapshot,omitempty"`
// BusinessOwnerNameSnapshot 触发时业务员账号名快照。
BusinessOwnerNameSnapshot string `gorm:"column:business_owner_name_snapshot;type:varchar(64);not null;default:'';comment:触发时业务员名称快照" json:"business_owner_name_snapshot"`
// NotificationEventID 可靠通知事件ID无有效业务员或写入失败前保持为空。
NotificationEventID string `gorm:"column:notification_event_id;type:varchar(64);not null;default:'';comment:可靠通知事件ID" json:"notification_event_id"`
// CreatedAt 创建时间。
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
// UpdatedAt 最近更新时间;预警事实创建后不再改写。
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回套餐真流量达量预警表名。
func (PackageTrafficAlert) TableName() string {
return "tb_package_traffic_alert"
}

View File

@@ -76,9 +76,22 @@ func notificationTargetDefinitions() map[string]targetDefinition {
constants.NotificationRefTypeIntegrationLog: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable},
constants.NotificationRefTypeCardSync: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable},
constants.NotificationRefTypeSystemConfig: {targetType: constants.NotificationTargetTypeSystemConfig, keyTarget: true, available: systemConfigTargetAvailable},
// 套餐真流量达量预警:仅超级管理员与平台账号可见,目标为预警详情,不返回 URL。
constants.NotificationRefTypePackageTrafficAlert: {targetType: constants.NotificationTargetTypePackageTrafficAlertDetail, idTarget: true, available: packageTrafficAlertTargetAvailable},
}
}
// packageTrafficAlertTargetAvailable 复核当前账号是否仍可查看该预警详情。
// 预警只对超级管理员与平台账号开放;其他身份的账号即使持有通知也不可跳转。
func packageTrafficAlertTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return false, nil
}
query := q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}).Where("id = ?", *id)
return targetExists(query, "查询套餐真流量达量预警通知目标失败")
}
func parseNotificationTargetID(value string) (uint, bool) {
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 || parsed > math.MaxInt64 {

View 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
}

View File

@@ -103,6 +103,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.PackageUsage != nil {
registerPackageUsageRoutes(authGroup, handlers.PackageUsage, doc, basePath)
}
if handlers.PackageTrafficAlert != nil {
registerPackageTrafficAlertRoutes(authGroup, handlers.PackageTrafficAlert, doc, basePath)
}
if handlers.ShopPackageBatchAllocation != nil {
registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath)
}

View File

@@ -0,0 +1,84 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"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"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPackageTrafficAlertRoutes 注册套餐真流量预警规则与达量预警路由。
// 沿用超管/平台路由组级 gate 先例:代理、企业与个人客户账号一律 403。
func registerPackageTrafficAlertRoutes(router fiber.Router, handler *admin.PackageTrafficAlertHandler, doc *openapi.Generator, basePath string) {
group := router.Group("", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return c.Next()
})
ruleGroup := group.Group("/package-traffic-alert-rules")
rulePath := basePath + "/package-traffic-alert-rules"
Register(ruleGroup, doc, rulePath, "GET", "", handler.ListRules, RouteSpec{
Summary: "查询套餐真流量预警规则列表",
Description: "返回套餐、商品当前真流量额度、阈值、启用状态、备注与更新时间;仅超级管理员与平台账号可访问",
Tags: []string{"套餐管理"},
Input: new(dto.ListPackageTrafficAlertRuleRequest),
Output: new(dto.PackageTrafficAlertRuleListResponse),
Auth: true,
})
Register(ruleGroup, doc, rulePath, "POST", "", handler.CreateRule, RouteSpec{
Summary: "创建套餐真流量预警规则",
Description: "每个套餐商品至多一条当前规则;创建时校验套餐存在且商品真流量额度大于零,阈值为 1%100% 的小数百分比",
Tags: []string{"套餐管理"},
Body: new(dto.CreatePackageTrafficAlertRuleRequest),
Output: new(dto.PackageTrafficAlertRuleItem),
Auth: true,
})
Register(ruleGroup, doc, rulePath, "PUT", "/:id", handler.UpdateRule, RouteSpec{
Summary: "修改套餐真流量预警规则",
Description: "允许修改阈值、启停与备注;修改不影响既有预警快照,停用后扫描不再创建新预警",
Tags: []string{"套餐管理"},
Input: new(dto.UpdatePackageTrafficAlertRuleParams),
Output: new(dto.PackageTrafficAlertRuleItem),
Auth: true,
})
alertGroup := group.Group("/package-traffic-alerts")
alertPath := basePath + "/package-traffic-alerts"
Register(alertGroup, doc, alertPath, "GET", "", handler.ListAlerts, RouteSpec{
Summary: "查询套餐真流量达量预警列表",
Description: "先应用既有资产数据范围;资产、套餐、用量、阈值、到期时间与触发时归属均为触发快照,支持按触发时间闭区间与通知投递结果筛选",
Tags: []string{"套餐流量预警"},
Input: new(dto.ListPackageTrafficAlertRequest),
Output: new(dto.PackageTrafficAlertListResponse),
Auth: true,
})
Register(alertGroup, doc, alertPath, "GET", "/:id", handler.GetAlert, RouteSpec{
Summary: "查询套餐真流量达量预警详情",
Description: "越权与不存在统一按资源不可见处理;返回冻结快照、通知投递结果与通知已读展示期",
Tags: []string{"套餐流量预警"},
Input: new(dto.IDReq),
Output: new(dto.PackageTrafficAlertDetailResponse),
Auth: true,
})
Register(alertGroup, doc, alertPath, "POST", "/export", handler.ExportAlerts, RouteSpec{
Summary: "导出套餐真流量达量预警",
Description: "复用既有异步导出任务,创建时冻结操作者、筛选、时间范围与可见资产范围;归属列按执行时当前归属补充",
Tags: []string{"套餐流量预警"},
Body: new(dto.ExportPackageTrafficAlertRequest),
Output: new(dto.CreateExportTaskResponse),
Auth: true,
})
}

View File

@@ -0,0 +1,81 @@
package postgres
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// PackageTrafficAlertStore 套餐真流量预警规则与预警事实的数据访问层。
// 规则只提供创建、修改与查询(无删除入口,停用走 enabled
// 预警事实以「主套餐使用记录 + 阈值快照」唯一键幂等插入,冲突即视为已处理。
type PackageTrafficAlertStore struct {
db *gorm.DB
}
// NewPackageTrafficAlertStore 创建套餐真流量预警 Store。
func NewPackageTrafficAlertStore(db *gorm.DB) *PackageTrafficAlertStore {
return &PackageTrafficAlertStore{db: db}
}
// DB 返回 Store 使用的数据库连接。
func (s *PackageTrafficAlertStore) DB() *gorm.DB { return s.db }
// WithTx 返回绑定指定事务的 Store。
func (s *PackageTrafficAlertStore) WithTx(tx *gorm.DB) *PackageTrafficAlertStore {
return &PackageTrafficAlertStore{db: tx}
}
// CreateRule 创建套餐真流量预警规则。
func (s *PackageTrafficAlertStore) CreateRule(ctx context.Context, rule *model.PackageTrafficAlertRule) error {
return s.db.WithContext(ctx).Create(rule).Error
}
// ExistsRuleByPackageID 判断套餐商品是否已有当前规则。
func (s *PackageTrafficAlertStore) ExistsRuleByPackageID(ctx context.Context, packageID uint) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.PackageTrafficAlertRule{}).
Where("package_id = ?", packageID).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// LockRuleByID 在事务内按主键加行锁查询预警规则。
func (s *PackageTrafficAlertStore) LockRuleByID(ctx context.Context, id uint) (*model.PackageTrafficAlertRule, error) {
var rule model.PackageTrafficAlertRule
if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&rule, id).Error; err != nil {
return nil, err
}
return &rule, nil
}
// UpdateRule 保存预警规则的阈值、启停与备注。
func (s *PackageTrafficAlertStore) UpdateRule(ctx context.Context, rule *model.PackageTrafficAlertRule, operatorID uint) error {
return s.db.WithContext(ctx).Model(&model.PackageTrafficAlertRule{}).Where("id = ?", rule.ID).Updates(map[string]any{
"threshold_percent": rule.ThresholdPercent,
"enabled": rule.Enabled,
"remark": rule.Remark,
"updater": operatorID,
"updated_at": time.Now(),
}).Error
}
// CreateAlertIdempotent 幂等插入预警事实。
// 返回 true 表示本次插入了新事实;返回 false 表示唯一键冲突,即该「使用记录 + 阈值快照」已被处理。
// 调用方必须只在返回 true 时继续写通知事件与审计。
func (s *PackageTrafficAlertStore) CreateAlertIdempotent(ctx context.Context, alert *model.PackageTrafficAlert) (bool, error) {
result := s.db.WithContext(ctx).
Clauses(clause.OnConflict{Columns: []clause.Column{
{Name: "package_usage_id"}, {Name: "threshold_percent_snapshot"},
}, DoNothing: true}).
Create(alert)
if result.Error != nil {
return false, result.Error
}
return result.RowsAffected == 1, nil
}

View File

@@ -0,0 +1,42 @@
package task
import (
"context"
"github.com/hibiken/asynq"
"go.uber.org/zap"
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PackageTrafficAlertScanHandler 处理每日套餐真流量达量预警扫描任务。
// 扫描按资产汇总当前有效套餐的真流量,命中主套餐规则阈值时原子创建预警与通知事件;
// 任务可重复执行,唯一冲突视为已处理,不重复投递通知。
type PackageTrafficAlertScanHandler struct {
service *packagetrafficalertapp.ScanService
logger *zap.Logger
}
// NewPackageTrafficAlertScanHandler 创建每日套餐真流量达量预警扫描任务处理器。
func NewPackageTrafficAlertScanHandler(service *packagetrafficalertapp.ScanService, logger *zap.Logger) *PackageTrafficAlertScanHandler {
return &PackageTrafficAlertScanHandler{service: service, logger: logger}
}
// Handle 执行一次套餐真流量达量扫描。
// 审计上下文固定为系统任务与 Worker 入口,与审计注册表中该动作声明的操作者和来源一致。
func (h *PackageTrafficAlertScanHandler) Handle(ctx context.Context, _ *asynq.Task) error {
h.logger.Info("开始执行套餐真流量达量预警扫描")
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask,
ActorID: constants.TaskTypePackageTrafficAlertScan,
ActorName: "套餐真流量达量预警扫描任务",
Source: constants.AuditSourceWorker,
})
if err := h.service.Run(ctx); err != nil {
h.logger.Error("套餐真流量达量预警扫描失败", zap.Error(err))
return err
}
return nil
}