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:
@@ -31,6 +31,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
|||||||
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
||||||
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
||||||
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
||||||
|
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||||
|
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
||||||
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ func generateAdminDocs(outputPath string) error {
|
|||||||
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
||||||
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
||||||
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
||||||
|
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||||
|
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
|
||||||
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
|
||||||
|
|||||||
@@ -917,6 +917,15 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
|
|||||||
)); err != nil {
|
)); err != nil {
|
||||||
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
|
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 * * *", asynq.NewTask(
|
||||||
|
constants.TaskTypePackageTrafficAlertScan,
|
||||||
|
nil,
|
||||||
|
asynq.MaxRetry(3),
|
||||||
|
asynq.Timeout(10*time.Minute),
|
||||||
|
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageTrafficAlertScan)),
|
||||||
|
)); err != nil {
|
||||||
|
return fmt.Errorf("注册每日套餐真流量达量预警扫描定时任务失败: %w", err)
|
||||||
|
}
|
||||||
if _, err := asynqScheduler.Register(
|
if _, err := asynqScheduler.Register(
|
||||||
"0 2 * * *",
|
"0 2 * * *",
|
||||||
asynq.NewTask(
|
asynq.NewTask(
|
||||||
|
|||||||
@@ -4288,6 +4288,72 @@
|
|||||||
],
|
],
|
||||||
"classification": "behavior"
|
"classification": "behavior"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"entry": "GET /api/admin/package-traffic-alert-rules",
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirements": [
|
||||||
|
"package-traffic-alert::真流量预警规则"
|
||||||
|
],
|
||||||
|
"classification": "behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"entry": "POST /api/admin/package-traffic-alert-rules",
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirements": [
|
||||||
|
"package-traffic-alert::真流量预警规则"
|
||||||
|
],
|
||||||
|
"classification": "behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"entry": "PUT /api/admin/package-traffic-alert-rules/{id}",
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirements": [
|
||||||
|
"package-traffic-alert::真流量预警规则"
|
||||||
|
],
|
||||||
|
"classification": "behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"entry": "GET /api/admin/package-traffic-alerts",
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirements": [
|
||||||
|
"package-traffic-alert::预警查询与导出"
|
||||||
|
],
|
||||||
|
"classification": "behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"entry": "GET /api/admin/package-traffic-alerts/{id}",
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirements": [
|
||||||
|
"package-traffic-alert::预警查询与导出"
|
||||||
|
],
|
||||||
|
"classification": "behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "http",
|
||||||
|
"entry": "POST /api/admin/package-traffic-alerts/export",
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirements": [
|
||||||
|
"package-traffic-alert::预警查询与导出"
|
||||||
|
],
|
||||||
|
"classification": "behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entry_type": "async",
|
||||||
|
"entry": "constants.TaskTypePackageTrafficAlertScan",
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirements": [
|
||||||
|
"package-traffic-alert::有效套餐与汇总口径",
|
||||||
|
"package-traffic-alert::去重与补建",
|
||||||
|
"package-traffic-alert::预警通知",
|
||||||
|
"package-traffic-alert::与运营商通道阈值分离"
|
||||||
|
],
|
||||||
|
"classification": "behavior"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"entry_type": "async",
|
"entry_type": "async",
|
||||||
"entry": "constants.TaskTypeCarrierThresholdCycle",
|
"entry": "constants.TaskTypeCarrierThresholdCycle",
|
||||||
@@ -4302,4 +4368,4 @@
|
|||||||
"requirements": [],
|
"requirements": [],
|
||||||
"classification": "route_index_or_infrastructure"
|
"classification": "route_index_or_infrastructure"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3955,5 +3955,169 @@
|
|||||||
],
|
],
|
||||||
"exit_status": 0
|
"exit_status": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirement": "真流量预警规则",
|
||||||
|
"spec": "openspec/specs/package-traffic-alert/spec.md",
|
||||||
|
"entries": [
|
||||||
|
"/api/admin/package-traffic-alert-rules"
|
||||||
|
],
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"internal/routes/package_traffic_alert.go",
|
||||||
|
"internal/handler/admin/package_traffic_alert.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/packagetrafficalert/rule.go",
|
||||||
|
"internal/query/packagetrafficalert/query.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"internal/domain/packagetrafficalert/threshold.go",
|
||||||
|
"pkg/constants/notification.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"migrations/000228_add_package_traffic_alert.up.sql",
|
||||||
|
"internal/store/postgres/package_traffic_alert_store.go"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "隔离库 junhong_cmp_test 验证规则 CRUD 与阈值上下界校验",
|
||||||
|
"literal_output": [
|
||||||
|
"internal/handler/admin/package_traffic_alert.go: 真流量预警阈值必须大于等于 1 且小于等于 100,允许两位小数"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirement": "有效套餐与汇总口径",
|
||||||
|
"spec": "openspec/specs/package-traffic-alert/spec.md",
|
||||||
|
"entries": [
|
||||||
|
"constants.TaskTypePackageTrafficAlertScan"
|
||||||
|
],
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"cmd/worker/main.go",
|
||||||
|
"internal/task/package_traffic_alert_scan.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/packagetrafficalert/scan.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"internal/domain/packagetrafficalert/threshold.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"internal/infrastructure/packagetrafficalert/scanner.go"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "隔离库验证多套餐汇总、失效记录不计入、商品改价重跑",
|
||||||
|
"literal_output": [
|
||||||
|
"internal/application/packagetrafficalert/scan.go: used=1000 limit=2000 percent=50.00(status 1/2 汇总,status 3 与 refund_id 记录不计入)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirement": "去重与补建",
|
||||||
|
"spec": "openspec/specs/package-traffic-alert/spec.md",
|
||||||
|
"entries": [
|
||||||
|
"constants.TaskTypePackageTrafficAlertScan"
|
||||||
|
],
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"cmd/worker/main.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/packagetrafficalert/scan.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"pkg/constants/notification.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"internal/infrastructure/packagetrafficalert/writer.go"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "隔离库重复扫描验证唯一冲突不重复通知、降阈值补建新快照",
|
||||||
|
"literal_output": [
|
||||||
|
"internal/application/packagetrafficalert/scan.go: hits=1 created=0 duplicates=1(重复扫描)",
|
||||||
|
"internal/application/packagetrafficalert/scan.go: threshold=10.00 created=1(降阈值补建)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirement": "预警通知",
|
||||||
|
"spec": "openspec/specs/package-traffic-alert/spec.md",
|
||||||
|
"entries": [
|
||||||
|
"constants.TaskTypePackageTrafficAlertScan"
|
||||||
|
],
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"cmd/worker/main.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/packagetrafficalert/scan.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"pkg/constants/notification.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"internal/infrastructure/notification/registry.go"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "隔离库验证仅业务员解析、无业务员只建预警、通知可见未读可跳转",
|
||||||
|
"literal_output": [
|
||||||
|
"internal/application/packagetrafficalert/scan.go: notification recipient=account/675 category=expiry isRead=false",
|
||||||
|
"GET /api/admin/notifications/162/target: target_type=package_traffic_alert_detail available=true"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirement": "预警查询与导出",
|
||||||
|
"spec": "openspec/specs/package-traffic-alert/spec.md",
|
||||||
|
"entries": [
|
||||||
|
"/api/admin/package-traffic-alerts"
|
||||||
|
],
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"internal/routes/package_traffic_alert.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/query/packagetrafficalert/query.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"internal/exporter/package_traffic_alert_scene.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"internal/model/dto/package_traffic_alert_dto.go"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "隔离库验证超管/平台可读、代理 1005、越权与不存在统一不可见、导出冻结语义",
|
||||||
|
"literal_output": [
|
||||||
|
"internal/handler/admin/package_traffic_alert.go: 代理访问返回 无权限操作该资源或资源不存在",
|
||||||
|
"internal/exporter/package_traffic_alert_scene.go: export row 套餐/用量/阈值=触发快照,店铺/业务员=执行时当前归属(归属变更后由 AUG26004店铺 变为 AUG26004店铺2)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "package-traffic-alert",
|
||||||
|
"requirement": "与运营商通道阈值分离",
|
||||||
|
"spec": "openspec/specs/package-traffic-alert/spec.md",
|
||||||
|
"entries": [
|
||||||
|
"constants.TaskTypePackageTrafficAlertScan"
|
||||||
|
],
|
||||||
|
"handler_consumer_job": [
|
||||||
|
"internal/task/package_traffic_alert_scan.go"
|
||||||
|
],
|
||||||
|
"application_service_query": [
|
||||||
|
"internal/application/packagetrafficalert/scan.go"
|
||||||
|
],
|
||||||
|
"domain_state_amount": [
|
||||||
|
"internal/domain/packagetrafficalert/threshold.go"
|
||||||
|
],
|
||||||
|
"store_migration_config": [
|
||||||
|
"migrations/000228_add_package_traffic_alert.up.sql"
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"command": "代码检索确认无停机锁、无运营商调用、不读通道累计流量",
|
||||||
|
"literal_output": [
|
||||||
|
"internal/infrastructure/packagetrafficalert/scanner.go: 不读取虚流量、展示量、卡级累计或运营商通道累计"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
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"
|
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
|
||||||
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
|
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
|
||||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
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"
|
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
|
||||||
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||||
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
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)
|
riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit)
|
||||||
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
|
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
|
||||||
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
|
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
|
||||||
|
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
|
||||||
|
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||||
@@ -282,6 +284,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||||
Package: admin.NewPackageHandler(svc.Package),
|
Package: admin.NewPackageHandler(svc.Package),
|
||||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||||
|
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
||||||
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
|
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
|
||||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
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"
|
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||||
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||||
@@ -115,6 +116,7 @@ type services struct {
|
|||||||
Package *packageSvc.Service
|
Package *packageSvc.Service
|
||||||
PackageDailyRecord *packageSvc.DailyRecordService
|
PackageDailyRecord *packageSvc.DailyRecordService
|
||||||
PackageCustomerView *packageSvc.CustomerViewService
|
PackageCustomerView *packageSvc.CustomerViewService
|
||||||
|
PackageTrafficAlertRule *packagetrafficalertapp.RuleService
|
||||||
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
|
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
|
||||||
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
|
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
|
||||||
ShopSeriesGrant *shopSeriesGrantSvc.Service
|
ShopSeriesGrant *shopSeriesGrantSvc.Service
|
||||||
@@ -494,6 +496,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
Package: packageService,
|
Package: packageService,
|
||||||
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
||||||
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, 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),
|
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),
|
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),
|
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
|
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
|
||||||
// 流量系统
|
// 流量系统
|
||||||
CardDailyUsage *postgres.CardDailyUsageStore
|
CardDailyUsage *postgres.CardDailyUsageStore
|
||||||
|
// 套餐真流量预警规则与达量预警事实
|
||||||
|
PackageTrafficAlert *postgres.PackageTrafficAlertStore
|
||||||
// 资产标识符注册表
|
// 资产标识符注册表
|
||||||
AssetIdentifier *postgres.AssetIdentifierStore
|
AssetIdentifier *postgres.AssetIdentifierStore
|
||||||
}
|
}
|
||||||
@@ -148,5 +150,6 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB),
|
BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB),
|
||||||
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
|
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
|
||||||
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
|
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
|
||||||
|
PackageTrafficAlert: postgres.NewPackageTrafficAlertStore(deps.DB),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type Handlers struct {
|
|||||||
BusinessUserGroup *admin.BusinessUserGroupHandler
|
BusinessUserGroup *admin.BusinessUserGroupHandler
|
||||||
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
|
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
|
||||||
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
|
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
|
||||||
|
PackageTrafficAlert *admin.PackageTrafficAlertHandler
|
||||||
ClientWechat *app.ClientWechatHandler
|
ClientWechat *app.ClientWechatHandler
|
||||||
SuperAdmin *admin.SuperAdminHandler
|
SuperAdmin *admin.SuperAdminHandler
|
||||||
SystemConfig *admin.SystemConfigHandler
|
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),
|
NewRefundDataSource(db),
|
||||||
NewExchangeDataSource(db),
|
NewExchangeDataSource(db),
|
||||||
NewCommissionRecordDataSource(db),
|
NewCommissionRecordDataSource(db),
|
||||||
|
NewPackageTrafficAlertDataSource(db),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +74,8 @@ func IsSupportedScene(scene string) bool {
|
|||||||
constants.ExportTaskSceneAgentRecharge,
|
constants.ExportTaskSceneAgentRecharge,
|
||||||
constants.ExportTaskSceneRefund,
|
constants.ExportTaskSceneRefund,
|
||||||
constants.ExportTaskSceneExchange,
|
constants.ExportTaskSceneExchange,
|
||||||
constants.ExportTaskSceneCommissionRecord:
|
constants.ExportTaskSceneCommissionRecord,
|
||||||
|
constants.ExportTaskScenePackageTrafficAlert:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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)
|
businessUserGroupDisabled := businessUserGroupAction(constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup)
|
||||||
businessUserGroupDeleted := businessUserGroupAction(constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", constants.AuditRiskHigh, constants.AuditResourceBusinessUserGroup)
|
businessUserGroupDeleted := businessUserGroupAction(constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", constants.AuditRiskHigh, constants.AuditResourceBusinessUserGroup)
|
||||||
businessUserGroupMembersUpdated := businessUserGroupAction(constants.AuditActionBusinessUserGroupMembersUpdated, "批量维护业务用户组成员", constants.AuditRiskNormal, constants.AuditResourceAccount)
|
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)
|
shopBusinessOwnerBatchUpdated := batchRootAction(constants.AuditActionShopBusinessOwnerBatchUpdated, "批量交接店铺负责人", constants.AuditResourceShopBusinessOwnerBatch)
|
||||||
shopBusinessOwnerImported := taskAction(constants.AuditActionShopBusinessOwnerImported, "导入变更店铺负责人", constants.AuditResourceShop, constants.AuditActorSystemTask, constants.AuditSourceWorker)
|
shopBusinessOwnerImported := taskAction(constants.AuditActionShopBusinessOwnerImported, "导入变更店铺负责人", constants.AuditResourceShop, constants.AuditActorSystemTask, constants.AuditSourceWorker)
|
||||||
shopBusinessOwnerImportTaskCreated := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCreated, "创建店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
shopBusinessOwnerImportTaskCreated := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCreated, "创建店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||||
@@ -594,6 +599,11 @@ func NewRegistry() *Registry {
|
|||||||
constants.AuditActionBusinessUserGroupDisabled: businessUserGroupDisabled,
|
constants.AuditActionBusinessUserGroupDisabled: businessUserGroupDisabled,
|
||||||
constants.AuditActionBusinessUserGroupDeleted: businessUserGroupDeleted,
|
constants.AuditActionBusinessUserGroupDeleted: businessUserGroupDeleted,
|
||||||
constants.AuditActionBusinessUserGroupMembersUpdated: businessUserGroupMembersUpdated,
|
constants.AuditActionBusinessUserGroupMembersUpdated: businessUserGroupMembersUpdated,
|
||||||
|
constants.AuditActionPackageTrafficAlertRuleCreated: packageTrafficAlertRuleCreated,
|
||||||
|
constants.AuditActionPackageTrafficAlertRuleUpdated: packageTrafficAlertRuleUpdated,
|
||||||
|
constants.AuditActionPackageTrafficAlertRuleEnabled: packageTrafficAlertRuleEnabled,
|
||||||
|
constants.AuditActionPackageTrafficAlertRuleDisabled: packageTrafficAlertRuleDisabled,
|
||||||
|
constants.AuditActionPackageTrafficAlertTriggered: packageTrafficAlertTriggered,
|
||||||
constants.AuditActionShopBusinessOwnerBatchUpdated: shopBusinessOwnerBatchUpdated,
|
constants.AuditActionShopBusinessOwnerBatchUpdated: shopBusinessOwnerBatchUpdated,
|
||||||
constants.AuditActionShopBusinessOwnerImported: shopBusinessOwnerImported,
|
constants.AuditActionShopBusinessOwnerImported: shopBusinessOwnerImported,
|
||||||
constants.AuditActionShopBusinessOwnerImportTaskCreated: shopBusinessOwnerImportTaskCreated,
|
constants.AuditActionShopBusinessOwnerImportTaskCreated: shopBusinessOwnerImportTaskCreated,
|
||||||
@@ -841,6 +851,14 @@ func NewRegistry() *Registry {
|
|||||||
Type: constants.AuditResourcePhoneAssetUnbindImportTask, Name: "手机号资产解绑导入任务",
|
Type: constants.AuditResourcePhoneAssetUnbindImportTask, Name: "手机号资产解绑导入任务",
|
||||||
IdentityFields: []string{"id", "task_no", "file_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: {
|
constants.AuditResourceNotification: {
|
||||||
Type: constants.AuditResourceNotification, Name: "站内通知",
|
Type: constants.AuditResourceNotification, Name: "站内通知",
|
||||||
IdentityFields: []string{"id", "event_id", "recipient_kind", "recipient_id", "category", "type", "severity", "ref_type", "ref_id", "ref_key"},
|
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 定义同步后台批次根动作;子事件自带店铺或资源作用域。
|
// batchRootAction 定义同步后台批次根动作;子事件自带店铺或资源作用域。
|
||||||
func batchRootAction(code, name, primaryResource string) ActionDefinition {
|
func batchRootAction(code, name, primaryResource string) ActionDefinition {
|
||||||
return ActionDefinition{
|
return ActionDefinition{
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ func NewRegistry() *Registry {
|
|||||||
constants.NotificationRefTypeAsset: {},
|
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 创建导出任务请求。
|
// CreateExportTaskRequest 创建导出任务请求。
|
||||||
type CreateExportTaskRequest struct {
|
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)"`
|
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对象,可选)"`
|
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象,可选)"`
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ type CreateExportTaskResponse struct {
|
|||||||
type ListExportTaskRequest struct {
|
type ListExportTaskRequest struct {
|
||||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
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:"每页数量"`
|
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:已取消)"`
|
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:"创建时间起始"`
|
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
|
||||||
EndTime *time.Time `json:"end_time" query:"end_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"`
|
ID uint `json:"id" description:"任务ID"`
|
||||||
TaskID uint `json:"task_id" description:"任务ID"`
|
TaskID uint `json:"task_id" description:"任务ID"`
|
||||||
TaskNo string `json:"task_no" description:"任务编号"`
|
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)"`
|
Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"`
|
||||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
||||||
StatusName string `json:"status_name" description:"任务状态名称(中文)"`
|
StatusName string `json:"status_name" description:"任务状态名称(中文)"`
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type NotificationUnreadCountResponse struct {
|
|||||||
// NotificationListRequest 是后台通知基础分页参数。
|
// NotificationListRequest 是后台通知基础分页参数。
|
||||||
type NotificationListRequest struct {
|
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:系统)"`
|
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:严重)"`
|
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:"已读状态;不传时查询全部"`
|
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"`
|
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 {
|
type NotificationItem struct {
|
||||||
ID uint `json:"id" description:"通知ID"`
|
ID uint `json:"id" description:"通知ID"`
|
||||||
Category string `json:"category" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
|
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:严重)"`
|
Severity string `json:"severity" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
|
||||||
Title string `json:"title" description:"纯文本标题"`
|
Title string `json:"title" description:"纯文本标题"`
|
||||||
Body string `json:"body" 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"`
|
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"`
|
RefKey string `json:"ref_key" description:"受控资源稳定Key或展示快照;可能为空。system_config 为配置Key,integration_log/card_sync 为集成标识,asset 为资产标识快照;仅用于定位或展示,不是前端URL"`
|
||||||
IsRead bool `json:"is_read" description:"是否已读"`
|
IsRead bool `json:"is_read" description:"是否已读"`
|
||||||
@@ -65,7 +65,7 @@ type NotificationReadResponse struct {
|
|||||||
|
|
||||||
// NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。
|
// NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。
|
||||||
type NotificationTargetResponse struct {
|
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"`
|
TargetID *uint `json:"target_id,omitempty" description:"ID型目标的业务主键;前端按 target_type 映射受控页面,不得自行拼接任意URL"`
|
||||||
TargetKey string `json:"target_key,omitempty" description:"Key型目标的稳定定位值;仅用于 integration_log 或 system_config 等白名单目标"`
|
TargetKey string `json:"target_key,omitempty" description:"Key型目标的稳定定位值;仅用于 integration_log 或 system_config 等白名单目标"`
|
||||||
Available bool `json:"available" description:"当前账号是否仍可访问目标;false 时只展示通知正文,不执行跳转"`
|
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.NotificationRefTypeIntegrationLog: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable},
|
||||||
constants.NotificationRefTypeCardSync: {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},
|
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) {
|
func parseNotificationTargetID(value string) (uint, bool) {
|
||||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||||
if err != nil || parsed == 0 || parsed > math.MaxInt64 {
|
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 {
|
if handlers.PackageUsage != nil {
|
||||||
registerPackageUsageRoutes(authGroup, handlers.PackageUsage, doc, basePath)
|
registerPackageUsageRoutes(authGroup, handlers.PackageUsage, doc, basePath)
|
||||||
}
|
}
|
||||||
|
if handlers.PackageTrafficAlert != nil {
|
||||||
|
registerPackageTrafficAlertRoutes(authGroup, handlers.PackageTrafficAlert, doc, basePath)
|
||||||
|
}
|
||||||
if handlers.ShopPackageBatchAllocation != nil {
|
if handlers.ShopPackageBatchAllocation != nil {
|
||||||
registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath)
|
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
|
||||||
|
}
|
||||||
23
migrations/000228_add_package_traffic_alert.down.sql
Normal file
23
migrations/000228_add_package_traffic_alert.down.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- 回滚套餐真流量达量预警的规则表、预警快照表与扫描部分索引。
|
||||||
|
-- 先删扫描索引,再按依赖倒序删除预警表与规则表,与 up 的创建顺序严格成对;
|
||||||
|
-- 既有 tb_package_usage 的列与数据不受影响,本迁移从未修改既有表结构。
|
||||||
|
--
|
||||||
|
-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除 tb_package_traffic_alert 与
|
||||||
|
-- tb_package_traffic_alert_rule。规则可由后台按当前业务口径重建;已触发的预警与冻结快照
|
||||||
|
-- 无法由数据库自身重建,只能依据 tb_audit_event 中 package_traffic_alert.trigger 的前后值快照
|
||||||
|
-- 与站内通知正文人工复核。因此 down 只在预警事实已无留存需求时执行。
|
||||||
|
--
|
||||||
|
-- 守卫:预警表存在任何行时直接阻断回滚,避免静默丢失预警事实与已投递通知的追溯依据。
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM tb_package_traffic_alert LIMIT 1) THEN
|
||||||
|
RAISE EXCEPTION '存在套餐真流量达量预警事实,拒绝回滚以避免丢失预警快照与通知追溯依据';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_package_usage_alert_scope;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS tb_package_traffic_alert;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS tb_package_traffic_alert_rule;
|
||||||
125
migrations/000228_add_package_traffic_alert.up.sql
Normal file
125
migrations/000228_add_package_traffic_alert.up.sql
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
-- 套餐真流量达量预警:规则表与预警快照表,以及扫描用的有效套餐部分索引。
|
||||||
|
-- 背景:运营需要按「同一资产全部当前有效套餐的真流量汇总」判断达量,并向触发时资产所属店铺的
|
||||||
|
-- 有效平台业务员投递站内通知。既有事实里没有任何承载「套餐商品真流量预警规则」的表,
|
||||||
|
-- 也没有可冻结触发阈值、汇总用量、归属与资产展示快照的预警表,因此本迁移新增两张表;
|
||||||
|
-- 既有 tb_notification 的类别 CHECK 已允许 expiry,通知类型与受控资源类型只在代码注册表登记,
|
||||||
|
-- 故不改通知表结构;tb_package_usage 只新增一个非唯一部分索引,不改任何既有列与数据。
|
||||||
|
--
|
||||||
|
-- 设计选择:
|
||||||
|
-- 1. 规则表不设 deleted_at:本能力只提供创建、修改阈值与启停,没有删除入口,
|
||||||
|
-- 因此 package_id 可以直接用「非部分」唯一约束表达「每个套餐商品至多一条当前规则」,
|
||||||
|
-- 避免部分唯一索引在 OnConflict 未声明谓词时的失败(KNOWN-ISSUE-001)。
|
||||||
|
-- 2. 阈值用 NUMERIC(5,2) 表示 1%~100% 的小数百分比;判定在应用层用整数基点比较,不使用浮点。
|
||||||
|
-- 3. 预警表以「主套餐使用记录 + 阈值快照」为唯一键:一个资产的一个阈值至多一条预警,
|
||||||
|
-- 重复扫描由唯一冲突视为已处理;降低阈值产生新阈值快照属预期补建,不修改既有预警快照。
|
||||||
|
-- 4. 触发快照列(用量、额度、比例、阈值、到期时间、资产标识、卡标识、对端标识、设备类型与型号、
|
||||||
|
-- 店铺与业务员)在创建预警时冻结,列表、详情与导出一律读快照,不随之后的归属或绑定变化改写。
|
||||||
|
-- 5. notification_event_id 只在写入可靠通知事件时填充;无有效业务员时保持为空字符串,
|
||||||
|
-- 表示该预警不产生通知且不在未来补发。
|
||||||
|
-- 6. 不使用数据库外键;资产、店铺、账号与规则均以 ID 保存并由应用层显式校验。
|
||||||
|
|
||||||
|
CREATE TABLE tb_package_traffic_alert_rule (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
package_id BIGINT NOT NULL,
|
||||||
|
threshold_percent NUMERIC(5, 2) NOT NULL,
|
||||||
|
enabled SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
remark VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
creator BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updater BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_package_traffic_alert_rule_package UNIQUE (package_id),
|
||||||
|
CONSTRAINT ck_package_traffic_alert_rule_threshold CHECK (threshold_percent >= 1 AND threshold_percent <= 100),
|
||||||
|
CONSTRAINT ck_package_traffic_alert_rule_enabled CHECK (enabled IN (0, 1))
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tb_package_traffic_alert_rule IS '套餐真流量预警规则,每个套餐商品至多一条当前规则,仅超级管理员与平台账号维护';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.id IS '主键';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.package_id IS '套餐商品ID;唯一约束保证每个商品至多一条当前规则';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.threshold_percent IS '真流量预警阈值百分比,取值 1~100,允许两位小数';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.enabled IS '状态 0-禁用 1-启用;停用后扫描不再创建新预警,既有预警快照不变';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.remark IS '备注,最多 500 字符';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.creator IS '创建人账号ID';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.updater IS '最近更新人账号ID';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert_rule.updated_at IS '最近更新时间,阈值修改与启停均必须刷新';
|
||||||
|
|
||||||
|
CREATE TABLE tb_package_traffic_alert (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
package_usage_id BIGINT NOT NULL,
|
||||||
|
package_id BIGINT NOT NULL,
|
||||||
|
rule_id BIGINT NOT NULL,
|
||||||
|
asset_type VARCHAR(16) NOT NULL,
|
||||||
|
asset_id BIGINT NOT NULL,
|
||||||
|
asset_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
card_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
counterpart_identifier_snapshot VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
device_type_snapshot VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
device_model_snapshot VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
package_name_snapshot VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
used_mb_snapshot BIGINT NOT NULL DEFAULT 0,
|
||||||
|
limit_mb_snapshot BIGINT NOT NULL,
|
||||||
|
usage_percent_snapshot NUMERIC(9, 2) NOT NULL DEFAULT 0,
|
||||||
|
threshold_percent_snapshot NUMERIC(5, 2) NOT NULL,
|
||||||
|
expires_at_snapshot TIMESTAMPTZ,
|
||||||
|
triggered_at TIMESTAMPTZ NOT NULL,
|
||||||
|
shop_id_snapshot BIGINT NOT NULL DEFAULT 0,
|
||||||
|
shop_name_snapshot VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
business_owner_account_id_snapshot BIGINT,
|
||||||
|
business_owner_name_snapshot VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
notification_event_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
creator BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updater BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_package_traffic_alert_usage_threshold UNIQUE (package_usage_id, threshold_percent_snapshot),
|
||||||
|
CONSTRAINT ck_package_traffic_alert_asset_type CHECK (asset_type IN ('iot_card', 'device')),
|
||||||
|
CONSTRAINT ck_package_traffic_alert_threshold CHECK (threshold_percent_snapshot >= 1 AND threshold_percent_snapshot <= 100),
|
||||||
|
CONSTRAINT ck_package_traffic_alert_limit CHECK (limit_mb_snapshot > 0),
|
||||||
|
CONSTRAINT ck_package_traffic_alert_used CHECK (used_mb_snapshot >= 0),
|
||||||
|
CONSTRAINT ck_package_traffic_alert_percent CHECK (usage_percent_snapshot >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE tb_package_traffic_alert IS '套餐真流量达量预警事实,触发时冻结阈值、汇总用量与资产归属快照';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.id IS '主键';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.package_usage_id IS '主套餐使用记录ID(master_usage_id 为空且按优先级/生效时间/编号取第一条),预警锚点';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.package_id IS '阈值来源的套餐商品ID快照';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.rule_id IS '触发时的预警规则ID快照';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.asset_type IS '资产类型 iot_card-物联网卡 device-设备';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.asset_id IS '资产ID;取自使用记录的绑定资产,是权威归属';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.asset_identifier_snapshot IS '资产标识快照:卡取 ICCID,设备取虚拟号→IMEI→SN 的稳定优先级';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.card_identifier_snapshot IS '卡标识快照:卡资产为自身 ICCID,设备资产为触发时当前绑定卡 ICCID';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.counterpart_identifier_snapshot IS '对应标识符快照:卡资产为触发时当前绑定设备标识,设备资产为触发时当前绑定卡 ICCID';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.device_type_snapshot IS '设备类型快照:设备资产取自身,卡资产取触发时当前绑定设备';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.device_model_snapshot IS '设备型号快照:设备资产取自身,卡资产取触发时当前绑定设备';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.package_name_snapshot IS '套餐名称快照,优先使用记录快照,缺失时回落商品名称';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.used_mb_snapshot IS '触发时该资产全部当前有效套餐的真已用量汇总(MB)';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.limit_mb_snapshot IS '触发时该资产全部当前有效套餐的真总量快照汇总(MB)';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.usage_percent_snapshot IS '触发时汇总比例快照,单位百分比,保留两位小数';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.threshold_percent_snapshot IS '触发阈值快照,单位百分比;与使用记录组成唯一键';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.expires_at_snapshot IS '主套餐使用记录到期时间快照;为空时导出到期时间与剩余天数为空';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.triggered_at IS '触发时间,列表与导出按该列筛选';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.shop_id_snapshot IS '触发时资产所属店铺ID快照';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.shop_name_snapshot IS '触发时店铺名称快照';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.business_owner_account_id_snapshot IS '触发时店铺业务员账号ID快照;无有效业务员为空';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.business_owner_name_snapshot IS '触发时业务员账号名快照;无有效业务员为空字符串';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.notification_event_id IS '可靠通知事件ID;仅在写入通知事件时填充,无有效业务员时为空';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.creator IS '创建人账号ID,扫描任务写入 0';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.updater IS '最近更新人账号ID,预警事实创建后不再改写';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN tb_package_traffic_alert.updated_at IS '最近更新时间';
|
||||||
|
|
||||||
|
-- 列表默认按触发时间倒序;店铺、套餐与资产筛选各自走独立索引。
|
||||||
|
CREATE INDEX idx_package_traffic_alert_triggered ON tb_package_traffic_alert (triggered_at DESC, id DESC);
|
||||||
|
CREATE INDEX idx_package_traffic_alert_shop ON tb_package_traffic_alert (shop_id_snapshot, triggered_at DESC, id DESC);
|
||||||
|
CREATE INDEX idx_package_traffic_alert_package ON tb_package_traffic_alert (package_id, triggered_at DESC, id DESC);
|
||||||
|
CREATE INDEX idx_package_traffic_alert_asset ON tb_package_traffic_alert (asset_type, asset_id);
|
||||||
|
CREATE INDEX idx_package_traffic_alert_event ON tb_package_traffic_alert (notification_event_id);
|
||||||
|
|
||||||
|
-- 扫描只看当前有效套餐集合:status IN (1,2)、未退款、未软删。
|
||||||
|
-- 该索引为非唯一部分索引,只服务只读聚合,不与任何 OnConflict 组合,因此不涉及部分唯一索引谓词问题。
|
||||||
|
CREATE INDEX idx_package_usage_alert_scope
|
||||||
|
ON tb_package_usage (iot_card_id, device_id, master_usage_id)
|
||||||
|
WHERE deleted_at IS NULL AND refund_id IS NULL AND status IN (1, 2);
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_package_usage_alert_scope IS '套餐真流量达量扫描的有效套餐集合部分索引';
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
套餐商品已有 `real_data_mb`,套餐使用记录承载真实用量;现有通知以事件投递并按接收人隔离。预警必须是套餐级观测,不能复用运营商通道阈值停机锁。
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- 新增套餐规则和预警表;规则以套餐唯一,预警以套餐使用记录+阈值快照唯一,保存触发时流量、资产/卡/设备、套餐和店铺/业务员快照。
|
|
||||||
- 扫描查询当前有效使用记录,按资产汇总真实用量与额度,并选出主套餐规则;用唯一约束和事务创建预警及通知事件,扫描可安全重跑。
|
|
||||||
- 不向未来业务员补发:接收人仅在预警创建事务中解析。读侧以资产范围过滤,导出复用现有快照任务。
|
|
||||||
|
|
||||||
## 管理、扫描与查询动作契约
|
|
||||||
|
|
||||||
### 规则维护
|
|
||||||
|
|
||||||
- `POST /package-traffic-alert-rules`:仅超级管理员、平台用户;请求 `package_id`、`threshold_percent`(大于等于 1、小于等于 100,允许小数)、`enabled`、`remark`(最多 500 字符)。套餐必须存在且 `real_data_mb > 0`;同套餐已有规则返回“套餐已存在真流量预警规则”。
|
|
||||||
- `PUT /package-traffic-alert-rules/:id`:允许修改阈值、启停、备注;不修改已产生预警快照。停用后扫描不建新预警;启用或降低阈值后不主动回填,仅由下一次扫描按当前有效套餐判断。
|
|
||||||
- `GET /package-traffic-alert-rules` 返回套餐、真流量总额度、阈值、启用状态、备注和更新时间。所有成功写操作记录操作者、前后值和时间。
|
|
||||||
|
|
||||||
### 扫描与预警创建
|
|
||||||
|
|
||||||
- Worker 只读取当前有效套餐使用记录;按同一资产聚合这些记录的真实已用量与套餐 `real_data_mb`,不读取虚流量、展示流量或运营商通道累计值。主套餐不存在有效规则、总额度不大于零或比例未达阈值时跳过。
|
|
||||||
- 对命中主套餐规则的套餐使用记录,在事务中写预警唯一键 `(package_usage_id, threshold_percent_snapshot)`,同时冻结套餐、资产、卡、当前设备、店铺、业务员、真实用量、额度、比例、阈值和触发时间。唯一冲突视为已处理,不重复投递通知。
|
|
||||||
- 创建时仅解析当前资产所属店铺的有效业务员;存在时在同一可靠事件链创建一条站内通知,保存预警 ID 作为幂等键;不存在时只保存预警。通知失败进入既有可靠投递恢复,不能删除预警或重新计算快照。
|
|
||||||
|
|
||||||
### 列表、详情与导出
|
|
||||||
|
|
||||||
- `GET /package-traffic-alerts`:仅超级管理员、平台用户,先应用既有资产数据范围;支持套餐、店铺、业务员、资产/卡标识、阈值、触发时间和通知投递状态筛选、分页。返回冻结快照与通知结果,当前归属变化不得改写预警事实。
|
|
||||||
- `GET /package-traffic-alerts/:id`:同一数据范围校验后返回完整预警快照和通知投递历史;越权与不存在统一按既有资源不可见处理。
|
|
||||||
- `POST /package-traffic-alerts/export`:复用异步导出;创建时冻结操作者、筛选、时间范围和可见资产范围。每行对应一条预警,执行时不得扩大范围或重算已冻结流量字段。
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- 流量数据延迟 → 下次扫描补建,不回写已冻结预警。
|
|
||||||
- 多卡设备 → 以使用记录资产归属聚合,不从当前设备反推流量。
|
|
||||||
- 扫描并发 → 唯一索引处理同一命中重复创建。
|
|
||||||
|
|
||||||
## Migration Plan
|
|
||||||
|
|
||||||
新增成对迁移和索引;隔离环境验证规则启停/降阈值、有效套餐汇总、去重、无业务员、权限导出和 up/down/up。
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
## Purpose
|
|
||||||
|
|
||||||
按套餐真实流量和当前有效套餐事实生成一次性达量预警,向资产所属店铺当时有效业务员投递可追溯通知,而不将通道级停复机控制或虚流量混入套餐预警。
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: 真流量预警规则
|
|
||||||
系统 SHALL 为每个套餐商品维护至多一条当前真流量预警规则,阈值为 1% 至 100% 的小数百分比。规则启用、修改或降低阈值只影响后续扫描;既有预警 MUST 保留触发时的套餐、阈值、流量和资产快照。规则停用后停止创建新预警;重新启用或降低阈值后,下次扫描发现已有有效套餐达量时必须补建符合条件的预警。
|
|
||||||
|
|
||||||
#### Scenario: 降低阈值后补建
|
|
||||||
- **WHEN** 管理员降低一个启用规则的阈值,下一次扫描发现其有效套餐已达到新阈值
|
|
||||||
- **THEN** 系统创建预警并冻结新阈值,不修改既有预警快照
|
|
||||||
|
|
||||||
### Requirement: 汇总口径、去重与通知
|
|
||||||
系统 SHALL 以同一资产全部当前有效套餐的 `真流量使用量 / 真流量总额度` 汇总比例判断达量,并使用该资产主套餐的规则。实际消耗流量的套餐使用记录所关联资产是权威归属;插拔卡时预警同时展示卡和当前关联设备,但 MUST NOT 汇总多张卡。虚流量、已失效/过期/非当前有效套餐不得计入。
|
|
||||||
|
|
||||||
同一套餐使用记录和同一命中阈值 MUST 至多创建一条预警。预警创建时仅向资产所属店铺当时有效业务员创建站内通知;无有效业务员时仍保留预警事实但不补发给未来新增业务员。通知和预警均须幂等,重复扫描不得重复创建。
|
|
||||||
|
|
||||||
#### Scenario: 多个有效套餐共同达量
|
|
||||||
- **WHEN** 某资产的多个当前有效套餐真流量汇总达到其主套餐规则阈值
|
|
||||||
- **THEN** 系统为命中套餐使用记录创建唯一预警,并只向扫描时该资产所属店铺的有效业务员投递通知
|
|
||||||
|
|
||||||
#### Scenario: 重复扫描
|
|
||||||
- **WHEN** 相同套餐使用记录和相同阈值被重复扫描命中
|
|
||||||
- **THEN** 系统保留原预警和通知,不创建重复记录
|
|
||||||
|
|
||||||
### Requirement: 预警查询与导出
|
|
||||||
超级管理员和平台用户 SHALL 在既有资产数据范围内查询和导出预警;列表和导出返回资产、卡、当前设备、套餐使用记录、真流量用量/额度/比例、阈值快照、触发时间及通知投递结果。导出必须使用既有异步任务并冻结创建时操作者、筛选条件和可见范围。
|
|
||||||
|
|
||||||
#### Scenario: 受限导出
|
|
||||||
- **WHEN** 平台用户在其资产数据范围内创建预警导出
|
|
||||||
- **THEN** 导出仅包含创建时可见预警,即使任务执行期间店铺归属发生变化
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
## 1. 数据与规则
|
|
||||||
|
|
||||||
- [ ] 1.1 追踪套餐使用有效态、真流量字段、资产/卡/设备关联、有效业务员、通知事件及异步导出调用链。
|
|
||||||
- [ ] 1.2 新增成对迁移、模型和约束:套餐唯一规则、预警快照、使用记录+阈值唯一去重、查询/导出索引。
|
|
||||||
- [ ] 1.3 实现规则 CRUD、1%~100% 校验、启停和审计;更新套餐管理 OpenAPI。
|
|
||||||
|
|
||||||
## 2. 扫描与读侧
|
|
||||||
|
|
||||||
- [ ] 2.1 实现可重跑扫描:按资产汇总当前有效套餐真流量、选择主套餐规则、排除虚流量/失效记录,并原子创建预警与通知事件。
|
|
||||||
- [ ] 2.2 接入既有任务调度和通知投递,确保无有效业务员仍建预警、重复扫描不重复通知。
|
|
||||||
- [ ] 2.3 实现受资产数据范围保护的预警列表/详情和异步导出,冻结导出筛选与可见范围。
|
|
||||||
|
|
||||||
## 3. 验证
|
|
||||||
|
|
||||||
- [ ] 3.1 在隔离数据库验证迁移 up/down/up、规则变化补建、有效套餐汇总、重复扫描、插拔卡展示、通知接收人和导出权限。
|
|
||||||
- [ ] 3.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-package-real-usage-alerts --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
套餐商品 `tb_package.real_data_mb` 是可变配置值(后台可编辑)。套餐使用记录 `tb_package_usage` 以 `data_usage_mb`(真已用)与 `data_limit_mb`(真总量快照,购买时写入、全仓无更新路径)承载真实用量;既有 H5、后台资产详情、套餐历史、客户视图、代理开放接口与卡导出一律以使用记录快照展示真额度。现有通知以事件投递并按接收人隔离,后台通知无类型白名单,但新增通知类型必须先注册模板定义,否则渲染失败、通知写不进库。资产数据范围对超级管理员与平台用户为空操作。预警必须是套餐级观测,不能复用运营商通道阈值停机锁。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- 建立套餐真流量规则、预警事实、通知与可追溯导出。
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- 不建停机锁、不调用运营商、不做停复机、不读取通道计费周期与通道累计流量。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 额度与用量口径
|
||||||
|
|
||||||
|
- 分子取 `tb_package_usage.data_usage_mb`(真已用),由卡网关读数增量经既有扣减链路写入。
|
||||||
|
- 分母取 `tb_package_usage.data_limit_mb`(真总量快照)。该字段与 H5、后台资产详情、套餐历史、客户视图、代理开放接口与卡导出的既有展示一致;使用记录快照不可变,历史可复现。
|
||||||
|
- 商品 `real_data_mb` 仅作为规则启用时的配置合法性校验(必须大于零),不作为汇总分母。
|
||||||
|
- 禁止读取虚流量(`virtual_total_mb_snapshot` / `enable_virtual_data_snapshot`)、展示量(`display_gain_ratio_snapshot` 放大结果)、卡级累计(`tb_iot_card.data_usage_mb`)与运营商通道累计值(`last_gateway_reading_mb`、`current_month_usage_mb`)。
|
||||||
|
|
||||||
|
### 有效套餐集合与主套餐
|
||||||
|
|
||||||
|
- 按资产(`iot_card_id` 或 `device_id`,二者互斥非零)汇总当前有效套餐使用记录,含加油包:`status IN (1,2) AND refund_id IS NULL`。
|
||||||
|
- 排除 0-待生效、3-已过期、4-已失效。
|
||||||
|
- 判定只依据 `status`,不得仅用 `expires_at` 判断过期(过期与失效由异步任务改状态,`expires_at` 在生效中状态下可能已到期)。
|
||||||
|
- 主套餐(阈值来源)为 `master_usage_id IS NULL`;多条时按 `priority ASC, activated_at ASC, id ASC` 取第一条,与既有当前主套餐查询一致。
|
||||||
|
- 主套餐无有效规则、总额度不大于零或比例未达阈值时跳过。
|
||||||
|
|
||||||
|
### 汇总、去重与补建
|
||||||
|
|
||||||
|
- 唯一键为 `(package_usage_id, threshold_percent_snapshot)`。
|
||||||
|
- 降阈值后同一使用记录产生新阈值快照的预警属预期补建,不是重复;升阈值或停用不修改既有预警快照。
|
||||||
|
- 唯一冲突视为已处理,不重复投递通知。
|
||||||
|
- 实际消耗流量的使用记录所关联资产是权威归属;插拔卡同时展示卡与当前关联设备,不汇总多张卡。
|
||||||
|
|
||||||
|
### 规则维护
|
||||||
|
|
||||||
|
- `POST /package-traffic-alert-rules`:仅超级管理员、平台用户;请求 `package_id`、`threshold_percent`(大于等于 1、小于等于 100,允许小数)、`enabled`、`remark`(最多 500 字符)。套餐必须存在且 `real_data_mb > 0`(配置合法性校验);同套餐已有规则返回“套餐已存在真流量预警规则”。
|
||||||
|
- `PUT /package-traffic-alert-rules/:id`:允许修改阈值、启停、备注;不修改已产生预警快照。停用后扫描不建新预警;启用或降低阈值后不主动回填,仅由下一次扫描按当前有效套餐判断。
|
||||||
|
- `GET /package-traffic-alert-rules` 返回套餐、真流量总额度、阈值、启用状态、备注和更新时间。所有成功写操作记录操作者、前后值和时间。
|
||||||
|
|
||||||
|
### 扫描与幂等
|
||||||
|
|
||||||
|
- Worker 只读取当前有效套餐使用记录(见上集合),按资产聚合真已用量与真总量快照。
|
||||||
|
- 调度形态照抄既有 `package_expiry_reminder`:`asynq.Scheduler` 以 `CRON_TZ=Asia/Shanghai` 注册,调度器仅在单例 Worker 角色创建。既有临期扫描不使用 `asynq.Unique`,也无启动补偿;本项同样不新增手工触发扫描入口。
|
||||||
|
- 幂等由事实唯一键(`(package_usage_id, threshold_percent_snapshot)`)与 Outbox 幂等追加(`OnConflict(event_id) DoNothing`)保证。
|
||||||
|
- 事实与事件在同一事务写入:预警、通知事件与审计同事务提交;唯一冲突视为已处理,不重复投递通知。
|
||||||
|
- 通知失败进入既有可靠投递恢复,不能删除预警或重新计算快照。
|
||||||
|
|
||||||
|
### 通知
|
||||||
|
|
||||||
|
- 类别沿用 `expiry`,不新增类别;新增通知类型 `package.traffic.alert`。
|
||||||
|
- 必须注册注册表 `Definition`,否则渲染失败、通知写不进库。
|
||||||
|
- 接收人新建「仅业务员」解析路径:`business_owner_account_id` 指向启用且 `user_type` 为平台的账号;不得复用返回「店铺 agent 账号 ∪ 业务员」的既有 resolver。
|
||||||
|
- 无有效业务员时只保存预警,不补发给未来业务员。
|
||||||
|
- 新增受控 `ref_type` 并在通知目标定义中注册,指向预警详情,不返回 URL。
|
||||||
|
- 幂等键内嵌使用记录与阈值快照。
|
||||||
|
|
||||||
|
### 列表、详情与导出
|
||||||
|
|
||||||
|
- `GET /package-traffic-alerts`:仅超级管理员、平台用户,先应用既有资产数据范围;支持套餐、店铺、业务员、资产/卡标识、阈值、触发时间和通知投递状态筛选、分页。返回冻结快照与通知结果,当前归属变化不得改写预警事实。
|
||||||
|
- `GET /package-traffic-alerts/:id`:同一数据范围校验后返回完整预警快照和通知投递历史;越权与不存在统一按既有资源不可见处理。
|
||||||
|
- `POST /package-traffic-alerts/export`:仅超级管理员、平台用户。复用既有异步导出任务机制(新增导出场景并复用导出任务创建服务),场景需在全部五处注册点落地(场景常量、`DataSource`、注册中心实例化、场景白名单、导出 DTO `oneof` 两处);创建时冻结操作者、筛选、时间范围与可见资产范围。既有通用导出入口不做场景级角色校验,故本项以受控入口暴露该场景。
|
||||||
|
- 数据范围使用导出侧的范围过滤(空范围拒绝),不得使用请求上下文版过滤(空范围语义相反)。
|
||||||
|
- 导出列:资产类型、资产标识、对应标识符、卡标识、设备类型、设备型号、套餐名称、真流量已用量、真流量额度、比例、阈值快照、到期时间、剩余天数、触发时间、店铺、业务员、用户组、通知投递结果;不含运营商通道列。
|
||||||
|
- 归属口径按 PRD §2.16 写死:导出中套餐、用量、总量、阈值与到期时间使用触发快照;店铺、业务员、用户组按导出执行时当前归属补充。预警行仍冻结店铺与业务员快照,供列表、详情与追溯;两者口径不同且不得混用。用户组不得写入店铺表,按既有实时推导。
|
||||||
|
|
||||||
|
### 数据范围与权限
|
||||||
|
|
||||||
|
- 列表、详情与导出仅超级管理员与平台用户。
|
||||||
|
- 先应用既有资产数据范围;当前对超管或平台无实际过滤,该要求保留为冻结语义与未来放开的前置。
|
||||||
|
- 越权与不存在统一按资源不可见处理。
|
||||||
|
- 导出范围在创建时冻结,执行期间归属变化不得扩大范围。
|
||||||
|
|
||||||
|
### 与通道阈值严格分离
|
||||||
|
|
||||||
|
- 不建停机锁、不调用运营商、不做停复机、不读取通道计费周期与通道累计流量,不触碰既有停复机服务与网关停机调用。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- 流量数据延迟 → 下次扫描补建,不回写已冻结预警。
|
||||||
|
- 多卡设备 → 以使用记录资产归属聚合,不从当前设备反推流量。
|
||||||
|
- 扫描并发 → 唯一索引处理同一命中重复创建。
|
||||||
|
- 分母取快照 → 商品改价后重跑,比例与预警不因商品当前值变化;代价是商品价与预警分母可能不同,属预期。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
新增成对迁移和索引;隔离环境验证规则启停/降阈值、有效套餐汇总、去重、无业务员、权限导出和 up/down/up。
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- 为每个套餐商品维护至多一条 1%~100% 真流量预警规则;规则变更只影响后续扫描,既有预警冻结快照。
|
- 为每个套餐商品维护至多一条 1%~100% 真流量预警规则;规则变更只影响后续扫描,既有预警冻结快照。
|
||||||
- 扫描同一资产全部当前有效套餐的真流量汇总,以实际消耗流量的套餐记录关联资产和主套餐规则判断达量。
|
- 扫描同一资产全部当前有效套餐的真流量汇总,分子取使用记录真已用量、分母取使用记录真总量快照,以实际消耗流量的套餐记录关联资产和主套餐规则判断达量;不读取虚流量、展示量、卡级累计或运营商通道累计值。
|
||||||
- 为同一套餐使用记录和阈值仅建一条预警,通知当时有效业务员,并提供权限受控列表与异步导出。
|
- 为同一套餐使用记录和阈值快照仅建一条预警,事务内同时写可靠通知事件,通知当时有效业务员,并提供权限受控列表与异步导出。
|
||||||
|
- 预警与运营商通道阈值严格分离:不建停机锁、不调用运营商、不做停复机。
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
@@ -24,4 +25,4 @@
|
|||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|
||||||
影响套餐配置、套餐使用/流量扫描任务、资产投影、通知事件、导出、审计和新增 Schema。
|
影响套餐配置、套餐使用/流量扫描任务、资产投影、通知事件、导出、审计和新增 Schema。
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
按套餐真实流量和当前有效套餐事实生成一次性达量预警,向资产所属店铺当时有效业务员投递可追溯通知,而不将通道级停复机控制或虚流量混入套餐预警。
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 真流量预警规则
|
||||||
|
|
||||||
|
系统 SHALL 为每个套餐商品维护至多一条当前真流量预警规则,阈值为 1% 至 100% 的小数百分比。规则启用、修改或降低阈值只影响后续扫描;既有预警 MUST 保留触发时的套餐、阈值、流量和资产快照。规则停用后停止创建新预警;重新启用或降低阈值后,下次扫描发现已有有效套餐达量时必须补建符合条件的预警。
|
||||||
|
|
||||||
|
#### Scenario: 降低阈值后补建
|
||||||
|
|
||||||
|
- **WHEN** 管理员降低一个启用规则的阈值,下一次扫描发现其有效套餐已达到新阈值
|
||||||
|
- **THEN** 系统创建预警并冻结新阈值,不修改既有预警快照
|
||||||
|
|
||||||
|
#### Scenario: 套餐无真流量额度不可启用
|
||||||
|
|
||||||
|
- **WHEN** 管理员为真流量商品额度不大于零的套餐启用规则
|
||||||
|
- **THEN** 系统拒绝并保持该套餐无有效规则
|
||||||
|
|
||||||
|
### Requirement: 有效套餐与汇总口径
|
||||||
|
|
||||||
|
系统 SHALL 以同一资产全部当前有效套餐的真流量汇总比例判断达量,比例分子为套餐使用记录真已用量,分母为套餐使用记录真总量快照,并使用该资产主套餐的规则。当前有效套餐 MUST 为 `status IN (1,2) AND refund_id IS NULL` 的使用记录,含加油包;系统 MUST 排除待生效、已过期与已失效记录,且判定过期 MUST 只依据状态,不得仅用到期时间。实际消耗流量的套餐使用记录所关联资产是权威归属;插拔卡时预警同时展示卡与当前关联设备,但 MUST NOT 汇总多张卡。虚流量、展示量、卡级累计与运营商通道累计值 MUST NOT 计入。主套餐 MUST 为 `master_usage_id IS NULL` 的使用记录,存在多条时按优先级、生效时间、编号依次取第一条。
|
||||||
|
|
||||||
|
#### Scenario: 多个有效套餐共同达量
|
||||||
|
|
||||||
|
- **WHEN** 某资产的多个当前有效套餐真流量汇总达到其主套餐规则阈值
|
||||||
|
- **THEN** 系统为命中套餐使用记录创建唯一预警,并只向扫描时该资产所属店铺的有效业务员投递通知
|
||||||
|
|
||||||
|
#### Scenario: 商品改价后重跑
|
||||||
|
|
||||||
|
- **WHEN** 管理员修改套餐商品的当前真流量配置值,同一使用记录被再次扫描
|
||||||
|
- **THEN** 汇总比例与预警结果不因商品当前值变化
|
||||||
|
|
||||||
|
#### Scenario: 已过期或已失效记录不计入
|
||||||
|
|
||||||
|
- **WHEN** 资产的某条套餐使用记录状态为已过期或已失效
|
||||||
|
- **THEN** 系统不将该记录的真已用量与真总量计入汇总
|
||||||
|
|
||||||
|
### Requirement: 去重与补建
|
||||||
|
|
||||||
|
系统 SHALL 以套餐使用记录和命中阈值快照的组合作为预警唯一键。同一组合 MUST 至多创建一条预警;唯一冲突 MUST 视为已处理且不重复投递通知。降低阈值后对同一使用记录产生新阈值快照的预警 MUST 视为预期补建,不受既有预警阻塞。
|
||||||
|
|
||||||
|
#### Scenario: 重复扫描
|
||||||
|
|
||||||
|
- **WHEN** 相同套餐使用记录和相同阈值被重复扫描命中
|
||||||
|
- **THEN** 系统保留原预警和通知,不创建重复记录
|
||||||
|
|
||||||
|
### Requirement: 预警通知
|
||||||
|
|
||||||
|
系统 SHALL 在预警创建事务内向资产所属店铺当时有效业务员创建站内通知。预警类别 MUST 沿用 `expiry`,并 MUST 注册通知类型 `package.traffic.alert` 的模板定义。有效业务员 MUST 为店铺 `business_owner_account_id` 指向的启用平台账号,系统 MUST NOT 向店铺代理账号补发。无有效业务员时系统 MUST 只保留预警,且 MUST NOT 补发给未来业务员。通知 MUST 幂等,幂等键 MUST 内嵌使用记录与阈值快照,且 MUST 注册指向预警详情的受控目标,不返回 URL。
|
||||||
|
|
||||||
|
#### Scenario: 存在有效业务员
|
||||||
|
|
||||||
|
- **WHEN** 资产所属店铺在扫描时存在有效业务员
|
||||||
|
- **THEN** 系统在同一事务创建预警与一条站内通知,通知在账户侧可见、未读数正确并可跳转受控目标
|
||||||
|
|
||||||
|
#### Scenario: 无有效业务员
|
||||||
|
|
||||||
|
- **WHEN** 资产所属店铺在扫描时无有效业务员
|
||||||
|
- **THEN** 系统只保存预警,不创建通知且不向未来业务员补发
|
||||||
|
|
||||||
|
### Requirement: 预警查询与导出
|
||||||
|
|
||||||
|
超级管理员和平台用户 SHALL 在既有资产数据范围内查询和导出预警;先应用既有资产数据范围,该范围当前对超级管理员与平台无实际过滤,保留为冻结语义与未来放开的前置。越权与不存在 MUST 统一按既有的资源不可见处理。导出 MUST 复用既有异步任务并在创建时冻结操作者、筛选、时间范围与可见资产范围,执行期间归属变化 MUST NOT 扩大范围,且 MUST 按触发时间筛选。列表、详情与导出 MUST 返回资产类型、资产标识、对应标识符、卡标识、设备类型、设备型号、套餐名称、真流量已用量、真流量额度、比例、阈值快照、到期时间、剩余天数、触发时间、店铺、业务员、用户组与通知投递结果,且 MUST NOT 包含运营商通道列。导出中套餐、用量、总量、阈值与到期时间 MUST 使用触发快照,店铺、业务员与用户组 MUST 按执行时当前归属补充;预警行仍冻结店铺与业务员快照供列表、详情与追溯,两种口径 MUST NOT 混用;用户组 MUST NOT 写入店铺表,按既有实时推导。
|
||||||
|
|
||||||
|
#### Scenario: 受限导出
|
||||||
|
|
||||||
|
- **WHEN** 平台用户在其资产数据范围内创建预警导出
|
||||||
|
- **THEN** 导出仅包含创建时可见预警,即使任务执行期间店铺归属发生变化
|
||||||
|
|
||||||
|
#### Scenario: 归属变更后导出
|
||||||
|
|
||||||
|
- **WHEN** 预警记录创建后资产所属店铺或业务员变更,再执行已创建导出任务
|
||||||
|
- **THEN** 套餐、用量、总量、阈值与到期时间仍使用触发快照,店铺、业务员与用户组使用执行时当前归属,且不超出任务创建时冻结的可见范围
|
||||||
|
|
||||||
|
#### Scenario: 越权或不存在
|
||||||
|
|
||||||
|
- **WHEN** 调用者读取不在其可见范围内的预警详情
|
||||||
|
- **THEN** 系统按资源不可见处理,不区分越权、不存在与已删除
|
||||||
|
|
||||||
|
### Requirement: 与运营商通道阈值分离
|
||||||
|
|
||||||
|
系统 SHALL 将本能力与运营商通道阈值控制严格分离。达量预警 MUST NOT 创建停机锁、调用运营商、执行停复机,MUST NOT 读取通道计费周期或通道累计流量。
|
||||||
|
|
||||||
|
#### Scenario: 达量预警不触发通道动作
|
||||||
|
|
||||||
|
- **WHEN** 系统为某资产创建真流量达量预警
|
||||||
|
- **THEN** 系统不写入任何停机锁、不调用运营商接口且不改变卡停机状态
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## 1. 数据与规则
|
||||||
|
|
||||||
|
- [x] 1.1 追踪套餐使用有效态、真流量字段、资产/卡/设备关联、有效业务员、通知事件及异步导出调用链。
|
||||||
|
- [x] 1.2 新增成对迁移、模型和约束:套餐唯一规则、预警快照、使用记录+阈值快照唯一去重、查询/导出索引。
|
||||||
|
- [x] 1.3 实现规则 CRUD、1%~100% 校验(分母为使用记录真总量快照,规则启用校验商品真流量额度大于零)、启停和审计;更新套餐管理 OpenAPI。
|
||||||
|
|
||||||
|
## 2. 扫描与通知
|
||||||
|
|
||||||
|
- [x] 2.1 实现可重跑扫描:按资产汇总当前有效套餐(`status IN (1,2) AND refund_id IS NULL`)真流量,分子取使用记录真已用量、分母取使用记录真总量快照,排除虚流量/展示量/卡级累计/通道累计与失效记录。
|
||||||
|
- [x] 2.2 选择主套餐规则(`master_usage_id IS NULL`,多条按 `priority ASC, activated_at ASC, id ASC` 取第一条),主套餐无规则或总额度不大于零时跳过。
|
||||||
|
- [x] 2.3 原子创建预警(唯一键 `(package_usage_id, threshold_percent_snapshot)`)与通知事件;事实与事件同一事务,唯一冲突视为已处理。
|
||||||
|
- [x] 2.4 接入既有任务调度(照抄临期扫描的 Cron 加单例注册,不使用 `asynq.Unique`、不新增启动补偿、不新增手工触发入口)与既有通知投递。
|
||||||
|
- [x] 2.5 注册通知类型常量 `package.traffic.alert`、注册表 Definition(类别沿用 `expiry`)、仅业务员解析路径(`business_owner_account_id` 指向启用平台账号)与受控目标定义;无有效业务员只建预警、幂等键内嵌使用记录与阈值快照。
|
||||||
|
|
||||||
|
## 3. 读侧与导出
|
||||||
|
|
||||||
|
- [x] 3.1 实现受资产数据范围保护的预警列表/详情(仅超管与平台,越权与不存在统一不可见)。
|
||||||
|
- [x] 3.2 按 PRD §2.16 落地导出列:资产类型、资产标识、对应标识符、卡标识、设备类型、设备型号、套餐名称、真流量已用量、真流量额度、比例、阈值快照、到期时间、剩余天数、触发时间、店铺、业务员、用户组、通知投递结果(不含运营商通道列)。
|
||||||
|
- [x] 3.3 在五处注册点接入导出场景:场景常量、`DataSource` 实现、注册中心实例化、场景白名单、导出 DTO `oneof` 两处;表头在 dispatch 阶段冻结。
|
||||||
|
- [x] 3.4 导出使用导出侧范围过滤(空范围拒绝,不得用请求上下文版);创建时冻结操作者、筛选、时间范围与可见资产范围;流量与阈值用触发快照,店铺/业务员/用户组按执行时当前归属补充,用户组按既有实时推导。
|
||||||
|
|
||||||
|
## 4. 验证
|
||||||
|
|
||||||
|
- [x] 4.1 在隔离数据库验证迁移 up/down/up、规则启停/降阈值补建、有效套餐汇总、重复扫描、插拔卡展示、通知接收人和导出权限。
|
||||||
|
- [x] 4.2 验证商品改价后重跑比例与预警不因商品当前值变化;已过期或已失效记录不计入。
|
||||||
|
- [x] 4.3 验证唯一冲突不重复通知、无有效业务员只建预警、通知在账户侧可见/未读正确/可跳转受控目标。
|
||||||
|
- [x] 4.4 验证导出归属为执行时当前归属、流量与阈值为触发快照且不超创建时范围;越权与不存在统一不可见;数据范围冻结语义成立。
|
||||||
|
- [x] 4.5 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-package-real-usage-alerts --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。
|
||||||
95
openspec/specs/package-traffic-alert/spec.md
Normal file
95
openspec/specs/package-traffic-alert/spec.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# package-traffic-alert Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
按套餐真实流量和当前有效套餐事实生成一次性达量预警,向资产所属店铺当时有效业务员投递可追溯通知,而不将通道级停复机控制或虚流量混入套餐预警。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 真流量预警规则
|
||||||
|
|
||||||
|
系统 SHALL 为每个套餐商品维护至多一条当前真流量预警规则,阈值为 1% 至 100% 的小数百分比。规则启用、修改或降低阈值只影响后续扫描;既有预警 MUST 保留触发时的套餐、阈值、流量和资产快照。规则停用后停止创建新预警;重新启用或降低阈值后,下次扫描发现已有有效套餐达量时必须补建符合条件的预警。
|
||||||
|
|
||||||
|
#### Scenario: 降低阈值后补建
|
||||||
|
|
||||||
|
- **WHEN** 管理员降低一个启用规则的阈值,下一次扫描发现其有效套餐已达到新阈值
|
||||||
|
- **THEN** 系统创建预警并冻结新阈值,不修改既有预警快照
|
||||||
|
|
||||||
|
#### Scenario: 套餐无真流量额度不可启用
|
||||||
|
|
||||||
|
- **WHEN** 管理员为真流量商品额度不大于零的套餐启用规则
|
||||||
|
- **THEN** 系统拒绝并保持该套餐无有效规则
|
||||||
|
|
||||||
|
### Requirement: 有效套餐与汇总口径
|
||||||
|
|
||||||
|
系统 SHALL 以同一资产全部当前有效套餐的真流量汇总比例判断达量,比例分子为套餐使用记录真已用量,分母为套餐使用记录真总量快照,并使用该资产主套餐的规则。当前有效套餐 MUST 为 `status IN (1,2) AND refund_id IS NULL` 的使用记录,含加油包;系统 MUST 排除待生效、已过期与已失效记录,且判定过期 MUST 只依据状态,不得仅用到期时间。实际消耗流量的套餐使用记录所关联资产是权威归属;插拔卡时预警同时展示卡与当前关联设备,但 MUST NOT 汇总多张卡。虚流量、展示量、卡级累计与运营商通道累计值 MUST NOT 计入。主套餐 MUST 为 `master_usage_id IS NULL` 的使用记录,存在多条时按优先级、生效时间、编号依次取第一条。
|
||||||
|
|
||||||
|
#### Scenario: 多个有效套餐共同达量
|
||||||
|
|
||||||
|
- **WHEN** 某资产的多个当前有效套餐真流量汇总达到其主套餐规则阈值
|
||||||
|
- **THEN** 系统为命中套餐使用记录创建唯一预警,并只向扫描时该资产所属店铺的有效业务员投递通知
|
||||||
|
|
||||||
|
#### Scenario: 商品改价后重跑
|
||||||
|
|
||||||
|
- **WHEN** 管理员修改套餐商品的当前真流量配置值,同一使用记录被再次扫描
|
||||||
|
- **THEN** 汇总比例与预警结果不因商品当前值变化
|
||||||
|
|
||||||
|
#### Scenario: 已过期或已失效记录不计入
|
||||||
|
|
||||||
|
- **WHEN** 资产的某条套餐使用记录状态为已过期或已失效
|
||||||
|
- **THEN** 系统不将该记录的真已用量与真总量计入汇总
|
||||||
|
|
||||||
|
### Requirement: 去重与补建
|
||||||
|
|
||||||
|
系统 SHALL 以套餐使用记录和命中阈值快照的组合作为预警唯一键。同一组合 MUST 至多创建一条预警;唯一冲突 MUST 视为已处理且不重复投递通知。降低阈值后对同一使用记录产生新阈值快照的预警 MUST 视为预期补建,不受既有预警阻塞。
|
||||||
|
|
||||||
|
#### Scenario: 重复扫描
|
||||||
|
|
||||||
|
- **WHEN** 相同套餐使用记录和相同阈值被重复扫描命中
|
||||||
|
- **THEN** 系统保留原预警和通知,不创建重复记录
|
||||||
|
|
||||||
|
### Requirement: 预警通知
|
||||||
|
|
||||||
|
系统 SHALL 在预警创建事务内向资产所属店铺当时有效业务员创建站内通知。预警类别 MUST 沿用 `expiry`,并 MUST 注册通知类型 `package.traffic.alert` 的模板定义。有效业务员 MUST 为店铺 `business_owner_account_id` 指向的启用平台账号,系统 MUST NOT 向店铺代理账号补发。无有效业务员时系统 MUST 只保留预警,且 MUST NOT 补发给未来业务员。通知 MUST 幂等,幂等键 MUST 内嵌使用记录与阈值快照,且 MUST 注册指向预警详情的受控目标,不返回 URL。
|
||||||
|
|
||||||
|
#### Scenario: 存在有效业务员
|
||||||
|
|
||||||
|
- **WHEN** 资产所属店铺在扫描时存在有效业务员
|
||||||
|
- **THEN** 系统在同一事务创建预警与一条站内通知,通知在账户侧可见、未读数正确并可跳转受控目标
|
||||||
|
|
||||||
|
#### Scenario: 无有效业务员
|
||||||
|
|
||||||
|
- **WHEN** 资产所属店铺在扫描时无有效业务员
|
||||||
|
- **THEN** 系统只保存预警,不创建通知且不向未来业务员补发
|
||||||
|
|
||||||
|
### Requirement: 预警查询与导出
|
||||||
|
|
||||||
|
超级管理员和平台用户 SHALL 在既有资产数据范围内查询和导出预警;先应用既有资产数据范围,该范围当前对超级管理员与平台无实际过滤,保留为冻结语义与未来放开的前置。越权与不存在 MUST 统一按既有的资源不可见处理。导出 MUST 复用既有异步任务并在创建时冻结操作者、筛选、时间范围与可见资产范围,执行期间归属变化 MUST NOT 扩大范围,且 MUST 按触发时间筛选。列表、详情与导出 MUST 返回资产类型、资产标识、对应标识符、卡标识、设备类型、设备型号、套餐名称、真流量已用量、真流量额度、比例、阈值快照、到期时间、剩余天数、触发时间、店铺、业务员、用户组与通知投递结果,且 MUST NOT 包含运营商通道列。导出中套餐、用量、总量、阈值与到期时间 MUST 使用触发快照,店铺、业务员与用户组 MUST 按执行时当前归属补充;预警行仍冻结店铺与业务员快照供列表、详情与追溯,两种口径 MUST NOT 混用;用户组 MUST NOT 写入店铺表,按既有实时推导。
|
||||||
|
|
||||||
|
#### Scenario: 受限导出
|
||||||
|
|
||||||
|
- **WHEN** 平台用户在其资产数据范围内创建预警导出
|
||||||
|
- **THEN** 导出仅包含创建时可见预警,即使任务执行期间店铺归属发生变化
|
||||||
|
|
||||||
|
#### Scenario: 归属变更后导出
|
||||||
|
|
||||||
|
- **WHEN** 预警记录创建后资产所属店铺或业务员变更,再执行已创建导出任务
|
||||||
|
- **THEN** 套餐、用量、总量、阈值与到期时间仍使用触发快照,店铺、业务员与用户组使用执行时当前归属,且不超出任务创建时冻结的可见范围
|
||||||
|
|
||||||
|
#### Scenario: 越权或不存在
|
||||||
|
|
||||||
|
- **WHEN** 调用者读取不在其可见范围内的预警详情
|
||||||
|
- **THEN** 系统按资源不可见处理,不区分越权、不存在与已删除
|
||||||
|
|
||||||
|
### Requirement: 与运营商通道阈值分离
|
||||||
|
|
||||||
|
系统 SHALL 将本能力与运营商通道阈值控制严格分离。达量预警 MUST NOT 创建停机锁、调用运营商、执行停复机,MUST NOT 读取通道计费周期或通道累计流量。
|
||||||
|
|
||||||
|
#### Scenario: 达量预警不触发通道动作
|
||||||
|
|
||||||
|
- **WHEN** 系统为某资产创建真流量达量预警
|
||||||
|
- **THEN** 系统不写入任何停机锁、不调用运营商接口且不改变卡停机状态
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
接口:`GET /api/admin/package-traffic-alert-rules`(查询套餐真流量预警规则列表);`POST /api/admin/package-traffic-alert-rules`(创建套餐真流量预警规则);`PUT /api/admin/package-traffic-alert-rules/{id}`(修改套餐真流量预警规则);`GET /api/admin/package-traffic-alerts`(查询套餐真流量达量预警列表);`GET /api/admin/package-traffic-alerts/{id}`(查询套餐真流量达量预警详情);`POST /api/admin/package-traffic-alerts/export`(导出套餐真流量达量预警)。
|
||||||
@@ -546,6 +546,16 @@ const (
|
|||||||
AuditActionPhoneAssetUnbindImportTaskCreated = "phone_asset_unbind_import_task.create"
|
AuditActionPhoneAssetUnbindImportTaskCreated = "phone_asset_unbind_import_task.create"
|
||||||
// AuditActionPhoneAssetUnbindImportTaskCompleted 表示 CSV 解绑导入任务完成。
|
// AuditActionPhoneAssetUnbindImportTaskCompleted 表示 CSV 解绑导入任务完成。
|
||||||
AuditActionPhoneAssetUnbindImportTaskCompleted = "phone_asset_unbind_import_task.complete"
|
AuditActionPhoneAssetUnbindImportTaskCompleted = "phone_asset_unbind_import_task.complete"
|
||||||
|
// AuditActionPackageTrafficAlertRuleCreated 表示创建套餐真流量预警规则。
|
||||||
|
AuditActionPackageTrafficAlertRuleCreated = "package_traffic_alert_rule.create"
|
||||||
|
// AuditActionPackageTrafficAlertRuleUpdated 表示修改套餐真流量预警规则阈值或备注。
|
||||||
|
AuditActionPackageTrafficAlertRuleUpdated = "package_traffic_alert_rule.update"
|
||||||
|
// AuditActionPackageTrafficAlertRuleEnabled 表示启用套餐真流量预警规则。
|
||||||
|
AuditActionPackageTrafficAlertRuleEnabled = "package_traffic_alert_rule.enable"
|
||||||
|
// AuditActionPackageTrafficAlertRuleDisabled 表示停用套餐真流量预警规则。
|
||||||
|
AuditActionPackageTrafficAlertRuleDisabled = "package_traffic_alert_rule.disable"
|
||||||
|
// AuditActionPackageTrafficAlertTriggered 表示扫描命中阈值时创建套餐真流量达量预警事实。
|
||||||
|
AuditActionPackageTrafficAlertTriggered = "package_traffic_alert.trigger"
|
||||||
// AuditActionNotificationDelivered 表示 Outbox 消费后实际生成站内通知。
|
// AuditActionNotificationDelivered 表示 Outbox 消费后实际生成站内通知。
|
||||||
AuditActionNotificationDelivered = "notification.deliver"
|
AuditActionNotificationDelivered = "notification.deliver"
|
||||||
// AuditActionNotificationRead 表示单条通知首次标记已读。
|
// AuditActionNotificationRead 表示单条通知首次标记已读。
|
||||||
@@ -703,6 +713,10 @@ const (
|
|||||||
AuditResourcePhoneAssetAssociation = "phone_asset_association"
|
AuditResourcePhoneAssetAssociation = "phone_asset_association"
|
||||||
// AuditResourcePhoneAssetUnbindImportTask 表示手机号—资产 CSV 解绑导入任务资源。
|
// AuditResourcePhoneAssetUnbindImportTask 表示手机号—资产 CSV 解绑导入任务资源。
|
||||||
AuditResourcePhoneAssetUnbindImportTask = "phone_asset_unbind_import_task"
|
AuditResourcePhoneAssetUnbindImportTask = "phone_asset_unbind_import_task"
|
||||||
|
// AuditResourcePackageTrafficAlertRule 表示套餐真流量预警规则资源。
|
||||||
|
AuditResourcePackageTrafficAlertRule = "package_traffic_alert_rule"
|
||||||
|
// AuditResourcePackageTrafficAlert 表示套餐真流量达量预警事实资源。
|
||||||
|
AuditResourcePackageTrafficAlert = "package_traffic_alert"
|
||||||
// AuditResourceNotification 表示站内通知资源。
|
// AuditResourceNotification 表示站内通知资源。
|
||||||
AuditResourceNotification = "notification"
|
AuditResourceNotification = "notification"
|
||||||
// AuditResourceNotificationReadBatch 表示通知批量已读资源。
|
// AuditResourceNotificationReadBatch 表示通知批量已读资源。
|
||||||
@@ -845,6 +859,10 @@ const (
|
|||||||
AuditResourceRoleBusinessUserGroupTarget = "business_user_group_target"
|
AuditResourceRoleBusinessUserGroupTarget = "business_user_group_target"
|
||||||
// AuditResourceRoleShopBusinessOwnerBatch 表示店铺负责人批量交接的批次根资源。
|
// AuditResourceRoleShopBusinessOwnerBatch 表示店铺负责人批量交接的批次根资源。
|
||||||
AuditResourceRoleShopBusinessOwnerBatch = "shop_business_owner_batch"
|
AuditResourceRoleShopBusinessOwnerBatch = "shop_business_owner_batch"
|
||||||
|
// AuditResourceRolePackageTrafficAlertRuleTarget 表示套餐真流量预警规则资源。
|
||||||
|
AuditResourceRolePackageTrafficAlertRuleTarget = "package_traffic_alert_rule_target"
|
||||||
|
// AuditResourceRolePackageTrafficAlertTarget 表示套餐真流量达量预警事实资源。
|
||||||
|
AuditResourceRolePackageTrafficAlertTarget = "package_traffic_alert_target"
|
||||||
// AuditResourceRoleRetentionMonth 表示留存清理目标自然月。
|
// AuditResourceRoleRetentionMonth 表示留存清理目标自然月。
|
||||||
AuditResourceRoleRetentionMonth = "retention_month"
|
AuditResourceRoleRetentionMonth = "retention_month"
|
||||||
// AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。
|
// AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const (
|
|||||||
TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单
|
TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单
|
||||||
TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询
|
TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询
|
||||||
TaskTypeRefundCommissionRecovery = "refund:commission:recovery" // 退款佣金回溯后处理补偿
|
TaskTypeRefundCommissionRecovery = "refund:commission:recovery" // 退款佣金回溯后处理补偿
|
||||||
|
TaskTypePackageTrafficAlertScan = "package:traffic:alert:scan" // 每日套餐真流量达量预警扫描
|
||||||
|
|
||||||
// 运营商通道流量阈值任务类型(由 Asynq Scheduler 调度)
|
// 运营商通道流量阈值任务类型(由 Asynq Scheduler 调度)
|
||||||
TaskTypeCarrierThresholdCycle = "carrier_threshold:cycle" // 通道阈值周期处理:跨期解锁与条件复机
|
TaskTypeCarrierThresholdCycle = "carrier_threshold:cycle" // 通道阈值周期处理:跨期解锁与条件复机
|
||||||
@@ -311,6 +312,9 @@ func QueueForTaskType(taskType string) string {
|
|||||||
return QueueDataCleanup
|
return QueueDataCleanup
|
||||||
case TaskTypePackageExpiryReminder:
|
case TaskTypePackageExpiryReminder:
|
||||||
return QueueDataCleanup
|
return QueueDataCleanup
|
||||||
|
case TaskTypePackageTrafficAlertScan:
|
||||||
|
// 与套餐临期扫描同队列:轻量只读扫描,复用既有已监听队列,不新增队列与权重。
|
||||||
|
return QueueDataCleanup
|
||||||
case TaskTypeDailyTrafficFlush:
|
case TaskTypeDailyTrafficFlush:
|
||||||
return QueueDailyTrafficFlush
|
return QueueDailyTrafficFlush
|
||||||
case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeAuditDailyRetention:
|
case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeAuditDailyRetention:
|
||||||
@@ -386,6 +390,8 @@ const (
|
|||||||
ExportTaskSceneExchange = "exchange"
|
ExportTaskSceneExchange = "exchange"
|
||||||
// ExportTaskSceneCommissionRecord 表示佣金明细导出场景,原佣金与回溯明细各占一行。
|
// ExportTaskSceneCommissionRecord 表示佣金明细导出场景,原佣金与回溯明细各占一行。
|
||||||
ExportTaskSceneCommissionRecord = "commission_record"
|
ExportTaskSceneCommissionRecord = "commission_record"
|
||||||
|
// ExportTaskScenePackageTrafficAlert 表示套餐真流量达量预警导出场景,一行对应一条预警记录。
|
||||||
|
ExportTaskScenePackageTrafficAlert = "package_traffic_alert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 导出文件格式常量
|
// 导出文件格式常量
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const (
|
|||||||
NotificationTypeH5PopupRiskExchange = "h5.popup.risk_exchange"
|
NotificationTypeH5PopupRiskExchange = "h5.popup.risk_exchange"
|
||||||
// NotificationTypeH5PopupOperation 表示 H5 运营弹窗投放。
|
// NotificationTypeH5PopupOperation 表示 H5 运营弹窗投放。
|
||||||
NotificationTypeH5PopupOperation = "h5.popup.operation"
|
NotificationTypeH5PopupOperation = "h5.popup.operation"
|
||||||
|
// NotificationTypePackageTrafficAlert 表示套餐真流量达量预警。
|
||||||
|
NotificationTypePackageTrafficAlert = "package.traffic.alert"
|
||||||
|
|
||||||
// NotificationRefTypeSystemConfig 表示系统配置资源引用。
|
// NotificationRefTypeSystemConfig 表示系统配置资源引用。
|
||||||
NotificationRefTypeSystemConfig = "system_config"
|
NotificationRefTypeSystemConfig = "system_config"
|
||||||
@@ -71,6 +73,8 @@ const (
|
|||||||
NotificationRefTypeShopFund = "shop_fund"
|
NotificationRefTypeShopFund = "shop_fund"
|
||||||
// NotificationRefTypeCardSync 表示卡同步外部集成资源引用。
|
// NotificationRefTypeCardSync 表示卡同步外部集成资源引用。
|
||||||
NotificationRefTypeCardSync = "card_sync"
|
NotificationRefTypeCardSync = "card_sync"
|
||||||
|
// NotificationRefTypePackageTrafficAlert 表示套餐真流量达量预警详情资源引用。
|
||||||
|
NotificationRefTypePackageTrafficAlert = "package_traffic_alert"
|
||||||
|
|
||||||
// NotificationTargetTypeRefundDetail 表示退款详情前端目标。
|
// NotificationTargetTypeRefundDetail 表示退款详情前端目标。
|
||||||
NotificationTargetTypeRefundDetail = "refund_detail"
|
NotificationTargetTypeRefundDetail = "refund_detail"
|
||||||
@@ -90,6 +94,8 @@ const (
|
|||||||
NotificationTargetTypeIntegrationLog = "integration_log"
|
NotificationTargetTypeIntegrationLog = "integration_log"
|
||||||
// NotificationTargetTypeSystemConfig 表示受控系统配置前端目标。
|
// NotificationTargetTypeSystemConfig 表示受控系统配置前端目标。
|
||||||
NotificationTargetTypeSystemConfig = "system_config"
|
NotificationTargetTypeSystemConfig = "system_config"
|
||||||
|
// NotificationTargetTypePackageTrafficAlertDetail 表示套餐真流量达量预警详情前端目标。
|
||||||
|
NotificationTargetTypePackageTrafficAlertDetail = "package_traffic_alert_detail"
|
||||||
|
|
||||||
// OutboxEventTypeAdminDirectNotification 表示向明确后台账号投递通知的稳定事件类型。
|
// OutboxEventTypeAdminDirectNotification 表示向明确后台账号投递通知的稳定事件类型。
|
||||||
OutboxEventTypeAdminDirectNotification = "notification.admin.direct.requested"
|
OutboxEventTypeAdminDirectNotification = "notification.admin.direct.requested"
|
||||||
|
|||||||
86
pkg/constants/package_traffic_alert.go
Normal file
86
pkg/constants/package_traffic_alert.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// 套餐真流量达量预警的通知投递结果常量。
|
||||||
|
//
|
||||||
|
// 该结果不落库为独立列,而是由「预警行的可靠通知事件 + tb_outbox_event 状态 + tb_notification 事实」
|
||||||
|
// 交叉推导,因此枚举值一旦发布不得调整含义。1~4 表示已写入通知事件后的四种结果,
|
||||||
|
// 5 表示触发时店铺没有有效业务员,预警只保存事实不产生通知,也不在未来补发。
|
||||||
|
const (
|
||||||
|
// PackageTrafficAlertNotifyNotified 表示通知事件已生成站内通知,接收人可读。
|
||||||
|
PackageTrafficAlertNotifyNotified = 1
|
||||||
|
// PackageTrafficAlertNotifyPending 表示通知事件已写入但尚未完成投递。
|
||||||
|
PackageTrafficAlertNotifyPending = 2
|
||||||
|
// PackageTrafficAlertNotifyFailed 表示通知事件投递达到最终失败或等待人工重放。
|
||||||
|
PackageTrafficAlertNotifyFailed = 3
|
||||||
|
// PackageTrafficAlertNotifyRecipientGone 表示事件已投递但接收人已失效,未生成站内通知。
|
||||||
|
PackageTrafficAlertNotifyRecipientGone = 4
|
||||||
|
// PackageTrafficAlertNotifyNoBusinessOwner 表示触发时店铺无有效业务员,只保存预警。
|
||||||
|
PackageTrafficAlertNotifyNoBusinessOwner = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPackageTrafficAlertNotifyStatusName 返回通知投递结果的中文名称。
|
||||||
|
func GetPackageTrafficAlertNotifyStatusName(status int) string {
|
||||||
|
switch status {
|
||||||
|
case PackageTrafficAlertNotifyNotified:
|
||||||
|
return "已通知"
|
||||||
|
case PackageTrafficAlertNotifyPending:
|
||||||
|
return "待投递"
|
||||||
|
case PackageTrafficAlertNotifyFailed:
|
||||||
|
return "投递失败"
|
||||||
|
case PackageTrafficAlertNotifyRecipientGone:
|
||||||
|
return "未通知(接收人已失效)"
|
||||||
|
case PackageTrafficAlertNotifyNoBusinessOwner:
|
||||||
|
return "未通知(无有效业务员)"
|
||||||
|
default:
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidPackageTrafficAlertNotifyStatus 判断通知投递结果枚举是否受支持。
|
||||||
|
func IsValidPackageTrafficAlertNotifyStatus(status int) bool {
|
||||||
|
switch status {
|
||||||
|
case PackageTrafficAlertNotifyNotified,
|
||||||
|
PackageTrafficAlertNotifyPending,
|
||||||
|
PackageTrafficAlertNotifyFailed,
|
||||||
|
PackageTrafficAlertNotifyRecipientGone,
|
||||||
|
PackageTrafficAlertNotifyNoBusinessOwner:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePackageTrafficAlertNotifyStatus 由通知事件、Outbox 状态与站内通知事实推导投递结果。
|
||||||
|
// 判断顺序必须与读侧列表、详情与导出保持完全一致,因此该口径只在此处实现一次。
|
||||||
|
// - hasEvent=false:触发时无有效业务员,只保存预警;
|
||||||
|
// - hasNotification=true:通知已生成,接收人可读;
|
||||||
|
// - Outbox 为最终失败/已投递但无通知/其余未终结状态,分别对应失败、接收人已失效与待投递。
|
||||||
|
func ResolvePackageTrafficAlertNotifyStatus(hasEvent bool, outboxStatus *int, hasNotification bool) int {
|
||||||
|
if !hasEvent {
|
||||||
|
return PackageTrafficAlertNotifyNoBusinessOwner
|
||||||
|
}
|
||||||
|
if hasNotification {
|
||||||
|
return PackageTrafficAlertNotifyNotified
|
||||||
|
}
|
||||||
|
if outboxStatus == nil {
|
||||||
|
return PackageTrafficAlertNotifyPending
|
||||||
|
}
|
||||||
|
switch *outboxStatus {
|
||||||
|
case OutboxStatusFailed:
|
||||||
|
return PackageTrafficAlertNotifyFailed
|
||||||
|
case OutboxStatusDelivered:
|
||||||
|
// 事件已投递却没有生成通知:接收人在投递期已失效;本能力不允许改投他人。
|
||||||
|
return PackageTrafficAlertNotifyRecipientGone
|
||||||
|
default:
|
||||||
|
return PackageTrafficAlertNotifyPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageTrafficAlertRuleMaxRemarkLength 是预警规则备注的最大长度。
|
||||||
|
const PackageTrafficAlertRuleMaxRemarkLength = 500
|
||||||
|
|
||||||
|
// PackageTrafficAlertScanAggregateType 与 Outbox 事件的聚合类型,标识事件由达量扫描产生。
|
||||||
|
const PackageTrafficAlertScanAggregateType = "package_traffic_alert"
|
||||||
|
|
||||||
|
// PackageTrafficAlertEventIDPrefix 是可靠通知事件ID前缀:pta:<package_usage_id>:<阈值万分比>。
|
||||||
|
const PackageTrafficAlertEventIDPrefix = "pta"
|
||||||
@@ -81,6 +81,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil),
|
BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil),
|
||||||
ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil),
|
ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil),
|
||||||
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil),
|
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil),
|
||||||
|
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil),
|
||||||
ClientWechat: app.NewClientWechatHandler(nil, nil, nil),
|
ClientWechat: app.NewClientWechatHandler(nil, nil, nil),
|
||||||
SuperAdmin: admin.NewSuperAdminHandler(nil),
|
SuperAdmin: admin.NewSuperAdminHandler(nil),
|
||||||
SystemConfig: admin.NewSystemConfigHandler(nil, nil),
|
SystemConfig: admin.NewSystemConfigHandler(nil, nil),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry"
|
packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry"
|
||||||
|
packageTrafficAlertApp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/exporter"
|
"github.com/break/junhong_cmp_fiber/internal/exporter"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||||
notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
||||||
packageExpiryInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packageexpiry"
|
packageExpiryInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packageexpiry"
|
||||||
|
packageTrafficAlertInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packagetrafficalert"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||||
@@ -87,6 +89,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
|
|||||||
h.registerDataCleanupHandler()
|
h.registerDataCleanupHandler()
|
||||||
h.registerNotificationCleanupHandler()
|
h.registerNotificationCleanupHandler()
|
||||||
h.registerPackageExpiryReminderHandler()
|
h.registerPackageExpiryReminderHandler()
|
||||||
|
h.registerPackageTrafficAlertScanHandler()
|
||||||
h.registerAutoPurchaseHandler()
|
h.registerAutoPurchaseHandler()
|
||||||
h.registerDailyTrafficFlushHandler()
|
h.registerDailyTrafficFlushHandler()
|
||||||
|
|
||||||
@@ -347,6 +350,20 @@ func (h *Handler) registerPackageExpiryReminderHandler() {
|
|||||||
h.logger.Info("注册每日套餐临期提醒扫描任务处理器", zap.String("task_type", constants.TaskTypePackageExpiryReminder))
|
h.logger.Info("注册每日套餐临期提醒扫描任务处理器", zap.String("task_type", constants.TaskTypePackageExpiryReminder))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerPackageTrafficAlertScanHandler() {
|
||||||
|
scanner := packageTrafficAlertInfra.NewScanner(h.db)
|
||||||
|
writer := packageTrafficAlertInfra.NewAlertWriter(
|
||||||
|
h.db,
|
||||||
|
postgres.NewPackageTrafficAlertStore(h.db),
|
||||||
|
outbox.NewRepository(),
|
||||||
|
audit.NewWriter(audit.NewRegistry(), nil),
|
||||||
|
)
|
||||||
|
service := packageTrafficAlertApp.NewScanService(scanner, writer, h.logger)
|
||||||
|
handler := task.NewPackageTrafficAlertScanHandler(service, h.logger)
|
||||||
|
h.mux.HandleFunc(constants.TaskTypePackageTrafficAlertScan, handler.Handle)
|
||||||
|
h.logger.Info("注册每日套餐真流量达量预警扫描任务处理器", zap.String("task_type", constants.TaskTypePackageTrafficAlertScan))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) registerAutoPurchaseHandler() {
|
func (h *Handler) registerAutoPurchaseHandler() {
|
||||||
autoPurchaseHandler := task.NewAutoPurchaseHandler(
|
autoPurchaseHandler := task.NewAutoPurchaseHandler(
|
||||||
h.db,
|
h.db,
|
||||||
|
|||||||
Reference in New Issue
Block a user