diff --git a/cmd/api/docs.go b/cmd/api/docs.go index dea6ef4..f2cd08a 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -31,6 +31,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) + // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 + handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 70b7853..b0f3650 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -40,6 +40,8 @@ func generateAdminDocs(outputPath string) error { handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) + // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 + handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 diff --git a/cmd/worker/main.go b/cmd/worker/main.go index db9bfda..adde008 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -917,6 +917,15 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna )); err != nil { 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( "0 2 * * *", asynq.NewTask( diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 934ce12..e70aa01 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -4288,6 +4288,72 @@ ], "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": "constants.TaskTypeCarrierThresholdCycle", @@ -4302,4 +4368,4 @@ "requirements": [], "classification": "route_index_or_infrastructure" } -] +] \ No newline at end of file diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index 5885517..e0d3a65 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3955,5 +3955,169 @@ ], "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: 不读取虚流量、展示量、卡级累计或运营商通道累计" + ] + } } -] +] \ No newline at end of file diff --git a/internal/application/packagetrafficalert/rule.go b/internal/application/packagetrafficalert/rule.go new file mode 100644 index 0000000..17607ef --- /dev/null +++ b/internal/application/packagetrafficalert/rule.go @@ -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 "停用" +} diff --git a/internal/application/packagetrafficalert/scan.go b/internal/application/packagetrafficalert/scan.go new file mode 100644 index 0000000..adc8117 --- /dev/null +++ b/internal/application/packagetrafficalert/scan.go @@ -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)) +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 489083b..d6fe060 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -35,6 +35,7 @@ import ( integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration" notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" + packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert" shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" @@ -185,6 +186,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit) popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit) popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB) + packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB) return &Handlers{ Auth: authHandler.NewHandler(svc.Auth, validate), @@ -282,6 +284,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries), Package: admin.NewPackageHandler(svc.Package), PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord), + PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate), ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation), ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing), ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 416e9d0..abb3975 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -13,6 +13,7 @@ import ( employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert" refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" @@ -115,6 +116,7 @@ type services struct { Package *packageSvc.Service PackageDailyRecord *packageSvc.DailyRecordService PackageCustomerView *packageSvc.CustomerViewService + PackageTrafficAlertRule *packagetrafficalertapp.RuleService ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service ShopSeriesGrant *shopSeriesGrantSvc.Service @@ -494,6 +496,7 @@ func initServices(s *stores, deps *Dependencies) *services { Package: packageService, PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger), PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger), + PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter), ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter), ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter), ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter), diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 2cadfe0..cf94ba5 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -77,6 +77,8 @@ type stores struct { PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore // 流量系统 CardDailyUsage *postgres.CardDailyUsageStore + // 套餐真流量预警规则与达量预警事实 + PackageTrafficAlert *postgres.PackageTrafficAlertStore // 资产标识符注册表 AssetIdentifier *postgres.AssetIdentifierStore } @@ -148,5 +150,6 @@ func initStores(deps *Dependencies) *stores { BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB), ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB), PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB), + PackageTrafficAlert: postgres.NewPackageTrafficAlertStore(deps.DB), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 4af4960..42f3e8b 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -81,6 +81,7 @@ type Handlers struct { BusinessUserGroup *admin.BusinessUserGroupHandler ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler PhoneAssetAssociation *admin.PhoneAssetAssociationHandler + PackageTrafficAlert *admin.PackageTrafficAlertHandler ClientWechat *app.ClientWechatHandler SuperAdmin *admin.SuperAdminHandler SystemConfig *admin.SystemConfigHandler diff --git a/internal/domain/packagetrafficalert/threshold.go b/internal/domain/packagetrafficalert/threshold.go new file mode 100644 index 0000000..f9abdf9 --- /dev/null +++ b/internal/domain/packagetrafficalert/threshold.go @@ -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 +} diff --git a/internal/exporter/package_traffic_alert_scene.go b/internal/exporter/package_traffic_alert_scene.go new file mode 100644 index 0000000..32173f3 --- /dev/null +++ b/internal/exporter/package_traffic_alert_scene.go @@ -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) +} diff --git a/internal/exporter/registry.go b/internal/exporter/registry.go index 394b894..266316e 100644 --- a/internal/exporter/registry.go +++ b/internal/exporter/registry.go @@ -37,6 +37,7 @@ func NewDefaultRegistry(db *gorm.DB) *Registry { NewRefundDataSource(db), NewExchangeDataSource(db), NewCommissionRecordDataSource(db), + NewPackageTrafficAlertDataSource(db), ) } @@ -73,7 +74,8 @@ func IsSupportedScene(scene string) bool { constants.ExportTaskSceneAgentRecharge, constants.ExportTaskSceneRefund, constants.ExportTaskSceneExchange, - constants.ExportTaskSceneCommissionRecord: + constants.ExportTaskSceneCommissionRecord, + constants.ExportTaskScenePackageTrafficAlert: return true default: return false diff --git a/internal/handler/admin/package_traffic_alert.go b/internal/handler/admin/package_traffic_alert.go new file mode 100644 index 0000000..d9502c6 --- /dev/null +++ b/internal/handler/admin/package_traffic_alert.go @@ -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 +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 9fc6205..d3a63f4 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -221,6 +221,11 @@ func NewRegistry() *Registry { businessUserGroupDisabled := businessUserGroupAction(constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup) businessUserGroupDeleted := businessUserGroupAction(constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", constants.AuditRiskHigh, constants.AuditResourceBusinessUserGroup) businessUserGroupMembersUpdated := businessUserGroupAction(constants.AuditActionBusinessUserGroupMembersUpdated, "批量维护业务用户组成员", constants.AuditRiskNormal, constants.AuditResourceAccount) + packageTrafficAlertRuleCreated := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleCreated, "创建套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI) + packageTrafficAlertRuleUpdated := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleUpdated, "更新套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI) + packageTrafficAlertRuleEnabled := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI) + packageTrafficAlertRuleDisabled := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlertRule, constants.AuditActorAccount, constants.AuditSourceAdminAPI) + packageTrafficAlertTriggered := packageTrafficAlertAction(constants.AuditActionPackageTrafficAlertTriggered, "创建套餐真流量达量预警", constants.AuditRiskNormal, constants.AuditResourcePackageTrafficAlert, constants.AuditActorSystemTask, constants.AuditSourceWorker) shopBusinessOwnerBatchUpdated := batchRootAction(constants.AuditActionShopBusinessOwnerBatchUpdated, "批量交接店铺负责人", constants.AuditResourceShopBusinessOwnerBatch) shopBusinessOwnerImported := taskAction(constants.AuditActionShopBusinessOwnerImported, "导入变更店铺负责人", constants.AuditResourceShop, constants.AuditActorSystemTask, constants.AuditSourceWorker) shopBusinessOwnerImportTaskCreated := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCreated, "创建店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) @@ -594,6 +599,11 @@ func NewRegistry() *Registry { constants.AuditActionBusinessUserGroupDisabled: businessUserGroupDisabled, constants.AuditActionBusinessUserGroupDeleted: businessUserGroupDeleted, constants.AuditActionBusinessUserGroupMembersUpdated: businessUserGroupMembersUpdated, + constants.AuditActionPackageTrafficAlertRuleCreated: packageTrafficAlertRuleCreated, + constants.AuditActionPackageTrafficAlertRuleUpdated: packageTrafficAlertRuleUpdated, + constants.AuditActionPackageTrafficAlertRuleEnabled: packageTrafficAlertRuleEnabled, + constants.AuditActionPackageTrafficAlertRuleDisabled: packageTrafficAlertRuleDisabled, + constants.AuditActionPackageTrafficAlertTriggered: packageTrafficAlertTriggered, constants.AuditActionShopBusinessOwnerBatchUpdated: shopBusinessOwnerBatchUpdated, constants.AuditActionShopBusinessOwnerImported: shopBusinessOwnerImported, constants.AuditActionShopBusinessOwnerImportTaskCreated: shopBusinessOwnerImportTaskCreated, @@ -841,6 +851,14 @@ func NewRegistry() *Registry { Type: constants.AuditResourcePhoneAssetUnbindImportTask, Name: "手机号资产解绑导入任务", IdentityFields: []string{"id", "task_no", "file_name"}, }, + constants.AuditResourcePackageTrafficAlertRule: { + Type: constants.AuditResourcePackageTrafficAlertRule, Name: "套餐真流量预警规则", + IdentityFields: []string{"id", "package_id", "threshold_percent", "enabled", "remark"}, + }, + constants.AuditResourcePackageTrafficAlert: { + Type: constants.AuditResourcePackageTrafficAlert, Name: "套餐真流量达量预警", + IdentityFields: []string{"id", "package_usage_id", "package_id", "asset_type", "asset_id", "threshold_percent_snapshot", "used_mb_snapshot", "limit_mb_snapshot", "usage_percent_snapshot", "shop_id", "business_owner_account_id"}, + }, constants.AuditResourceNotification: { Type: constants.AuditResourceNotification, Name: "站内通知", IdentityFields: []string{"id", "event_id", "recipient_kind", "recipient_id", "category", "type", "severity", "ref_type", "ref_id", "ref_key"}, @@ -1403,6 +1421,17 @@ func businessUserGroupAction(code, name, risk, primaryResource string) ActionDef } } +// packageTrafficAlertAction 定义套餐真流量预警规则维护与达量触发动作。 +// 规则维护由后台 API 写入;达量触发由扫描任务在业务事务内写入,两者通过 actor/source 区分。 +func packageTrafficAlertAction(code, name, risk, primaryResource, actor, source string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: risk, + PrimaryResource: primaryResource, AllowedActor: actor, Source: source, RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectInternalOnly, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, + } +} + // batchRootAction 定义同步后台批次根动作;子事件自带店铺或资源作用域。 func batchRootAction(code, name, primaryResource string) ActionDefinition { return ActionDefinition{ diff --git a/internal/infrastructure/notification/registry.go b/internal/infrastructure/notification/registry.go index 0174523..efb4faf 100644 --- a/internal/infrastructure/notification/registry.go +++ b/internal/infrastructure/notification/registry.go @@ -142,6 +142,21 @@ func NewRegistry() *Registry { constants.NotificationRefTypeAsset: {}, }, }, + // 套餐真流量达量预警:类别沿用 expiry,接收人只允许触发时冻结的平台业务员账号, + // 资源引用只指向预警详情,正文不含任何 URL 或前端路由。 + constants.NotificationTypePackageTrafficAlert: { + Type: constants.NotificationTypePackageTrafficAlert, Category: constants.NotificationCategoryExpiry, + Severity: constants.NotificationSeverityWarning, + TitleTemplate: "套餐真流量达量预警", + BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 真流量已用 {{.usage_percent}}%,达到预警阈值 {{.threshold_percent}}%。", + TemplateFields: map[string]struct{}{ + "asset_identifier": {}, "package_name": {}, "usage_percent": {}, "threshold_percent": {}, + }, + RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}}, + AllowedRefTypes: map[string]struct{}{ + constants.NotificationRefTypePackageTrafficAlert: {}, + }, + }, }} } diff --git a/internal/infrastructure/packagetrafficalert/scanner.go b/internal/infrastructure/packagetrafficalert/scanner.go new file mode 100644 index 0000000..774f1f9 --- /dev/null +++ b/internal/infrastructure/packagetrafficalert/scanner.go @@ -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 +} diff --git a/internal/infrastructure/packagetrafficalert/writer.go b/internal/infrastructure/packagetrafficalert/writer.go new file mode 100644 index 0000000..8869575 --- /dev/null +++ b/internal/infrastructure/packagetrafficalert/writer.go @@ -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 +} diff --git a/internal/model/dto/export_task_dto.go b/internal/model/dto/export_task_dto.go index fd7b665..5fa7109 100644 --- a/internal/model/dto/export_task_dto.go +++ b/internal/model/dto/export_task_dto.go @@ -4,7 +4,7 @@ import "time" // CreateExportTaskRequest 创建导出任务请求。 type CreateExportTaskRequest struct { - Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"` + Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"` Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"` Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象,可选)"` } @@ -22,7 +22,7 @@ type CreateExportTaskResponse struct { type ListExportTaskRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"` + Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"` Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"` EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"` @@ -33,7 +33,7 @@ type ExportTaskItem struct { ID uint `json:"id" description:"任务ID"` TaskID uint `json:"task_id" description:"任务ID"` TaskNo string `json:"task_no" description:"任务编号"` - Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细)"` + Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"` Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"` Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` StatusName string `json:"status_name" description:"任务状态名称(中文)"` diff --git a/internal/model/dto/notification_dto.go b/internal/model/dto/notification_dto.go index df8dab1..06b5119 100644 --- a/internal/model/dto/notification_dto.go +++ b/internal/model/dto/notification_dto.go @@ -11,7 +11,7 @@ type NotificationUnreadCountResponse struct { // NotificationListRequest 是后台通知基础分页参数。 type NotificationListRequest struct { Category string `json:"category" query:"category" validate:"omitempty,oneof=approval expiry sync system" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"` - Type string `json:"type" query:"type" validate:"omitempty,oneof=system.notice package.expiring agent.recharge.completed refund.completed exchange.shipping.created agent.main_wallet.low_balance h5.popup.risk_exchange h5.popup.operation" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗)"` + Type string `json:"type" query:"type" validate:"omitempty,oneof=system.notice package.expiring agent.recharge.completed refund.completed exchange.shipping.created agent.main_wallet.low_balance h5.popup.risk_exchange h5.popup.operation package.traffic.alert" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation,package.traffic.alert" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗, package.traffic.alert:套餐真流量达量预警)"` Severity string `json:"severity" query:"severity" validate:"omitempty,oneof=info warning error critical" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"` IsRead *bool `json:"is_read" query:"is_read" description:"已读状态;不传时查询全部"` Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1,最大 10000"` @@ -22,11 +22,11 @@ type NotificationListRequest struct { type NotificationItem struct { ID uint `json:"id" description:"通知ID"` Category string `json:"category" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"` - Type string `json:"type" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗)"` + Type string `json:"type" enums:"system.notice,package.expiring,agent.recharge.completed,refund.completed,exchange.shipping.created,agent.main_wallet.low_balance,h5.popup.risk_exchange,h5.popup.operation,package.traffic.alert" description:"稳定通知类型 (system.notice:系统通知, package.expiring:套餐临期, agent.recharge.completed:店铺充值入账, refund.completed:店铺退款完成, exchange.shipping.created:换货申请待处理, agent.main_wallet.low_balance:主钱包低余额, h5.popup.risk_exchange:风险换卡弹窗, h5.popup.operation:运营弹窗, package.traffic.alert:套餐真流量达量预警)"` Severity string `json:"severity" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"` Title string `json:"title" description:"纯文本标题"` Body string `json:"body" description:"纯文本正文"` - RefType string `json:"ref_type" description:"受控资源类型;可能为空。可选值及含义:system_config:系统配置, integration_log:外部集成日志, package:套餐, asset:C端资产, refund:退款, agent_recharge:代理充值, wecom_approval:企微审批, iot_card:物联网卡, device:设备, expiring_asset:临期资产列表, shop_fund:店铺资金概况, card_sync:卡同步记录。后台点击通知应调用目标解析接口,不得直接拼接路由"` + RefType string `json:"ref_type" description:"受控资源类型;可能为空。可选值及含义:system_config:系统配置, integration_log:外部集成日志, package:套餐, asset:C端资产, refund:退款, agent_recharge:代理充值, wecom_approval:企微审批, iot_card:物联网卡, device:设备, expiring_asset:临期资产列表, shop_fund:店铺资金概况, card_sync:卡同步记录, package_traffic_alert:套餐真流量达量预警。后台点击通知应调用目标解析接口,不得直接拼接路由"` RefID string `json:"ref_id" description:"受控资源数字ID的十进制字符串;可能为空。refund、agent_recharge、wecom_approval、iot_card、device、expiring_asset、shop_fund、asset 等类型使用;仅用于资源定位,不是前端URL"` RefKey string `json:"ref_key" description:"受控资源稳定Key或展示快照;可能为空。system_config 为配置Key,integration_log/card_sync 为集成标识,asset 为资产标识快照;仅用于定位或展示,不是前端URL"` IsRead bool `json:"is_read" description:"是否已读"` @@ -65,7 +65,7 @@ type NotificationReadResponse struct { // NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。 type NotificationTargetResponse struct { - TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转。可选值:refund_detail、agent_recharge_detail、wecom_approval_detail、iot_card_detail、device_detail、expiring_asset_list、shop_fund_summary、integration_log、system_config"` + TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转。可选值:refund_detail、agent_recharge_detail、wecom_approval_detail、iot_card_detail、device_detail、expiring_asset_list、shop_fund_summary、integration_log、system_config、package_traffic_alert_detail"` TargetID *uint `json:"target_id,omitempty" description:"ID型目标的业务主键;前端按 target_type 映射受控页面,不得自行拼接任意URL"` TargetKey string `json:"target_key,omitempty" description:"Key型目标的稳定定位值;仅用于 integration_log 或 system_config 等白名单目标"` Available bool `json:"available" description:"当前账号是否仍可访问目标;false 时只展示通知正文,不执行跳转"` diff --git a/internal/model/dto/package_traffic_alert_dto.go b/internal/model/dto/package_traffic_alert_dto.go new file mode 100644 index 0000000..8b52ac1 --- /dev/null +++ b/internal/model/dto/package_traffic_alert_dto.go @@ -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:未通知(无有效业务员))"` +} diff --git a/internal/model/package_traffic_alert.go b/internal/model/package_traffic_alert.go new file mode 100644 index 0000000..cacc266 --- /dev/null +++ b/internal/model/package_traffic_alert.go @@ -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" +} diff --git a/internal/query/notification/target.go b/internal/query/notification/target.go index 9656d2b..e11dd7b 100644 --- a/internal/query/notification/target.go +++ b/internal/query/notification/target.go @@ -76,9 +76,22 @@ func notificationTargetDefinitions() map[string]targetDefinition { constants.NotificationRefTypeIntegrationLog: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable}, constants.NotificationRefTypeCardSync: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable}, constants.NotificationRefTypeSystemConfig: {targetType: constants.NotificationTargetTypeSystemConfig, keyTarget: true, available: systemConfigTargetAvailable}, + // 套餐真流量达量预警:仅超级管理员与平台账号可见,目标为预警详情,不返回 URL。 + constants.NotificationRefTypePackageTrafficAlert: {targetType: constants.NotificationTargetTypePackageTrafficAlertDetail, idTarget: true, available: packageTrafficAlertTargetAvailable}, } } +// packageTrafficAlertTargetAvailable 复核当前账号是否仍可查看该预警详情。 +// 预警只对超级管理员与平台账号开放;其他身份的账号即使持有通知也不可跳转。 +func packageTrafficAlertTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return false, nil + } + query := q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}).Where("id = ?", *id) + return targetExists(query, "查询套餐真流量达量预警通知目标失败") +} + func parseNotificationTargetID(value string) (uint, bool) { parsed, err := strconv.ParseUint(value, 10, 64) if err != nil || parsed == 0 || parsed > math.MaxInt64 { diff --git a/internal/query/packagetrafficalert/query.go b/internal/query/packagetrafficalert/query.go new file mode 100644 index 0000000..7cf096f --- /dev/null +++ b/internal/query/packagetrafficalert/query.go @@ -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 +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index b49bc63..475c7a6 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -103,6 +103,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PackageUsage != nil { registerPackageUsageRoutes(authGroup, handlers.PackageUsage, doc, basePath) } + if handlers.PackageTrafficAlert != nil { + registerPackageTrafficAlertRoutes(authGroup, handlers.PackageTrafficAlert, doc, basePath) + } if handlers.ShopPackageBatchAllocation != nil { registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath) } diff --git a/internal/routes/package_traffic_alert.go b/internal/routes/package_traffic_alert.go new file mode 100644 index 0000000..b5f6f21 --- /dev/null +++ b/internal/routes/package_traffic_alert.go @@ -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, + }) +} diff --git a/internal/store/postgres/package_traffic_alert_store.go b/internal/store/postgres/package_traffic_alert_store.go new file mode 100644 index 0000000..f13f37d --- /dev/null +++ b/internal/store/postgres/package_traffic_alert_store.go @@ -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 +} diff --git a/internal/task/package_traffic_alert_scan.go b/internal/task/package_traffic_alert_scan.go new file mode 100644 index 0000000..d3fa2a0 --- /dev/null +++ b/internal/task/package_traffic_alert_scan.go @@ -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 +} diff --git a/migrations/000228_add_package_traffic_alert.down.sql b/migrations/000228_add_package_traffic_alert.down.sql new file mode 100644 index 0000000..524931b --- /dev/null +++ b/migrations/000228_add_package_traffic_alert.down.sql @@ -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; diff --git a/migrations/000228_add_package_traffic_alert.up.sql b/migrations/000228_add_package_traffic_alert.up.sql new file mode 100644 index 0000000..18b8f47 --- /dev/null +++ b/migrations/000228_add_package_traffic_alert.up.sql @@ -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 '套餐真流量达量扫描的有效套餐集合部分索引'; diff --git a/openspec/changes/add-package-real-usage-alerts/design.md b/openspec/changes/add-package-real-usage-alerts/design.md deleted file mode 100644 index f36904a..0000000 --- a/openspec/changes/add-package-real-usage-alerts/design.md +++ /dev/null @@ -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。 \ No newline at end of file diff --git a/openspec/changes/add-package-real-usage-alerts/specs/package-traffic-alert/spec.md b/openspec/changes/add-package-real-usage-alerts/specs/package-traffic-alert/spec.md deleted file mode 100644 index 2e4590f..0000000 --- a/openspec/changes/add-package-real-usage-alerts/specs/package-traffic-alert/spec.md +++ /dev/null @@ -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** 导出仅包含创建时可见预警,即使任务执行期间店铺归属发生变化 diff --git a/openspec/changes/add-package-real-usage-alerts/tasks.md b/openspec/changes/add-package-real-usage-alerts/tasks.md deleted file mode 100644 index 6570c15..0000000 --- a/openspec/changes/add-package-real-usage-alerts/tasks.md +++ /dev/null @@ -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。 \ No newline at end of file diff --git a/openspec/changes/add-package-real-usage-alerts/.openspec.yaml b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/.openspec.yaml similarity index 100% rename from openspec/changes/add-package-real-usage-alerts/.openspec.yaml rename to openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/design.md b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/design.md new file mode 100644 index 0000000..2f91cee --- /dev/null +++ b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/design.md @@ -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。 diff --git a/openspec/changes/add-package-real-usage-alerts/proposal.md b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/proposal.md similarity index 56% rename from openspec/changes/add-package-real-usage-alerts/proposal.md rename to openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/proposal.md index de70e82..4125a73 100644 --- a/openspec/changes/add-package-real-usage-alerts/proposal.md +++ b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/proposal.md @@ -9,8 +9,9 @@ ## What Changes - 为每个套餐商品维护至多一条 1%~100% 真流量预警规则;规则变更只影响后续扫描,既有预警冻结快照。 -- 扫描同一资产全部当前有效套餐的真流量汇总,以实际消耗流量的套餐记录关联资产和主套餐规则判断达量。 -- 为同一套餐使用记录和阈值仅建一条预警,通知当时有效业务员,并提供权限受控列表与异步导出。 +- 扫描同一资产全部当前有效套餐的真流量汇总,分子取使用记录真已用量、分母取使用记录真总量快照,以实际消耗流量的套餐记录关联资产和主套餐规则判断达量;不读取虚流量、展示量、卡级累计或运营商通道累计值。 +- 为同一套餐使用记录和阈值快照仅建一条预警,事务内同时写可靠通知事件,通知当时有效业务员,并提供权限受控列表与异步导出。 +- 预警与运营商通道阈值严格分离:不建停机锁、不调用运营商、不做停复机。 ## Capabilities @@ -24,4 +25,4 @@ ## Impact -影响套餐配置、套餐使用/流量扫描任务、资产投影、通知事件、导出、审计和新增 Schema。 \ No newline at end of file +影响套餐配置、套餐使用/流量扫描任务、资产投影、通知事件、导出、审计和新增 Schema。 diff --git a/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/specs/package-traffic-alert/spec.md b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/specs/package-traffic-alert/spec.md new file mode 100644 index 0000000..34ccd32 --- /dev/null +++ b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/specs/package-traffic-alert/spec.md @@ -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** 系统不写入任何停机锁、不调用运营商接口且不改变卡停机状态 diff --git a/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/tasks.md b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/tasks.md new file mode 100644 index 0000000..476c57d --- /dev/null +++ b/openspec/changes/archive/2026-09-16-add-package-real-usage-alerts/tasks.md @@ -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。 diff --git a/openspec/specs/package-traffic-alert/spec.md b/openspec/specs/package-traffic-alert/spec.md new file mode 100644 index 0000000..e79a2e4 --- /dev/null +++ b/openspec/specs/package-traffic-alert/spec.md @@ -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`(导出套餐真流量达量预警)。 \ No newline at end of file diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index bad870a..768084c 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -546,6 +546,16 @@ const ( AuditActionPhoneAssetUnbindImportTaskCreated = "phone_asset_unbind_import_task.create" // AuditActionPhoneAssetUnbindImportTaskCompleted 表示 CSV 解绑导入任务完成。 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 = "notification.deliver" // AuditActionNotificationRead 表示单条通知首次标记已读。 @@ -703,6 +713,10 @@ const ( AuditResourcePhoneAssetAssociation = "phone_asset_association" // AuditResourcePhoneAssetUnbindImportTask 表示手机号—资产 CSV 解绑导入任务资源。 AuditResourcePhoneAssetUnbindImportTask = "phone_asset_unbind_import_task" + // AuditResourcePackageTrafficAlertRule 表示套餐真流量预警规则资源。 + AuditResourcePackageTrafficAlertRule = "package_traffic_alert_rule" + // AuditResourcePackageTrafficAlert 表示套餐真流量达量预警事实资源。 + AuditResourcePackageTrafficAlert = "package_traffic_alert" // AuditResourceNotification 表示站内通知资源。 AuditResourceNotification = "notification" // AuditResourceNotificationReadBatch 表示通知批量已读资源。 @@ -845,6 +859,10 @@ const ( AuditResourceRoleBusinessUserGroupTarget = "business_user_group_target" // AuditResourceRoleShopBusinessOwnerBatch 表示店铺负责人批量交接的批次根资源。 AuditResourceRoleShopBusinessOwnerBatch = "shop_business_owner_batch" + // AuditResourceRolePackageTrafficAlertRuleTarget 表示套餐真流量预警规则资源。 + AuditResourceRolePackageTrafficAlertRuleTarget = "package_traffic_alert_rule_target" + // AuditResourceRolePackageTrafficAlertTarget 表示套餐真流量达量预警事实资源。 + AuditResourceRolePackageTrafficAlertTarget = "package_traffic_alert_target" // AuditResourceRoleRetentionMonth 表示留存清理目标自然月。 AuditResourceRoleRetentionMonth = "retention_month" // AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 0129dc1..485a263 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -96,6 +96,7 @@ const ( TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单 TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询 TaskTypeRefundCommissionRecovery = "refund:commission:recovery" // 退款佣金回溯后处理补偿 + TaskTypePackageTrafficAlertScan = "package:traffic:alert:scan" // 每日套餐真流量达量预警扫描 // 运营商通道流量阈值任务类型(由 Asynq Scheduler 调度) TaskTypeCarrierThresholdCycle = "carrier_threshold:cycle" // 通道阈值周期处理:跨期解锁与条件复机 @@ -311,6 +312,9 @@ func QueueForTaskType(taskType string) string { return QueueDataCleanup case TaskTypePackageExpiryReminder: return QueueDataCleanup + case TaskTypePackageTrafficAlertScan: + // 与套餐临期扫描同队列:轻量只读扫描,复用既有已监听队列,不新增队列与权重。 + return QueueDataCleanup case TaskTypeDailyTrafficFlush: return QueueDailyTrafficFlush case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeAuditDailyRetention: @@ -386,6 +390,8 @@ const ( ExportTaskSceneExchange = "exchange" // ExportTaskSceneCommissionRecord 表示佣金明细导出场景,原佣金与回溯明细各占一行。 ExportTaskSceneCommissionRecord = "commission_record" + // ExportTaskScenePackageTrafficAlert 表示套餐真流量达量预警导出场景,一行对应一条预警记录。 + ExportTaskScenePackageTrafficAlert = "package_traffic_alert" ) // 导出文件格式常量 diff --git a/pkg/constants/notification.go b/pkg/constants/notification.go index c3dd5d1..cac9c03 100644 --- a/pkg/constants/notification.go +++ b/pkg/constants/notification.go @@ -46,6 +46,8 @@ const ( NotificationTypeH5PopupRiskExchange = "h5.popup.risk_exchange" // NotificationTypeH5PopupOperation 表示 H5 运营弹窗投放。 NotificationTypeH5PopupOperation = "h5.popup.operation" + // NotificationTypePackageTrafficAlert 表示套餐真流量达量预警。 + NotificationTypePackageTrafficAlert = "package.traffic.alert" // NotificationRefTypeSystemConfig 表示系统配置资源引用。 NotificationRefTypeSystemConfig = "system_config" @@ -71,6 +73,8 @@ const ( NotificationRefTypeShopFund = "shop_fund" // NotificationRefTypeCardSync 表示卡同步外部集成资源引用。 NotificationRefTypeCardSync = "card_sync" + // NotificationRefTypePackageTrafficAlert 表示套餐真流量达量预警详情资源引用。 + NotificationRefTypePackageTrafficAlert = "package_traffic_alert" // NotificationTargetTypeRefundDetail 表示退款详情前端目标。 NotificationTargetTypeRefundDetail = "refund_detail" @@ -90,6 +94,8 @@ const ( NotificationTargetTypeIntegrationLog = "integration_log" // NotificationTargetTypeSystemConfig 表示受控系统配置前端目标。 NotificationTargetTypeSystemConfig = "system_config" + // NotificationTargetTypePackageTrafficAlertDetail 表示套餐真流量达量预警详情前端目标。 + NotificationTargetTypePackageTrafficAlertDetail = "package_traffic_alert_detail" // OutboxEventTypeAdminDirectNotification 表示向明确后台账号投递通知的稳定事件类型。 OutboxEventTypeAdminDirectNotification = "notification.admin.direct.requested" diff --git a/pkg/constants/package_traffic_alert.go b/pkg/constants/package_traffic_alert.go new file mode 100644 index 0000000..f17c22d --- /dev/null +++ b/pkg/constants/package_traffic_alert.go @@ -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::<阈值万分比>。 +const PackageTrafficAlertEventIDPrefix = "pta" diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index bb0992a..6782b7f 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -81,6 +81,7 @@ func BuildDocHandlers() *bootstrap.Handlers { BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil), ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil), PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil), + PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), SystemConfig: admin.NewSystemConfigHandler(nil, nil), diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 6307b93..dbbb342 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" 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/gateway" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" @@ -14,6 +15,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" 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" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" 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.registerNotificationCleanupHandler() h.registerPackageExpiryReminderHandler() + h.registerPackageTrafficAlertScanHandler() h.registerAutoPurchaseHandler() h.registerDailyTrafficFlushHandler() @@ -347,6 +350,20 @@ func (h *Handler) registerPackageExpiryReminderHandler() { 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() { autoPurchaseHandler := task.NewAutoPurchaseHandler( h.db,