From 7203c344098748a9e68f94cee44179d2aa04e709 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Tue, 3 Nov 2026 20:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E8=A7=84=E5=88=99=E7=AE=A1=E7=90=86=E5=92=8C=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/alert_rule_http_test.go | 103 +++++++ cmd/wire_gen.go | 7 +- .../alert-rules-http-2026-11-03.json | 38 +++ .../alert-rules-page-2026-11-03.json | 34 +++ docs/sw6-to-kra/implementation.md | 11 +- docs/sw6-to-kra/page-parity.md | 18 +- internal/biz/biz.go | 2 +- internal/biz/business/alert_rule.go | 287 ++++++++++++++++++ internal/biz/business/alert_rule_test.go | 113 +++++++ internal/data/business/alert_models.go | 55 ++++ internal/data/business/alert_rule.go | 97 ++++++ internal/data/business/alert_rule_test.go | 57 ++++ internal/data/business/migrations.go | 26 +- internal/data/data.go | 1 + internal/data/migrations_test.go | 4 +- internal/modules/business/definition.go | 2 + internal/modules/catalog_test.go | 8 +- internal/routecatalog/catalog.go | 7 + internal/server/gin_test.go | 3 +- internal/server/handler/alert_rule.go | 89 ++++++ internal/server/handler/provider.go | 1 + internal/server/handler/set.go | 1 + internal/server/router/alert_rule.go | 17 ++ internal/server/router/routes.go | 1 + internal/service/business/alert_rule.go | 108 +++++++ internal/service/dto/alert_rule.go | 47 +++ internal/service/service.go | 1 + scripts/sw_alert_rules_smoke.cjs | 36 +++ web/src/api/alert.js | 8 + web/src/pathInfo.json | 1 + web/src/view/sw/AlertRules.vue | 96 ++++++ 31 files changed, 1264 insertions(+), 15 deletions(-) create mode 100644 cmd/alert_rule_http_test.go create mode 100644 docs/sw6-to-kra/alert-rules-http-2026-11-03.json create mode 100644 docs/sw6-to-kra/alert-rules-page-2026-11-03.json create mode 100644 internal/biz/business/alert_rule.go create mode 100644 internal/biz/business/alert_rule_test.go create mode 100644 internal/data/business/alert_models.go create mode 100644 internal/data/business/alert_rule.go create mode 100644 internal/data/business/alert_rule_test.go create mode 100644 internal/server/handler/alert_rule.go create mode 100644 internal/server/router/alert_rule.go create mode 100644 internal/service/business/alert_rule.go create mode 100644 internal/service/dto/alert_rule.go create mode 100644 scripts/sw_alert_rules_smoke.cjs create mode 100644 web/src/api/alert.js create mode 100644 web/src/view/sw/AlertRules.vue diff --git a/cmd/alert_rule_http_test.go b/cmd/alert_rule_http_test.go new file mode 100644 index 0000000..bbfe44f --- /dev/null +++ b/cmd/alert_rule_http_test.go @@ -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("历史预警分类被删除或范围错误") + } +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c2ad6a2..5e42781 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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, diff --git a/docs/sw6-to-kra/alert-rules-http-2026-11-03.json b/docs/sw6-to-kra/alert-rules-http-2026-11-03.json new file mode 100644 index 0000000..4dc7aea --- /dev/null +++ b/docs/sw6-to-kra/alert-rules-http-2026-11-03.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/docs/sw6-to-kra/alert-rules-page-2026-11-03.json b/docs/sw6-to-kra/alert-rules-page-2026-11-03.json new file mode 100644 index 0000000..227ca61 --- /dev/null +++ b/docs/sw6-to-kra/alert-rules-page-2026-11-03.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index a897618..ddce203 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -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`。 +- 当前没有执行最终旧数据库复制与迁移验收。 diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md index b1d0f60..4670fe0 100644 --- a/docs/sw6-to-kra/page-parity.md +++ b/docs/sw6-to-kra/page-parity.md @@ -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 生命周期测试。预警记录及审核流程仍待迁移。 ## 登录页 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index ac26072..f554e7e 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -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) diff --git a/internal/biz/business/alert_rule.go b/internal/biz/business/alert_rule.go new file mode 100644 index 0000000..f40d0f6 --- /dev/null +++ b/internal/biz/business/alert_rule.go @@ -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) +} diff --git a/internal/biz/business/alert_rule_test.go b/internal/biz/business/alert_rule_test.go new file mode 100644 index 0000000..ceb5bbc --- /dev/null +++ b/internal/biz/business/alert_rule_test.go @@ -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("阈值上限未校验") + } +} diff --git a/internal/data/business/alert_models.go b/internal/data/business/alert_models.go new file mode 100644 index 0000000..5811ca0 --- /dev/null +++ b/internal/data/business/alert_models.go @@ -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" } diff --git a/internal/data/business/alert_rule.go b/internal/data/business/alert_rule.go new file mode 100644 index 0000000..71ffe0f --- /dev/null +++ b/internal/data/business/alert_rule.go @@ -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) +} diff --git a/internal/data/business/alert_rule_test.go b/internal/data/business/alert_rule_test.go new file mode 100644 index 0000000..be6c741 --- /dev/null +++ b/internal/data/business/alert_rule_test.go @@ -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) + } +} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index 0156d79..914e8d1 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -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 +} diff --git a/internal/data/data.go b/internal/data/data.go index b708bee..3c5a0c9 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -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)), diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 0fd5a3b..b8a08b2 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -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 { diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index 20a08fe..c7a5122 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -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}, }, diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index f475a25..2912de3 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -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)) diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index ae4d65a..7911217 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -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}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 67c7253..832975b 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -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) } } diff --git a/internal/server/handler/alert_rule.go b/internal/server/handler/alert_rule.go new file mode 100644 index 0000000..bbf1f6b --- /dev/null +++ b/internal/server/handler/alert_rule.go @@ -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) +} diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index cbee2b6..865e450 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -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, diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index 1f5171a..9a0d3df 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,6 +1,7 @@ package handler type Set struct { + AlertRule *AlertRule BusinessPromote *BusinessPromote Business *Business BusinessPassword *BusinessPassword diff --git a/internal/server/router/alert_rule.go b/internal/server/router/alert_rule.go new file mode 100644 index 0000000..a868bc4 --- /dev/null +++ b/internal/server/router/alert_rule.go @@ -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) +} diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 32b826c..6c89fc3 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -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) }, diff --git a/internal/service/business/alert_rule.go b/internal/service/business/alert_rule.go new file mode 100644 index 0000000..036db58 --- /dev/null +++ b/internal/service/business/alert_rule.go @@ -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) +} diff --git a/internal/service/dto/alert_rule.go b/internal/service/dto/alert_rule.go new file mode 100644 index 0000000..bc09558 --- /dev/null +++ b/internal/service/dto/alert_rule.go @@ -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"` +} diff --git a/internal/service/service.go b/internal/service/service.go index bbec280..7eab991 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -17,6 +17,7 @@ var ProviderSet = wire.NewSet( businessservice.NewBindingService, businessservice.NewPasswordResetService, businessservice.NewBusinessPromoteService, + businessservice.NewAlertRuleService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/scripts/sw_alert_rules_smoke.cjs b/scripts/sw_alert_rules_smoke.cjs new file mode 100644 index 0000000..e90d9f6 --- /dev/null +++ b/scripts/sw_alert_rules_smoke.cjs @@ -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}); diff --git a/web/src/api/alert.js b/web/src/api/alert.js new file mode 100644 index 0000000..22000e5 --- /dev/null +++ b/web/src/api/alert.js @@ -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 } }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 6616c10..92979fa 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -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", diff --git a/web/src/view/sw/AlertRules.vue b/web/src/view/sw/AlertRules.vue new file mode 100644 index 0000000..f680909 --- /dev/null +++ b/web/src/view/sw/AlertRules.vue @@ -0,0 +1,96 @@ + + +