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