feat: 迁移预警规则管理和历史分类查询
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/biz/system"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlertRuleNotFound = errors.NotFound("SW_ALERT_RULE_NOT_FOUND", "规则不存在")
|
||||
ErrAlertRuleScope = errors.Forbidden("SW_ALERT_RULE_SCOPE", "无权限操作该规则")
|
||||
ErrAlertRuleLevel = errors.Forbidden("SW_ALERT_RULE_LEVEL", "仅1级商务可操作预警规则")
|
||||
ErrAlertRuleInput = errors.BadRequest("SW_ALERT_RULE_INPUT", "预警规则参数不正确")
|
||||
ErrAlertRulePeriod = errors.BadRequest("SW_ALERT_RULE_PERIOD", "统计周期必须大于0")
|
||||
ErrAlertRuleDirection = errors.BadRequest("SW_ALERT_RULE_DIRECTION", "环比方向必须为上升或下降")
|
||||
ErrAlertRuleThreshold = errors.BadRequest("SW_ALERT_RULE_THRESHOLD", "环比阈值必须大于0且不超过9999")
|
||||
ErrAlertRuleUnlogin = errors.BadRequest("SW_ALERT_RULE_UNLOGIN", "未登录天数必须大于0")
|
||||
)
|
||||
|
||||
type LevelOneScope struct {
|
||||
All, Empty bool
|
||||
UserID uint
|
||||
}
|
||||
|
||||
func LevelOneOwner(actor system.Actor, a *Account) LevelOneScope {
|
||||
if actor.AuthorityID == 888 {
|
||||
return LevelOneScope{All: true}
|
||||
}
|
||||
if a == nil {
|
||||
return LevelOneScope{Empty: true}
|
||||
}
|
||||
if a.Level == 1 {
|
||||
return LevelOneScope{UserID: a.ID}
|
||||
}
|
||||
if a.Level < 2 || a.Level > 3 {
|
||||
return LevelOneScope{Empty: true}
|
||||
}
|
||||
parts := strings.Split(a.IDPath, ",")
|
||||
if len(parts) < 2 {
|
||||
return LevelOneScope{Empty: true}
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return LevelOneScope{Empty: true}
|
||||
}
|
||||
return LevelOneScope{UserID: uint(id)}
|
||||
}
|
||||
|
||||
type AlertRule struct {
|
||||
ID uint64
|
||||
Name, Type string
|
||||
Metric int
|
||||
StatPeriodDays, MomDirection, UnloginDays *int
|
||||
MomThreshold *string
|
||||
MonitorScope int
|
||||
WarningColor string
|
||||
CreatorID uint
|
||||
CreatorName string
|
||||
Enabled, IsDeleted int
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
type AlertRuleFilter struct {
|
||||
Name, Type string
|
||||
Metric *int
|
||||
}
|
||||
type AlertRuleRepo interface {
|
||||
List(context.Context, LevelOneScope, AlertRuleFilter, ...ListOption) ([]*AlertRule, int64, error)
|
||||
Find(context.Context, uint64) (*AlertRule, error)
|
||||
Create(context.Context, *AlertRule) error
|
||||
Update(context.Context, *AlertRule) error
|
||||
SetDeleted(context.Context, uint64) error
|
||||
SetEnabled(context.Context, uint64, int) error
|
||||
TypesFromRecords(context.Context, LevelOneScope) ([]string, error)
|
||||
}
|
||||
type AlertRuleUsecase struct {
|
||||
repo AlertRuleRepo
|
||||
accounts *AccountUsecase
|
||||
}
|
||||
|
||||
func NewAlertRuleUsecase(repo AlertRuleRepo, accounts *AccountUsecase) *AlertRuleUsecase {
|
||||
return &AlertRuleUsecase{repo: repo, accounts: accounts}
|
||||
}
|
||||
|
||||
func (uc *AlertRuleUsecase) List(ctx context.Context, filter AlertRuleFilter, options ...ListOption) ([]*AlertRule, int64, error) {
|
||||
actor, a, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return uc.repo.List(ctx, LevelOneOwner(actor, a), filter, options...)
|
||||
}
|
||||
func (uc *AlertRuleUsecase) Find(ctx context.Context, id uint64) (*AlertRule, error) {
|
||||
actor, a, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule, err := uc.repo.Find(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope := LevelOneOwner(actor, a)
|
||||
if !scope.All && (scope.Empty || scope.UserID != rule.CreatorID) {
|
||||
return nil, ErrAlertRuleScope
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
func (uc *AlertRuleUsecase) Types(ctx context.Context) ([]string, error) {
|
||||
actor, a, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := uc.repo.TypesFromRecords(ctx, LevelOneOwner(actor, a))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func validateAlertRule(r *AlertRule) error {
|
||||
if r == nil {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
r.Type = strings.TrimSpace(r.Type)
|
||||
if r.Type == "" || len(utf16.Encode([]rune(r.Type))) > 50 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
switch r.Metric {
|
||||
case 1, 2:
|
||||
if r.StatPeriodDays == nil || *r.StatPeriodDays <= 0 {
|
||||
return ErrAlertRulePeriod
|
||||
}
|
||||
if r.MomDirection == nil || (*r.MomDirection != 1 && *r.MomDirection != 2) {
|
||||
return ErrAlertRuleDirection
|
||||
}
|
||||
if r.MomThreshold == nil {
|
||||
return ErrAlertRuleThreshold
|
||||
}
|
||||
n, err := strconv.ParseFloat(*r.MomThreshold, 64)
|
||||
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n <= 0 || n > 9999 {
|
||||
return ErrAlertRuleThreshold
|
||||
}
|
||||
case 3:
|
||||
if r.UnloginDays == nil || *r.UnloginDays <= 0 {
|
||||
return ErrAlertRuleUnlogin
|
||||
}
|
||||
default:
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (uc *AlertRuleUsecase) Save(ctx context.Context, input *AlertRule) (uint64, error) {
|
||||
actor, a, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if actor.AuthorityID != 888 && a.Level != 1 {
|
||||
return 0, ErrAlertRuleLevel
|
||||
}
|
||||
if input == nil {
|
||||
return 0, ErrAlertRuleInput
|
||||
}
|
||||
rule := *input
|
||||
if err := validateAlertRule(&rule); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rule.MonitorScope = 1
|
||||
if rule.ID == 0 {
|
||||
rule.CreatorID, rule.Enabled, rule.IsDeleted = actor.UserID, 1, 0
|
||||
if err := uc.repo.Create(ctx, &rule); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rule.ID, nil
|
||||
}
|
||||
existing, err := uc.repo.Find(ctx, rule.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if actor.AuthorityID != 888 && existing.CreatorID != actor.UserID {
|
||||
return 0, ErrAlertRuleScope
|
||||
}
|
||||
// 原接口忽略未提供的可选指标字段,切换指标后保留历史配置值。
|
||||
if rule.StatPeriodDays == nil {
|
||||
rule.StatPeriodDays = existing.StatPeriodDays
|
||||
}
|
||||
if rule.MomDirection == nil {
|
||||
rule.MomDirection = existing.MomDirection
|
||||
}
|
||||
if rule.MomThreshold == nil {
|
||||
rule.MomThreshold = existing.MomThreshold
|
||||
}
|
||||
if rule.UnloginDays == nil {
|
||||
rule.UnloginDays = existing.UnloginDays
|
||||
}
|
||||
if err := uc.repo.Update(ctx, &rule); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rule.ID, nil
|
||||
}
|
||||
func (uc *AlertRuleUsecase) checkWrite(ctx context.Context, id uint64) error {
|
||||
actor, a, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actor.AuthorityID != 888 && a.Level != 1 {
|
||||
return ErrAlertRuleLevel
|
||||
}
|
||||
rule, err := uc.repo.Find(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actor.AuthorityID != 888 && rule.CreatorID != actor.UserID {
|
||||
return ErrAlertRuleScope
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (uc *AlertRuleUsecase) Delete(ctx context.Context, id uint64) error {
|
||||
if err := uc.checkWrite(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.SetDeleted(ctx, id)
|
||||
}
|
||||
func (uc *AlertRuleUsecase) Toggle(ctx context.Context, id uint64, enabled int) error {
|
||||
if enabled != 0 && enabled != 1 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
if err := uc.checkWrite(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.SetEnabled(ctx, id, enabled)
|
||||
}
|
||||
|
||||
func AlertRuleTypeLabel(value string) string {
|
||||
switch value {
|
||||
case "register_anomaly":
|
||||
return "注册异常"
|
||||
case "recharge_anomaly":
|
||||
return "充值异常"
|
||||
case "login_anomaly":
|
||||
return "登录异常"
|
||||
}
|
||||
return value
|
||||
}
|
||||
func AlertMetricLabel(metric int) string {
|
||||
return map[int]string{1: "注册量", 2: "充值金额", 3: "登录天数"}[metric]
|
||||
}
|
||||
func AlertDirectionLabel(direction *int) string {
|
||||
if direction == nil {
|
||||
return ""
|
||||
}
|
||||
return map[int]string{1: "上升", 2: "下降"}[*direction]
|
||||
}
|
||||
func optionalInt(value *int) string {
|
||||
if value == nil {
|
||||
return "null"
|
||||
}
|
||||
return strconv.Itoa(*value)
|
||||
}
|
||||
func (r *AlertRule) Description() string {
|
||||
if r.Metric == 3 {
|
||||
return "渠道连续 " + optionalInt(r.UnloginDays) + " 天未登录时触发预警"
|
||||
}
|
||||
if r.Metric != 1 && r.Metric != 2 {
|
||||
return ""
|
||||
}
|
||||
threshold := "null"
|
||||
if r.MomThreshold != nil {
|
||||
threshold = *r.MomThreshold
|
||||
}
|
||||
direction := AlertDirectionLabel(r.MomDirection)
|
||||
if direction == "" {
|
||||
direction = "null"
|
||||
}
|
||||
return fmt.Sprintf("渠道近 %s 天%s较上一同期环比%s %s%% 时触发", optionalInt(r.StatPeriodDays), AlertMetricLabel(r.Metric), direction, threshold)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type alertRuleRepoFake struct {
|
||||
AlertRuleRepo
|
||||
rule *AlertRule
|
||||
created *AlertRule
|
||||
updated *AlertRule
|
||||
scope LevelOneScope
|
||||
}
|
||||
|
||||
func (r *alertRuleRepoFake) Find(context.Context, uint64) (*AlertRule, error) {
|
||||
if r.rule == nil {
|
||||
return nil, ErrAlertRuleNotFound
|
||||
}
|
||||
copy := *r.rule
|
||||
return ©, nil
|
||||
}
|
||||
func (r *alertRuleRepoFake) List(_ context.Context, s LevelOneScope, _ AlertRuleFilter, _ ...ListOption) ([]*AlertRule, int64, error) {
|
||||
r.scope = s
|
||||
return []*AlertRule{}, 0, nil
|
||||
}
|
||||
func (r *alertRuleRepoFake) Create(_ context.Context, rule *AlertRule) error {
|
||||
copy := *rule
|
||||
r.created = ©
|
||||
rule.ID = 5
|
||||
return nil
|
||||
}
|
||||
func (r *alertRuleRepoFake) Update(_ context.Context, rule *AlertRule) error {
|
||||
r.updated = rule
|
||||
return nil
|
||||
}
|
||||
func intPointer(n int) *int { return &n }
|
||||
func textPointer(s string) *string { return &s }
|
||||
func alertRuleFixture() (*AlertRuleUsecase, *alertRuleRepoFake) {
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0, IDPath: "0"}, 7: {ID: 7, Level: 1, IDPath: "0"}, 8: {ID: 8, Level: 2, IDPath: "0,7"}, 9: {ID: 9, Level: 3, IDPath: "0,7,8"}, 10: {ID: 10, Level: 2, IDPath: "broken"}}}
|
||||
r := &alertRuleRepoFake{}
|
||||
return NewAlertRuleUsecase(r, NewAccountUsecase(a)), r
|
||||
}
|
||||
func validAlertRule() *AlertRule {
|
||||
return &AlertRule{Name: "注册异常", Type: " 自定义标签 ", Metric: 1, StatPeriodDays: intPointer(7), MomDirection: intPointer(2), MomThreshold: textPointer("20.50"), WarningColor: "#f56c6c"}
|
||||
}
|
||||
|
||||
func TestAlertRuleReadScopeUsesLevelOneAncestor(t *testing.T) {
|
||||
uc, r := alertRuleFixture()
|
||||
for _, id := range []uint{7, 8, 9} {
|
||||
if _, _, err := uc.List(accountContext(id, 901), AlertRuleFilter{}); err != nil || r.scope.UserID != 7 || r.scope.All || r.scope.Empty {
|
||||
t.Fatalf("所属一级范围错误: %+v %v", r.scope, err)
|
||||
}
|
||||
}
|
||||
uc.List(accountContext(10, 901), AlertRuleFilter{})
|
||||
if !r.scope.Empty {
|
||||
t.Fatal("损坏祖先链不应放行规则")
|
||||
}
|
||||
uc.List(accountContext(1, 888), AlertRuleFilter{})
|
||||
if !r.scope.All {
|
||||
t.Fatal("超管读取应不限创建人")
|
||||
}
|
||||
}
|
||||
func TestAlertRuleOwnershipDefaultsAndInactiveFields(t *testing.T) {
|
||||
uc, r := alertRuleFixture()
|
||||
input := validAlertRule()
|
||||
input.CreatorID = 99
|
||||
input.MonitorScope = 99
|
||||
input.Enabled = 0
|
||||
if _, err := uc.Save(accountContext(7, 901), input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.created.CreatorID != 7 || r.created.MonitorScope != 1 || r.created.Enabled != 1 || r.created.Type != "自定义标签" {
|
||||
t.Fatalf("服务器归属或默认值错误: %+v", r.created)
|
||||
}
|
||||
if _, err := uc.Save(accountContext(8, 902), validAlertRule()); err != ErrAlertRuleLevel {
|
||||
t.Fatal("二级不能创建规则")
|
||||
}
|
||||
r.rule = &AlertRule{ID: 5, CreatorID: 99}
|
||||
input.ID = 5
|
||||
if _, err := uc.Save(accountContext(7, 901), input); err != ErrAlertRuleScope {
|
||||
t.Fatal("不能编辑其他一级商务的规则")
|
||||
}
|
||||
r.rule = &AlertRule{ID: 5, CreatorID: 7, StatPeriodDays: intPointer(7), MomThreshold: textPointer("20.50"), MomDirection: intPointer(2)}
|
||||
if _, err := uc.Save(accountContext(7, 901), &AlertRule{ID: 5, Name: "登录", Type: "登录异常", Metric: 3, UnloginDays: intPointer(10), WarningColor: "red"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.updated.StatPeriodDays == nil || *r.updated.StatPeriodDays != 7 || r.updated.MomThreshold == nil {
|
||||
t.Fatal("切换指标丢失了未传入的历史配置")
|
||||
}
|
||||
}
|
||||
func TestAlertRuleValidationAndLegacyDescription(t *testing.T) {
|
||||
r := validAlertRule()
|
||||
if err := validateAlertRule(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Description() != "渠道近 7 天注册量较上一同期环比下降 20.50% 时触发" {
|
||||
t.Fatal(r.Description())
|
||||
}
|
||||
r.MomDirection = intPointer(99)
|
||||
if err := validateAlertRule(r); err != ErrAlertRuleDirection {
|
||||
t.Fatal("无效方向被接受")
|
||||
}
|
||||
r = validAlertRule()
|
||||
r.MomThreshold = textPointer("9999")
|
||||
if err := validateAlertRule(r); err != nil {
|
||||
t.Fatal("原页面上限应可保存")
|
||||
}
|
||||
r.MomThreshold = textPointer("10000")
|
||||
if err := validateAlertRule(r); err != ErrAlertRuleThreshold {
|
||||
t.Fatal("阈值上限未校验")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user