feat: 迁移预警规则管理和历史分类查询
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
businessbiz "kra/internal/biz/business"
|
||||
"kra/internal/biz/system"
|
||||
businessdata "kra/internal/data/business"
|
||||
systemdata "kra/internal/data/system"
|
||||
"kra/internal/server/handler"
|
||||
"kra/internal/server/router"
|
||||
businessservice "kra/internal/service/business"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertRuleHTTPScopesAndLifecycle(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "rules.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) {
|
||||
if err := step.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_users(id,uuid,username,level,id_path,enable) VALUES(1,'root','admin',0,'0',1),(7,'owner','owner',1,'0',1),(8,'child','child',2,'0,7',1),(99,'other','other',1,'0',1)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := businessHTTPData{db}
|
||||
accounts := businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider))
|
||||
service := businessservice.NewAlertRuleService(businessbiz.NewAlertRuleUsecase(businessdata.NewAlertRuleRepo(provider), accounts))
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
||||
aid := uint(901)
|
||||
if id == 1 {
|
||||
aid = 888
|
||||
}
|
||||
c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: aid}))
|
||||
c.Next()
|
||||
})
|
||||
router.RegisterAlertRule(engine.Group(""), handler.NewAlertRule(service))
|
||||
server := httptest.NewServer(engine)
|
||||
defer server.Close()
|
||||
call := func(user int, method, path, body string, expected int) map[string]any {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", strconv.Itoa(user))
|
||||
response, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["code"] != float64(expected) {
|
||||
t.Fatalf("%s %s 返回错误: %+v", method, path, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
payload := `{"ruleName":"注册异常","ruleType":"自定义分类","monitorMetric":1,"statPeriodDays":7,"momDirection":2,"momThreshold":"9999","warningColor":"#f56c6c","creatorId":99,"monitorScope":99}`
|
||||
created := call(7, "POST", "/sw/alertRule/add", payload, 0)["data"].(map[string]any)
|
||||
id := uint64(created["id"].(float64))
|
||||
path := fmt.Sprintf("/sw/alertRule/%d", id)
|
||||
value := call(8, "GET", path, "", 0)["data"].(map[string]any)
|
||||
if value["creatorId"] != float64(7) || value["monitorScope"] != float64(1) || value["momThreshold"] != float64(9999) {
|
||||
t.Fatalf("规则参数、归属或阈值不正确: %+v", value)
|
||||
}
|
||||
call(99, "GET", path, "", 7)
|
||||
call(8, "POST", "/sw/alertRule/add", payload, 7)
|
||||
call(7, "PUT", fmt.Sprintf("/sw/alertRule/toggle/%d?enabled=0", id), "", 0)
|
||||
call(7, "PUT", "/sw/alertRule/edit", fmt.Sprintf(`{"id":%d,"ruleName":"登录规则","ruleType":"新分类","monitorMetric":3,"unloginDays":7,"warningColor":"red"}`, id), 0)
|
||||
value = call(7, "GET", path, "", 0)["data"].(map[string]any)
|
||||
if value["enabled"] != float64(0) || value["statPeriodDays"] != float64(7) || value["momThreshold"] != float64(9999) {
|
||||
t.Fatal("编辑覆盖启停状态或未传入的历史参数")
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_alert_record(business_id,rule_type) VALUES(7,'历史分类')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call(99, "DELETE", path, "", 7)
|
||||
call(1, "DELETE", path, "", 0)
|
||||
list := call(7, "GET", "/sw/alertRule/list", "", 0)["data"].(map[string]any)
|
||||
if list["total"] != float64(0) {
|
||||
t.Fatal("逻辑删除未从规则列表移除")
|
||||
}
|
||||
types := call(8, "GET", "/sw/alertRule/ruleTypes", "", 0)["data"].([]any)
|
||||
if len(types) != 1 || types[0] != "历史分类" {
|
||||
t.Fatal("历史预警分类被删除或范围错误")
|
||||
}
|
||||
}
|
||||
Generated
+6
-1
@@ -82,9 +82,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
||||
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
||||
registry := taskRegistry()
|
||||
businessPromoteRepo := business.NewBusinessPromoteRepo(dataData)
|
||||
alertRuleRepo := business.NewAlertRuleRepo(dataData)
|
||||
accountRepo := business.NewAccountRepo(dataData)
|
||||
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
||||
alertRuleUsecase := business2.NewAlertRuleUsecase(alertRuleRepo, accountUsecase)
|
||||
alertRuleService := business3.NewAlertRuleService(alertRuleUsecase)
|
||||
alertRule := handler.NewAlertRule(alertRuleService)
|
||||
businessPromoteRepo := business.NewBusinessPromoteRepo(dataData)
|
||||
businessPromoteUsecase := business2.NewBusinessPromoteUsecase(businessPromoteRepo, accountUsecase)
|
||||
businessPromoteService := business3.NewBusinessPromoteService(businessPromoteUsecase)
|
||||
businessPromote := handler.NewBusinessPromote(businessPromoteService)
|
||||
@@ -201,6 +205,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||
set := &handler.Set{
|
||||
AlertRule: alertRule,
|
||||
BusinessPromote: businessPromote,
|
||||
Business: handlerBusiness,
|
||||
BusinessPassword: businessPassword,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"scope": "既有 sw7;查询和拒绝无效写请求,未创建真实规则",
|
||||
"schema_before": {
|
||||
"column_type": "decimal(7,2)",
|
||||
"rule_rows": 0
|
||||
},
|
||||
"schema_after": "decimal(7,2)",
|
||||
"checks": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/sw/alertRule/list?pageNum=1&pageSize=10",
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/sw/alertRule/ruleTypes",
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/sw/alertRule/add",
|
||||
"code": 7,
|
||||
"msg": "预警规则参数不正确",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/sw/alertRule/toggle/0?enabled=1",
|
||||
"code": 7,
|
||||
"msg": "预警规则参数不正确",
|
||||
"passed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"method": "真实 Chrome 渲染,受控角色及规则数据;不写真实规则",
|
||||
"results": [
|
||||
{
|
||||
"level": 0,
|
||||
"createButton": 1,
|
||||
"editButton": 1,
|
||||
"passed": true,
|
||||
"pageErrors": []
|
||||
},
|
||||
{
|
||||
"level": 1,
|
||||
"createButton": 1,
|
||||
"editButton": 1,
|
||||
"passed": true,
|
||||
"metricFields": true,
|
||||
"pageErrors": []
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"createButton": 0,
|
||||
"editButton": 0,
|
||||
"passed": true,
|
||||
"pageErrors": []
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"createButton": 0,
|
||||
"editButton": 0,
|
||||
"passed": true,
|
||||
"pageErrors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,8 +22,8 @@
|
||||
| 阶段 | 范围 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 |
|
||||
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 待实现 |
|
||||
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 待实现 |
|
||||
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定和旗下渠道已接入,画像及 OA 同步待迁移 |
|
||||
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则已接入,记录和工作流待迁移 |
|
||||
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 待实现 |
|
||||
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 |
|
||||
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
|
||||
@@ -48,7 +48,7 @@
|
||||
- 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。
|
||||
- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。
|
||||
- 商务画像及其关联报表仍在后续范围内;后端导出等接口另按源码核对,不能据此给旧页面增加不存在的按钮。当前不将整个商务菜单标为完成。
|
||||
- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面增加游戏 SDK 绑定页签与详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。
|
||||
- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面恢复仅三级可见的“绑定列表”页签及只读详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。
|
||||
- 已恢复旧登录页的图片、文案、布局及记住账号功能,系统名称统一为“遨游龙翔游戏平台”。找回密码四个匿名接口位于 `/sw/auth/forgot-password/`,保留账号/手机号检查、60 秒发码限制、两分钟验证码、十分钟凭证、6–15 位密码及失败五次吊销等规则。
|
||||
- 找回密码采用同库事务保存验证码摘要和一次性凭证,并原子执行改密/消费凭证;新增 `sys_business_password_codes`、`sys_business_password_resets`。更新密码版本使旧 JWT 失效。短信复用 Kra 集成配置,`SW_PASSWORD_RESET_SMS_PROVIDER` 默认 `tencent`,也支持 `aliyun`;模板和通道凭据在对应集成配置中维护。
|
||||
- 旗下渠道接口 `/sw/business/:userId/promotes` 和两处抽屉入口已接入:先校验商务可见范围,再按已通过绑定的 `(source_db, platform_busier_id)` 查询直属一级渠道;按渠道 ID、平台稳定分页。实际 sw7 请求与隔离完整数据 HTTP/仓储测试均通过,浏览器确认绑定入口同时携带平台和平台商务 ID,未通过绑定禁用该入口。
|
||||
@@ -60,4 +60,7 @@
|
||||
- 已通过正常验证码登录取得测试会话,并在修复后的独立 18000 实例连接同一 sw7 执行 HTTP 请求。列表总数 45,分页、账号筛选、资料和偏好查询全部成功。详见 `http-verification-2026-11-03.json`。8000 为用户 GoLand 旧二进制,需重新运行才加载源码修复。
|
||||
- 绑定批次通过 13 项真实 HTTP 检查:商务 45 条、绑定 58 条、平台与审核筛选、详情、偏好查询和写请求校验,结果见 `http-business-bindings-2026-11-03.json`;`scripts/sw_http_smoke.py` 可复用且不记录令牌。真实库不制造测试商务或发起实际平台绑定。新增、修改、偏好关闭、提交绑定及解绑的正向流程已用独立 SQLite 数据库通过实际 TCP HTTP 测试;平台验密外部边界使用测试替身,密码算法另用固定向量验证。
|
||||
- 登录/找回密码批次 `go test ./...` 全量通过,前端 ESLint 和生产构建通过。Chrome 已检查桌面/移动登录页及三步找回流程(短信接口受控替身);完整找回密码链路已通过匿名 TCP HTTP + SQLite 测试,覆盖错误验证码后重试、原子消费、改密版本更新和凭证重放拒绝。实际运行实例四个匿名接口校验与既有 13 项商务 HTTP 回归全部通过,未发送真实短信或修改真实用户密码。
|
||||
- 当前没有执行本轮真实数据库迁移验证。
|
||||
- 预警规则七个接口及 Kra 页面已接入。一级商务管理自己的规则,二/三级读取所属一级的规则,超管可管理全部;启停、软删除、监控指标与原校验一致。分类选项来自历史预警记录,删除规则不会丢失历史分类。编辑不覆盖独立的启停/删除状态,省略的可选指标字段沿用旧值。
|
||||
- 新增预警表缺表建表迁移,保留既有表;阈值列支持原页面 9999% 上限。当前 sw7 启动前后均为 `decimal(7,2)`,没有改动既有规则数据。触发检测任务和记录去重写入尚未迁移,不作为规则页面完成范围。
|
||||
- 规则批次通过 biz/data/service/cmd/server/modules 检查、前端 ESLint 与生产构建;隔离 SQLite + TCP HTTP 测试覆盖新增、编辑、启停、软删除、历史分类和跨商务权限。`alert-rules-http-2026-11-03.json` 记录真实 sw7 查询与无效写请求,`alert-rules-page-2026-11-03.json` 记录四级角色入口与指标表单联动;可复用脚本为 `scripts/sw_alert_rules_smoke.cjs`。
|
||||
- 当前没有执行最终旧数据库复制与迁移验收。
|
||||
|
||||
@@ -35,7 +35,23 @@
|
||||
|
||||
## 其他页面
|
||||
|
||||
渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析、排行榜、预警列表和预警规则,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
|
||||
渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析、排行榜和预警列表,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
|
||||
|
||||
## 预警规则
|
||||
|
||||
来源:`sw/web/src/views/AlertCenter/AlertRules.vue` 及其规则编辑组件;规则触发提示同时核对最终 `AlertRecordCreationService`,旧页面“每天一次”提示已失效。
|
||||
|
||||
| 旧页面行为 | 新页面状态 |
|
||||
| --- | --- |
|
||||
| 按监控指标、规则名称筛选 | 已实现 |
|
||||
| 列为启用状态、规则名称、规则类型、描述、创建人、作用范围、操作 | 已实现;类型显示预警颜色,范围固定一级渠道 |
|
||||
| 一级商务和超管可新建、编辑、删除、启停;二/三级只读 | 已实现,后端同时校验归属,真实 Chrome 检查四级角色 |
|
||||
| 新建/编辑右侧抽屉,类型为自由文本 | 已实现 |
|
||||
| 注册/充值展示周期、方向、阈值;登录展示未登录天数 | 已实现,浏览器验证切换字段及提交参数 |
|
||||
| 删除需要确认 | 已实现,后端软删除 |
|
||||
| 预警触发去重说明 | 按最终后端描述平台、渠道、类型的未关闭记录去重;生成任务仍待迁移 |
|
||||
|
||||
验证:`alert-rules-http-2026-11-03.json`、`alert-rules-page-2026-11-03.json`,以及隔离数据的真实 TCP HTTP 生命周期测试。预警记录及审核流程仍待迁移。
|
||||
|
||||
## 登录页
|
||||
|
||||
|
||||
+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("阈值上限未校验")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
AlertRule *AlertRule
|
||||
BusinessPromote *BusinessPromote
|
||||
Business *Business
|
||||
BusinessPassword *BusinessPassword
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -17,6 +17,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewBindingService,
|
||||
businessservice.NewPasswordResetService,
|
||||
businessservice.NewBusinessPromoteService,
|
||||
businessservice.NewAlertRuleService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 在独立 Chrome 上核对规则入口和指标联动,接口写入使用测试替身。
|
||||
const fs=require('node:fs/promises');const path=require('node:path');
|
||||
const {chromium}=require(process.env.SW_PLAYWRIGHT_MODULE||'playwright');
|
||||
(async()=>{
|
||||
const token=(await fs.readFile(path.join(process.env.TEMP,'sw-kra-http-test-token'),'utf8')).trim();
|
||||
const browser=await chromium.launch({channel:'chrome',headless:true});const results=[];
|
||||
try{
|
||||
for(const level of [0,1,2,3]){
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000}});await context.addInitScript(t=>localStorage.setItem('token',t),token);
|
||||
const page=await context.newPage();const errors=[];const writes=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('http://127.0.0.1:8080/api/**',async route=>{
|
||||
const req=route.request();const url=new URL(req.url());const api=url.pathname.slice(4);
|
||||
if(api==='/sw/alertRule/list'){await route.fulfill({json:{code:0,msg:'成功',data:{total:1,rows:[{id:1,ruleName:'测试规则',ruleType:'测试分类',ruleTypeLabel:'测试分类',monitorMetric:1,statPeriodDays:7,momDirection:2,momThreshold:20,warningColor:'#f56c6c',creatorId:70001,creatorName:'测试一级商务',monitorScope:1,enabled:1,ruleDescription:'渠道近 7 天注册量较上一同期环比下降 20% 时触发'}]}}});return;}
|
||||
if(api.startsWith('/sw/alertRule/')&&req.method()!=='GET'){writes.push({path:api,body:req.postDataJSON()});await route.fulfill({json:{code:0,msg:'成功',data:{id:2}}});return;}
|
||||
const response=await route.fetch({url:'http://127.0.0.1:18000'+api+url.search,headers:{...req.headers(),'x-token':token}});
|
||||
if(api==='/user/getUserInfo'){const body=await response.json();Object.assign(body.data.userInfo,{ID:70000+level,level,nickName:'页面核对账号',phone:'',email:'',authorityId:level===0?888:900+level});await route.fulfill({response,json:body});}
|
||||
else await route.fulfill({response});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:8080/#/layout/sw-AlertCenter/AlertRules');await page.getByText('测试规则',{exact:true}).waitFor();
|
||||
const create=await page.getByRole('button',{name:'新建规则',exact:true}).count();const edit=await page.getByRole('button',{name:'编辑',exact:true}).count();
|
||||
const allowed=level===0||level===1;if(create!==(allowed?1:0)||edit!==(allowed?1:0))throw new Error('规则写入口权限错误 level='+level);
|
||||
const result={level,createButton:create,editButton:edit,passed:true};
|
||||
if(level===1){
|
||||
await page.getByRole('button',{name:'新建规则',exact:true}).click();const dialog=page.getByRole('dialog',{name:'新建规则',exact:true});
|
||||
await dialog.getByPlaceholder('请输入规则名称').fill('登录测试');await dialog.getByPlaceholder('如:注册异常 / 充值异常 / 登录异常').fill('自定义类型');
|
||||
await dialog.locator('.el-form-item').filter({hasText:'监控指标'}).locator('.el-select__wrapper').click();await page.getByRole('option',{name:'登录天数',exact:true}).click();
|
||||
if(await dialog.getByText('统计周期(天)',{exact:true}).count())throw new Error('登录指标仍显示环比字段');
|
||||
await dialog.getByText('未登录天数',{exact:true}).waitFor();await dialog.getByRole('button',{name:'保存',exact:true}).click();await dialog.waitFor({state:'hidden'});
|
||||
const saved=writes.find(x=>x.path==='/sw/alertRule/add');if(!saved||saved.body.monitorMetric!==3||saved.body.unloginDays!==7)throw new Error('规则联动表单参数错误');result.metricFields=true;
|
||||
}
|
||||
result.pageErrors=errors;if(errors.length)throw new Error(errors.join(';'));
|
||||
results.push(result);console.log(JSON.stringify(result));await context.close();
|
||||
}
|
||||
await fs.writeFile('docs/sw6-to-kra/alert-rules-page-2026-11-03.json',JSON.stringify({method:'真实 Chrome 渲染,受控角色及规则数据;不写真实规则',results},null,2));
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e.message);process.exitCode=1});
|
||||
@@ -0,0 +1,8 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
export const listAlertRules = (params) => service({ url: '/sw/alertRule/list', method: 'get', params })
|
||||
export const getAlertRuleTypes = () => service({ url: '/sw/alertRule/ruleTypes', method: 'get' })
|
||||
export const createAlertRule = (data) => service({ url: '/sw/alertRule/add', method: 'post', data })
|
||||
export const updateAlertRule = (data) => service({ url: '/sw/alertRule/edit', method: 'put', data })
|
||||
export const deleteAlertRule = (id) => service({ url: `/sw/alertRule/${id}`, method: 'delete' })
|
||||
export const toggleAlertRule = (id, enabled) => service({ url: `/sw/alertRule/toggle/${id}`, method: 'put', params: { enabled } })
|
||||
@@ -52,6 +52,7 @@
|
||||
"/src/view/superAdmin/params/sysParams.vue": "SysParams",
|
||||
"/src/view/superAdmin/position/position.vue": "Position",
|
||||
"/src/view/superAdmin/user/user.vue": "User",
|
||||
"/src/view/sw/AlertRules.vue": "SwAlertRules",
|
||||
"/src/view/sw/BusinessList.vue": "SwBusinessList",
|
||||
"/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings",
|
||||
"/src/view/sw/components/BusinessChannels.vue": "SwBusinessChannels",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="kra-search-box">
|
||||
<el-form :inline="true" :model="query" @submit.prevent="search">
|
||||
<el-form-item label="监控指标"><el-select v-model="query.monitorMetric" clearable placeholder="全部监控指标" style="width: 160px"><el-option v-for="(label, value) in metrics" :key="value" :value="Number(value)" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="规则名称"><el-input v-model="query.ruleName" clearable placeholder="输入规则名称" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<div class="kra-btn-list"><el-button v-if="canCreate" type="primary" icon="plus" @click="open()">新建规则</el-button></div>
|
||||
<el-alert class="mb-4" type="info" :closable="false" title="同一平台、同一渠道、同类预警未关闭时不重复新建。未处理预警再次命中会刷新;处理中、待审核和审核驳回的记录保持不变。" />
|
||||
<el-table v-loading="loading" :data="rows" row-key="id">
|
||||
<el-table-column label="启用状态" width="110"><template #default="{ row }"><el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" :disabled="!canEdit || busy === row.id" @change="toggle(row, $event)" /></template></el-table-column>
|
||||
<el-table-column prop="ruleName" label="规则名称" min-width="150" />
|
||||
<el-table-column label="规则类型" min-width="130"><template #default="{ row }"><el-tag effect="plain" :style="{ color: row.warningColor, borderColor: row.warningColor }">{{ row.ruleTypeLabel }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="ruleDescription" label="规则描述" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="creatorName" label="创建人" min-width="120" />
|
||||
<el-table-column label="作用范围" min-width="150"><template #default>仅对一级渠道生效</template></el-table-column>
|
||||
<el-table-column v-if="canEdit || canDelete" label="操作" width="130" fixed="right"><template #default="{ row }"><el-button v-if="canEdit" type="primary" link @click="open(row)">编辑</el-button><el-button v-if="canDelete" type="danger" link @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<el-drawer v-model="drawer" append-to-body :title="form.id ? '编辑规则' : '新建规则'" size="480px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="规则名称" prop="ruleName"><el-input v-model="form.ruleName" maxlength="50" placeholder="请输入规则名称" /></el-form-item>
|
||||
<el-form-item label="规则类型" prop="ruleType"><el-input v-model="form.ruleType" maxlength="20" placeholder="如:注册异常 / 充值异常 / 登录异常" /></el-form-item>
|
||||
<el-form-item label="监控指标" prop="monitorMetric"><el-select v-model="form.monitorMetric"><el-option v-for="(label, value) in metrics" :key="value" :value="Number(value)" :label="label" /></el-select></el-form-item>
|
||||
<template v-if="form.monitorMetric !== 3">
|
||||
<el-form-item label="统计周期(天)" prop="statPeriodDays"><el-input-number v-model="form.statPeriodDays" :min="1" :max="365" :precision="0" /></el-form-item>
|
||||
<el-form-item label="环比方向" prop="momDirection"><el-select v-model="form.momDirection"><el-option label="环比上升 (异常暴涨)" :value="1" /><el-option label="环比下降 (异常恶化)" :value="2" /></el-select></el-form-item>
|
||||
<el-form-item label="环比阈值 (%)" prop="momThreshold"><el-input-number v-model="form.momThreshold" :min="1" :max="9999" :precision="2" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else label="未登录天数" prop="unloginDays"><el-input-number v-model="form.unloginDays" :min="1" :max="365" :precision="0" /></el-form-item>
|
||||
<el-form-item label="预警颜色" prop="warningColor"><el-color-picker v-model="form.warningColor" :predefine="colors" color-format="hex" /></el-form-item>
|
||||
<el-text type="info">作用范围固定为一级渠道。</el-text>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listAlertRules, createAlertRule, updateAlertRule, deleteAlertRule, toggleAlertRule } from '@/api/alert'
|
||||
|
||||
defineOptions({ name: 'SwAlertRules' })
|
||||
const user = useUserStore()
|
||||
const buttons = useBtnAuth()
|
||||
const admin = computed(() => Number(user.userInfo.authorityId) === 888)
|
||||
const manage = computed(() => admin.value || Number(user.userInfo.level) === 1)
|
||||
const canCreate = computed(() => manage.value && (admin.value || buttons['sw:alertRule:add']))
|
||||
const canEdit = computed(() => manage.value && (admin.value || buttons['sw:alertRule:edit']))
|
||||
const canDelete = computed(() => manage.value && (admin.value || buttons['sw:alertRule:remove']))
|
||||
const metrics = { 1: '注册量', 2: '充值金额', 3: '登录天数' }
|
||||
const colors = ['#f56c6c', '#e6a23c', '#22c55e', '#409eff', '#a855f7']
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, monitorMetric: undefined, ruleName: '' })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const drawer = ref(false)
|
||||
const busy = ref(0)
|
||||
const formRef = ref()
|
||||
const form = ref({})
|
||||
const rules = Object.fromEntries(['ruleName', 'ruleType', 'monitorMetric', 'statPeriodDays', 'momDirection', 'momThreshold', 'unloginDays', 'warningColor'].map((name) => [name, [{ required: true, message: '请填写完整规则信息', trigger: 'change' }]]))
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try { const res = await listAlertRules(query); if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total } } finally { loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNum = 1; return load() }
|
||||
const reset = () => { query.monitorMetric = undefined; query.ruleName = ''; return search() }
|
||||
const open = (row) => {
|
||||
form.value = { ruleName: '', ruleType: '', monitorMetric: 1, statPeriodDays: 7, momDirection: 1, momThreshold: 20, unloginDays: 7, warningColor: colors[0], ...row }
|
||||
if (form.value.momThreshold != null) form.value.momThreshold = Number(form.value.momThreshold)
|
||||
drawer.value = true
|
||||
}
|
||||
const save = async () => {
|
||||
if (!await formRef.value.validate().catch(() => false)) return
|
||||
saving.value = true
|
||||
try { const res = await (form.value.id ? updateAlertRule : createAlertRule)(form.value); if (res.code === 0) { ElMessage.success('保存成功'); drawer.value = false; await load() } } finally { saving.value = false }
|
||||
}
|
||||
const toggle = async (row, enabled) => {
|
||||
busy.value = row.id
|
||||
try { const res = await toggleAlertRule(row.id, enabled); if (res.code === 0) row.enabled = enabled } finally { busy.value = 0 }
|
||||
}
|
||||
const remove = async (row) => {
|
||||
if (!await ElMessageBox.confirm(`确认删除规则「${row.ruleName}」?`, '删除确认', { type: 'warning' }).then(() => true).catch(() => false)) return
|
||||
const res = await deleteAlertRule(row.id)
|
||||
if (res.code === 0) { ElMessage.success('删除成功'); await load() }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user