feat: 迁移预警规则管理和历史分类查询

This commit is contained in:
Yvan
2026-11-03 20:19:26 +08:00
parent cd42a1d3f0
commit 7203c34409
31 changed files with 1264 additions and 15 deletions
+1 -1
View File
@@ -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)
+287
View File
@@ -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)
}
+113
View File
@@ -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 &copy, 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 = &copy
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("阈值上限未校验")
}
}
+55
View File
@@ -0,0 +1,55 @@
package business
import "time"
type alertRulePO struct {
ID uint64 `gorm:"primaryKey"`
RuleName string `gorm:"size:100;not null"`
RuleType string `gorm:"size:50;not null"`
MonitorMetric int `gorm:"type:tinyint;not null"`
StatPeriodDays *int `gorm:"type:int"`
MomDirection *int `gorm:"type:tinyint"`
MomThreshold *string `gorm:"type:decimal(7,2)"`
UnloginDays *int `gorm:"type:int"`
MonitorScope int `gorm:"type:tinyint;not null;default:1"`
WarningColor string `gorm:"size:20;not null"`
CreatorID uint `gorm:"index;not null"`
Enabled int `gorm:"type:tinyint;not null;default:1"`
IsDeleted int `gorm:"type:tinyint;not null;default:0"`
CreatedAt, UpdatedAt time.Time
}
func (alertRulePO) TableName() string { return "sys_alert_rule" }
type alertRecordPO struct {
ID uint64 `gorm:"primaryKey"`
PlatformCode, PlatformName string `gorm:"size:50"`
RuleID uint64
RuleName string `gorm:"size:100"`
RuleType string `gorm:"size:50"`
MonitorMetric *int
TargetPromoteID uint64
TargetPromoteName string `gorm:"size:100"`
BusinessID uint `gorm:"index"`
BusinessName string `gorm:"size:100"`
AlertContent string `gorm:"type:text"`
Status int `gorm:"type:tinyint;default:1;index"`
AlertDate *time.Time `gorm:"type:date"`
CreatedAt, UpdatedAt time.Time
}
func (alertRecordPO) TableName() string { return "sys_alert_record" }
type alertLogPO struct {
ID uint64 `gorm:"primaryKey"`
AlertID uint64 `gorm:"index"`
OperatorID uint
OperatorName string `gorm:"size:50"`
LogType int
ActionDetail string `gorm:"size:100"`
NoteContent string `gorm:"type:text"`
AttachmentIDs *string `gorm:"type:json"`
CreatedAt time.Time
}
func (alertLogPO) TableName() string { return "sys_alert_operation_log" }
+97
View File
@@ -0,0 +1,97 @@
package business
import (
"context"
"errors"
"gorm.io/gorm"
"kra/internal/biz/business"
"time"
)
type alertRuleRepo struct{ data Provider }
func NewAlertRuleRepo(data Provider) business.AlertRuleRepo { return &alertRuleRepo{data: data} }
func toBizAlertRule(po alertRulePO) *business.AlertRule {
return &business.AlertRule{ID: po.ID, Name: po.RuleName, Type: po.RuleType, Metric: po.MonitorMetric, StatPeriodDays: po.StatPeriodDays, MomDirection: po.MomDirection, MomThreshold: po.MomThreshold, UnloginDays: po.UnloginDays, MonitorScope: po.MonitorScope, WarningColor: po.WarningColor, CreatorID: po.CreatorID, Enabled: po.Enabled, IsDeleted: po.IsDeleted, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt}
}
func newAlertRule(r *business.AlertRule) alertRulePO {
return alertRulePO{ID: r.ID, RuleName: r.Name, RuleType: r.Type, MonitorMetric: r.Metric, StatPeriodDays: r.StatPeriodDays, MomDirection: r.MomDirection, MomThreshold: r.MomThreshold, UnloginDays: r.UnloginDays, MonitorScope: r.MonitorScope, WarningColor: r.WarningColor, CreatorID: r.CreatorID, Enabled: r.Enabled, IsDeleted: r.IsDeleted}
}
func applyOwnerScope(db *gorm.DB, scope business.LevelOneScope, column string) *gorm.DB {
if scope.All {
return db
}
if scope.Empty {
return db.Where("1=0")
}
return db.Where(column+" = ?", scope.UserID)
}
func (r *alertRuleRepo) List(ctx context.Context, scope business.LevelOneScope, filter business.AlertRuleFilter, options ...business.ListOption) ([]*business.AlertRule, int64, error) {
db := applyOwnerScope(r.data.DB().WithContext(ctx).Table("sys_alert_rule r").Where("r.is_deleted = ?", 0), scope, "r.creator_id")
if filter.Type != "" {
db = db.Where("r.rule_type = ?", filter.Type)
}
if filter.Name != "" {
db = db.Where("r.rule_name LIKE ?", "%"+filter.Name+"%")
}
if filter.Metric != nil {
db = db.Where("r.monitor_metric = ?", *filter.Metric)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, business.ErrStorage
}
var pos []struct {
Rule alertRulePO `gorm:"embedded"`
CreatorName string
}
o := business.NewListOptions(options...)
if err := db.Select("r.*,u.username AS creator_name").Joins("LEFT JOIN sys_users u ON u.id=r.creator_id AND u.deleted_at IS NULL").Order("r.created_at DESC,r.id DESC").Offset(o.Offset).Limit(o.Limit).Scan(&pos).Error; err != nil {
return nil, 0, business.ErrStorage
}
rows := make([]*business.AlertRule, 0, len(pos))
for _, po := range pos {
rule := toBizAlertRule(po.Rule)
rule.CreatorName = po.CreatorName
rows = append(rows, rule)
}
return rows, total, nil
}
func (r *alertRuleRepo) Find(ctx context.Context, id uint64) (*business.AlertRule, error) {
var po alertRulePO
err := r.data.DB().WithContext(ctx).First(&po, "id = ?", id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, business.ErrAlertRuleNotFound
}
if err != nil {
return nil, business.ErrStorage
}
rule := toBizAlertRule(po)
if err := r.data.DB().WithContext(ctx).Table("sys_users").Where("id = ? AND deleted_at IS NULL", rule.CreatorID).Select("username").Scan(&rule.CreatorName).Error; err != nil {
return nil, business.ErrStorage
}
return rule, nil
}
func (r *alertRuleRepo) Create(ctx context.Context, rule *business.AlertRule) error {
po := newAlertRule(rule)
if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil {
return storageError(err)
}
rule.ID, rule.CreatedAt, rule.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
return nil
}
func (r *alertRuleRepo) Update(ctx context.Context, rule *business.AlertRule) error {
// 编辑不覆盖启停、删除和创建人字段,避免并发启停被旧配置覆盖。
return storageError(r.data.DB().WithContext(ctx).Model(&alertRulePO{}).Where("id = ?", rule.ID).Updates(map[string]any{"rule_name": rule.Name, "rule_type": rule.Type, "monitor_metric": rule.Metric, "stat_period_days": rule.StatPeriodDays, "mom_direction": rule.MomDirection, "mom_threshold": rule.MomThreshold, "unlogin_days": rule.UnloginDays, "monitor_scope": 1, "warning_color": rule.WarningColor, "updated_at": time.Now()}).Error)
}
func (r *alertRuleRepo) SetDeleted(ctx context.Context, id uint64) error {
return storageError(r.data.DB().WithContext(ctx).Model(&alertRulePO{}).Where("id = ?", id).Updates(map[string]any{"is_deleted": 1, "updated_at": time.Now()}).Error)
}
func (r *alertRuleRepo) SetEnabled(ctx context.Context, id uint64, enabled int) error {
return storageError(r.data.DB().WithContext(ctx).Model(&alertRulePO{}).Where("id = ?", id).Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()}).Error)
}
func (r *alertRuleRepo) TypesFromRecords(ctx context.Context, scope business.LevelOneScope) ([]string, error) {
rows := []string{}
err := applyOwnerScope(r.data.DB().WithContext(ctx).Table("sys_alert_record").Where("rule_type IS NOT NULL AND TRIM(rule_type) <> ''"), scope, "business_id").Distinct("rule_type").Order("rule_type").Pluck("rule_type", &rows).Error
return rows, storageError(err)
}
+57
View File
@@ -0,0 +1,57 @@
package business
import (
"context"
"kra/internal/biz/business"
"testing"
)
func TestAlertRulesKeepHistoryAndDoNotOverwriteConcurrentToggle(t *testing.T) {
db := prepareAccounts(t)
if err := db.Create(&accountPO{ID: 7, Username: "owner", Level: 1}).Error; err != nil {
t.Fatal(err)
}
r := NewAlertRuleRepo(testData{db})
ctx := context.Background()
threshold := "20.50"
period, direction := 7, 2
rule := &business.AlertRule{Name: "规则", Type: "register_anomaly", Metric: 1, StatPeriodDays: &period, MomDirection: &direction, MomThreshold: &threshold, CreatorID: 7, Enabled: 1, MonitorScope: 1, WarningColor: "red"}
if err := r.Create(ctx, rule); err != nil {
t.Fatal(err)
}
if err := r.SetEnabled(ctx, rule.ID, 0); err != nil {
t.Fatal(err)
}
rule.Name = "已修改"
if err := r.Update(ctx, rule); err != nil {
t.Fatal(err)
}
loaded, err := r.Find(ctx, rule.ID)
if err != nil {
t.Fatal(err)
}
if loaded.Enabled != 0 || loaded.CreatorID != 7 || loaded.CreatorName != "owner" {
t.Fatalf("编辑覆盖了独立状态或归属: %+v", loaded)
}
rows, total, err := r.List(ctx, business.LevelOneScope{UserID: 7}, business.AlertRuleFilter{})
if err != nil || total != 1 || len(rows) != 1 {
t.Fatalf("规则列表错误: %v", err)
}
if err := db.Create(&alertRecordPO{BusinessID: 7, RuleType: "register_anomaly"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&alertRecordPO{BusinessID: 99, RuleType: "other"}).Error; err != nil {
t.Fatal(err)
}
if err := r.SetDeleted(ctx, rule.ID); err != nil {
t.Fatal(err)
}
_, total, err = r.List(ctx, business.LevelOneScope{UserID: 7}, business.AlertRuleFilter{})
if err != nil || total != 0 {
t.Fatal("删除后仍出现在规则列表")
}
types, err := r.TypesFromRecords(ctx, business.LevelOneScope{UserID: 7})
if err != nil || len(types) != 1 || types[0] != "register_anomaly" {
t.Fatalf("删除规则后丢失历史分类或跨商务泄漏: %+v %v", types, err)
}
}
+25 -1
View File
@@ -6,7 +6,7 @@ import (
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return []migration.Step{{ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingLockPO{})
@@ -35,3 +35,27 @@ func Migrations() []migration.Step {
})
}}}
}
func migrateAlertSchema(db *gorm.DB) error {
if err := migration.CreateMissingTables(db, &alertRulePO{}, &alertRecordPO{}, &alertLogPO{}); err != nil {
return err
}
if db.Dialector.Name() != "mysql" {
return nil
}
columns, err := db.Migrator().ColumnTypes(&alertRulePO{})
if err != nil {
return err
}
for _, column := range columns {
if column.Name() == "mom_threshold" {
precision, scale, ok := column.DecimalSize()
if ok && precision >= 7 && scale >= 2 {
return nil
}
// 原页面允许 9999%,旧 decimal(5,2) 装不下;扩大精度且不改变已有值。
return db.Migrator().AlterColumn(&alertRulePO{}, "MomThreshold")
}
}
return nil
}
+1
View File
@@ -34,6 +34,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewPlatformAccountRepo,
databusiness.NewPasswordResetRepo,
databusiness.NewBusinessPromoteRepo,
databusiness.NewAlertRuleRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 19 {
t.Fatalf("migration versions = %d, want 19", versions)
if versions != 20 {
t.Fatalf("migration versions = %d, want 20", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
+2
View File
@@ -9,6 +9,8 @@ import (
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
Menus: []module.Menu{
{Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: "warn", Sort: 4},
{Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: "warn", Sort: 2},
{Name: "sw_BusMgmt", Path: "sw-BusMgmt", Component: "view/routerHolder.vue", Title: "商务管理", Icon: "user", Sort: 2},
{Name: "sw_BusinessList", Path: "BusinessList", ParentName: "sw_BusMgmt", Component: "view/sw/BusinessList.vue", Title: "商务列表", Icon: "user", Sort: 1},
},
+4 -4
View File
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 19 {
t.Fatalf("module migrations = %d, want 19", len(got))
if got := catalog.MigrationSteps(); len(got) != 20 {
t.Fatalf("module migrations = %d, want 20", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 6 || apis != 29 {
t.Fatalf("admin surface = %d menus/%d APIs, want 6/29", menus, apis)
if menus != 8 || apis != 36 {
t.Fatalf("admin surface = %d menus/%d APIs, want 8/36", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+7
View File
@@ -41,6 +41,13 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/alertRule/list": {group: "预警规则", description: "查询预警规则"},
"GET /sw/alertRule/ruleTypes": {group: "预警规则", description: "查询历史预警分类"},
"GET /sw/alertRule/:id": {group: "预警规则", description: "查询规则详情"},
"POST /sw/alertRule/add": {group: "预警规则", description: "新建预警规则", audit: true},
"PUT /sw/alertRule/edit": {group: "预警规则", description: "修改预警规则", audit: true},
"PUT /sw/alertRule/toggle/:id": {group: "预警规则", description: "启停预警规则", audit: true},
"DELETE /sw/alertRule/:id": {group: "预警规则", description: "删除预警规则", audit: true},
"GET /sw/business/:userId/promotes": {group: "商务管理", description: "查询商务旗下直属一级渠道"},
"POST /sw/auth/forgot-password/check": {group: "商务登录", description: "校验找回密码账号", public: true},
"POST /sw/auth/forgot-password/send-code": {group: "商务登录", description: "发送找回密码验证码", public: true},
+2 -1
View File
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
AlertRule: &handler.AlertRule{},
BusinessPromote: &handler.BusinessPromote{},
Business: &handler.Business{},
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
@@ -99,7 +100,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":212`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":219`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/service/business"
"kra/internal/service/dto"
"strconv"
)
type AlertRule struct{ service *business.AlertRuleService }
func NewAlertRule(service *business.AlertRuleService) *AlertRule { return &AlertRule{service: service} }
func (h *AlertRule) List(c *gin.Context) {
var req dto.AlertRuleQuery
if err := c.ShouldBindQuery(&req); err != nil {
Fail(c, "查询参数不正确")
return
}
v, err := h.service.List(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, v)
}
func (h *AlertRule) Types(c *gin.Context) {
v, err := h.service.Types(c.Request.Context())
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, v)
}
func (h *AlertRule) Find(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
Fail(c, "规则 ID 不正确")
return
}
v, err := h.service.Find(c.Request.Context(), id)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, v)
}
func (h *AlertRule) Save(c *gin.Context) {
var req dto.AlertRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "预警规则参数不正确")
return
}
id, err := h.service.Save(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, gin.H{"id": id})
}
func (h *AlertRule) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
Fail(c, "规则 ID 不正确")
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OK(c)
}
func (h *AlertRule) Toggle(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
Fail(c, "规则 ID 不正确")
return
}
enabled, err := strconv.Atoi(c.Query("enabled"))
if err != nil {
Fail(c, "启用状态参数不正确")
return
}
if err := h.service.Toggle(c.Request.Context(), id, enabled); err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OK(c)
}
+1
View File
@@ -8,6 +8,7 @@ var ProviderSet = wire.NewSet(
NewBusiness,
NewBusinessPassword,
NewBusinessPromote,
NewAlertRule,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
AlertRule *AlertRule
BusinessPromote *BusinessPromote
Business *Business
BusinessPassword *BusinessPassword
+17
View File
@@ -0,0 +1,17 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterAlertRule(private *gin.RouterGroup, h *handler.AlertRule) {
g := private.Group("/sw/alertRule")
g.GET("/list", h.List)
g.GET("/ruleTypes", h.Types)
g.GET("/:id", h.Find)
g.POST("/add", h.Save)
g.PUT("/edit", h.Save)
g.DELETE("/:id", h.Delete)
g.PUT("/toggle/:id", h.Toggle)
}
+1
View File
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() { RegisterAlertRule(private, r.handlers.AlertRule) },
func() { private.GET("/sw/business/:userId/promotes", r.handlers.BusinessPromote.List) },
func() { RegisterBusiness(private, r.handlers.Business) },
func() { RegisterBusinessPassword(public, r.handlers.BusinessPassword) },
+108
View File
@@ -0,0 +1,108 @@
package business
import (
"context"
"encoding/json"
"kra/internal/biz/business"
"kra/internal/service/dto"
"math"
"strconv"
"strings"
"unicode/utf8"
)
type AlertRuleUsecase interface {
List(context.Context, business.AlertRuleFilter, ...business.ListOption) ([]*business.AlertRule, int64, error)
Find(context.Context, uint64) (*business.AlertRule, error)
Types(context.Context) ([]string, error)
Save(context.Context, *business.AlertRule) (uint64, error)
Delete(context.Context, uint64) error
Toggle(context.Context, uint64, int) error
}
type AlertRuleService struct{ uc AlertRuleUsecase }
func NewAlertRuleService(uc *business.AlertRuleUsecase) *AlertRuleService {
return &AlertRuleService{uc: uc}
}
func alertRuleResponse(r *business.AlertRule) *dto.AlertRuleResponse {
var threshold *json.Number
if r.MomThreshold != nil {
n := json.Number(*r.MomThreshold)
threshold = &n
}
return &dto.AlertRuleResponse{ID: r.ID, RuleName: r.Name, RuleType: r.Type, RuleTypeLabel: business.AlertRuleTypeLabel(r.Type), MonitorMetric: r.Metric, MonitorMetricLabel: business.AlertMetricLabel(r.Metric), StatPeriodDays: r.StatPeriodDays, MomDirection: r.MomDirection, MomDirectionLabel: business.AlertDirectionLabel(r.MomDirection), MomThreshold: threshold, UnloginDays: r.UnloginDays, MonitorScope: r.MonitorScope, WarningColor: r.WarningColor, CreatorID: r.CreatorID, CreatorName: r.CreatorName, Enabled: r.Enabled, CreatedAt: localTime(&r.CreatedAt), UpdatedAt: localTime(&r.UpdatedAt), RuleDescription: r.Description()}
}
func (s *AlertRuleService) List(ctx context.Context, req *dto.AlertRuleQuery) (*dto.AlertRulePage, error) {
if req == nil {
return nil, business.ErrAlertRuleInput
}
filter := business.AlertRuleFilter{Name: req.RuleName, Type: req.RuleType}
if req.MonitorMetric != "" {
n, err := strconv.Atoi(req.MonitorMetric)
if err != nil || n < 1 || n > 3 {
return nil, business.ErrAlertRuleInput
}
filter.Metric = &n
}
page, size := req.Page, req.PageSize
if page < 1 {
page = 1
}
if size < 1 {
size = 10
}
if size > 500 {
size = 500
}
if page > int(^uint(0)>>1)/size {
return nil, business.ErrAlertRuleInput
}
rows, total, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size))
if err != nil {
return nil, err
}
out := &dto.AlertRulePage{Rows: make([]*dto.AlertRuleResponse, 0, len(rows)), Total: total}
for _, r := range rows {
out.Rows = append(out.Rows, alertRuleResponse(r))
}
return out, nil
}
func (s *AlertRuleService) Find(ctx context.Context, id uint64) (*dto.AlertRuleResponse, error) {
if id == 0 {
return nil, business.ErrAlertRuleInput
}
rule, err := s.uc.Find(ctx, id)
if err != nil {
return nil, err
}
return alertRuleResponse(rule), nil
}
func (s *AlertRuleService) Types(ctx context.Context) ([]string, error) { return s.uc.Types(ctx) }
func (s *AlertRuleService) Save(ctx context.Context, req *dto.AlertRuleRequest) (uint64, error) {
if req == nil || strings.TrimSpace(req.RuleName) == "" || utf8.RuneCountInString(req.RuleName) > 100 || strings.TrimSpace(req.RuleType) == "" || strings.TrimSpace(req.WarningColor) == "" || len(req.WarningColor) > 20 {
return 0, business.ErrAlertRuleInput
}
for _, n := range []*int{req.StatPeriodDays, req.UnloginDays} {
if n != nil && (*n > math.MaxInt32 || *n < math.MinInt32) {
return 0, business.ErrAlertRuleInput
}
}
rule := &business.AlertRule{ID: req.ID, Name: req.RuleName, Type: req.RuleType, Metric: req.MonitorMetric, StatPeriodDays: req.StatPeriodDays, MomDirection: req.MomDirection, UnloginDays: req.UnloginDays, WarningColor: req.WarningColor}
if req.MomThreshold != nil {
value := req.MomThreshold.String()
rule.MomThreshold = &value
}
return s.uc.Save(ctx, rule)
}
func (s *AlertRuleService) Delete(ctx context.Context, id uint64) error {
if id == 0 {
return business.ErrAlertRuleInput
}
return s.uc.Delete(ctx, id)
}
func (s *AlertRuleService) Toggle(ctx context.Context, id uint64, enabled int) error {
if id == 0 {
return business.ErrAlertRuleInput
}
return s.uc.Toggle(ctx, id, enabled)
}
+47
View File
@@ -0,0 +1,47 @@
package dto
import "encoding/json"
type AlertRuleQuery struct {
Page int `form:"pageNum"`
PageSize int `form:"pageSize"`
RuleName string `form:"ruleName"`
RuleType string `form:"ruleType"`
MonitorMetric string `form:"monitorMetric"`
}
type AlertRuleRequest struct {
ID uint64 `json:"id"`
RuleName string `json:"ruleName"`
RuleType string `json:"ruleType"`
MonitorMetric int `json:"monitorMetric"`
StatPeriodDays *int `json:"statPeriodDays"`
MomDirection *int `json:"momDirection"`
MomThreshold *json.Number `json:"momThreshold"`
UnloginDays *int `json:"unloginDays"`
WarningColor string `json:"warningColor"`
}
type AlertRuleResponse struct {
ID uint64 `json:"id"`
RuleName string `json:"ruleName"`
RuleType string `json:"ruleType"`
RuleTypeLabel string `json:"ruleTypeLabel"`
MonitorMetric int `json:"monitorMetric"`
MonitorMetricLabel string `json:"monitorMetricLabel"`
StatPeriodDays *int `json:"statPeriodDays"`
MomDirection *int `json:"momDirection"`
MomDirectionLabel string `json:"momDirectionLabel"`
MomThreshold *json.Number `json:"momThreshold"`
UnloginDays *int `json:"unloginDays"`
MonitorScope int `json:"monitorScope"`
WarningColor string `json:"warningColor"`
CreatorID uint `json:"creatorId"`
CreatorName string `json:"creatorName"`
Enabled int `json:"enabled"`
CreatedAt *string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
RuleDescription string `json:"ruleDescription"`
}
type AlertRulePage struct {
Rows []*AlertRuleResponse `json:"rows"`
Total int64 `json:"total"`
}
+1
View File
@@ -17,6 +17,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewBindingService,
businessservice.NewPasswordResetService,
businessservice.NewBusinessPromoteService,
businessservice.NewAlertRuleService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,