From 838c2c496f19fb989b3407e7b467309402a73414 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Tue, 3 Nov 2026 20:40:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=AE=A1=E6=A0=B8=E4=B8=8E=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/alert_record_http_test.go | 161 +++++++++ cmd/wire_gen.go | 7 +- docs/sw6-to-kra/alert-attachment-migration.md | 15 + .../alert-records-http-2026-11-03.json | 123 +++++++ .../alert-records-page-2026-11-03.json | 116 ++++++ docs/sw6-to-kra/implementation.md | 8 +- docs/sw6-to-kra/page-parity.md | 24 +- internal/biz/biz.go | 2 +- internal/biz/business/alert_record.go | 210 +++++++++++ internal/biz/business/alert_record_test.go | 122 +++++++ .../business/alert_attachment_migration.go | 87 +++++ internal/data/business/alert_record.go | 329 ++++++++++++++++++ internal/data/business/alert_record_test.go | 215 ++++++++++++ internal/data/business/migrations.go | 2 +- internal/data/data.go | 1 + internal/data/migrations_test.go | 4 +- internal/modules/business/definition.go | 1 + internal/modules/catalog_test.go | 8 +- internal/routecatalog/catalog.go | 8 + internal/server/gin_test.go | 3 +- internal/server/handler/alert_record.go | 76 ++++ internal/server/handler/provider.go | 1 + internal/server/handler/set.go | 1 + internal/server/router/alert_record.go | 19 + internal/server/router/routes.go | 1 + internal/service/business/alert_record.go | 133 +++++++ .../service/business/alert_record_test.go | 79 +++++ internal/service/dto/alert_record.go | 81 +++++ internal/service/service.go | 1 + scripts/sw_alert_http_smoke.py | 58 +++ scripts/sw_alert_records_smoke.cjs | 156 +++++++++ web/src/api/alert.js | 5 + web/src/pathInfo.json | 1 + web/src/view/sw/AlertList.vue | 106 ++++++ web/src/view/sw/alert-display.js | 4 + .../view/sw/components/AlertHandleDrawer.vue | 157 +++++++++ 36 files changed, 2312 insertions(+), 13 deletions(-) create mode 100644 cmd/alert_record_http_test.go create mode 100644 docs/sw6-to-kra/alert-attachment-migration.md create mode 100644 docs/sw6-to-kra/alert-records-http-2026-11-03.json create mode 100644 docs/sw6-to-kra/alert-records-page-2026-11-03.json create mode 100644 internal/biz/business/alert_record.go create mode 100644 internal/biz/business/alert_record_test.go create mode 100644 internal/data/business/alert_attachment_migration.go create mode 100644 internal/data/business/alert_record.go create mode 100644 internal/data/business/alert_record_test.go create mode 100644 internal/server/handler/alert_record.go create mode 100644 internal/server/router/alert_record.go create mode 100644 internal/service/business/alert_record.go create mode 100644 internal/service/business/alert_record_test.go create mode 100644 internal/service/dto/alert_record.go create mode 100644 scripts/sw_alert_http_smoke.py create mode 100644 scripts/sw_alert_records_smoke.cjs create mode 100644 web/src/view/sw/AlertList.vue create mode 100644 web/src/view/sw/alert-display.js create mode 100644 web/src/view/sw/components/AlertHandleDrawer.vue diff --git a/cmd/alert_record_http_test.go b/cmd/alert_record_http_test.go new file mode 100644 index 0000000..6fae0e7 --- /dev/null +++ b/cmd/alert_record_http_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "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" +) + +func TestAlertRecordHTTPCompleteWorkflowAndVisibility(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "records.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + for _, sql := range []string{ + "INSERT INTO sys_users(id,uuid,username,level,parent_id,id_path,enable) VALUES(1,'root','admin',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(8,'child','child',2,7,'0,7',1),(9,'leaf','leaf',3,8,'0,7,8',1),(10,'sibling','sibling',2,7,'0,7',1),(99,'other','other',1,0,'0',1)", + "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,promote_level INTEGER,status INTEGER)", + "INSERT INTO tab_promote VALUES(100,'lx',14,1,0),(100,'hw',14,1,1),(100,'yq',14,1,1)", + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(10,'hw',14,'0'),(99,'yq',14,'0')", + "INSERT INTO sys_alert_record(id,platform_code,target_promote_id,business_id,rule_name,status,created_at,updated_at) VALUES(9007199254740993,'lx',100,7,'目标规则',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(2,'hw',100,7,'平级规则',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(3,'lx',100,99,'归属错误',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(4,'yq',100,99,'其他规则',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(5,'lx',100,7,'关闭规则',5,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(6,'lx',100,7,'待审规则',3,'2026-11-03 08:00:00','2026-11-03 08:00:00')", + "INSERT INTO media_file_upload_and_downloads(id,url) VALUES(1,'/files/one.png'),(2,'/files/two.png')", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + provider := businessHTTPData{db} + accounts := businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)) + service := businessservice.NewAlertRecordService(businessbiz.NewAlertRecordUsecase(businessdata.NewAlertRecordRepo(provider), accounts)) + engine := gin.New() + engine.Use(func(c *gin.Context) { + id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64) + authority := uint(901) + if id == 1 { + authority = 888 + } + c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: authority})) + c.Next() + }) + router.RegisterAlertRecord(engine.Group(""), handler.NewAlertRecord(service)) + server := httptest.NewServer(engine) + defer server.Close() + call := func(user int, method, path, body string, expected int) map[string]any { + t.Helper() + req, err := http.NewRequest(method, server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + 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 用户%d 响应错误: %+v", method, path, user, result) + } + return result + } + list := func(user int, query string, total int) map[string]any { + t.Helper() + data := call(user, "GET", "/sw/alertRecord/list"+query, "", 0)["data"].(map[string]any) + if data["pageData"].(map[string]any)["total"] != float64(total) { + t.Fatalf("列表范围错误: %+v", data) + } + return data + } + list(1, "", 6) + list(7, "", 4) + list(8, "", 3) + list(9, "", 3) + list(7, "?businessId=8", 3) + list(8, "?businessId=99", 0) + list(1, "?businessId=8", 4) + list(8, "?platformCodes=hw", 0) + data := list(8, "?status=1&ruleName=%E7%9B%AE%E6%A0%87", 1) + if len(data["statusCount"].(map[string]any)) != 3 { + t.Fatal("汇总错误继承列表筛选") + } + const id = "9007199254740993" + detailPath := "/sw/alertRecord/detail/" + id + data = call(9, "GET", detailPath, "", 0)["data"].(map[string]any) + if data["canAudit"] != false || data["alertRecord"].(map[string]any)["id"] != id { + t.Fatal("详情审核权限或大 ID 精度错误") + } + call(8, "GET", "/sw/alertRecord/detail/2", "", 7) + call(8, "GET", "/sw/alertRecord/detail/3", "", 7) + call(99, "GET", detailPath, "", 7) + body := `{"alertId":"` + id + `"}` + process := func(user int, action, note string, code int) { + payload := body + if note != "" { + encoded, _ := json.Marshal(note) + payload = fmt.Sprintf(`{"alertId":"%s","note":%s}`, id, encoded) + } + call(user, "POST", "/sw/alertRecord/"+action, payload, code) + } + process(9, "submitAudit", "", 7) + process(9, "startProcess", "开始排查", 0) + process(9, "startProcess", "重复开始", 7) + process(9, "submitAudit", "", 0) + process(1, "approve", "超管不能绕过等级", 7) + process(8, "approve", "", 7) + process(9, "approve", "", 7) + process(7, "reject", "补充说明", 0) + process(9, "reProcess", "", 0) + process(9, "submitAudit", "", 0) + process(7, "approve", "", 0) + process(9, "reProcess", "", 7) + data = call(7, "GET", detailPath, "", 0)["data"].(map[string]any) + if data["canAudit"] != true || data["alertRecord"].(map[string]any)["status"] != float64(5) || len(data["operationLogs"].([]any)) != 8 { + t.Fatalf("完整处理审核日志错误: %+v", data) + } + call(9, "POST", "/sw/alertRecord/addNote", `{"alertId":"`+id+`","noteContent":" ","attachmentIds":[1]}`, 7) + call(9, "POST", "/sw/alertRecord/addNote", `{"alertId":"`+id+`","noteContent":"关闭后补充","attachmentIds":[2,"1"]}`, 0) + data = call(9, "GET", detailPath, "", 0)["data"].(map[string]any) + logs := data["operationLogs"].([]any) + latest := logs[0].(map[string]any) + if len(logs) != 9 || latest["operatorName"] != "leaf" || latest["logType"] != float64(2) || latest["attachments"].([]any)[0] != "/files/two.png" || data["alertRecord"].(map[string]any)["status"] != float64(5) { + t.Fatalf("备注附件或状态错误: %+v", data) + } + call(99, "POST", "/sw/alertRecord/addNote", `{"alertId":"`+id+`","noteContent":"越权"}`, 7) + call(1, "POST", "/sw/alertRecord/startProcess", `{"alertId":0}`, 7) + if err := db.Exec("DROP TABLE tab_promote").Error; err != nil { + t.Fatal(err) + } + data = list(8, "", 0) + if data["available"] != false { + t.Fatal("缺渠道表应报告未知范围") + } + call(8, "GET", detailPath, "", 7) + process(9, "startProcess", "", 7) + list(1, "", 6) +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 5e42781..0892c2f 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() - alertRuleRepo := business.NewAlertRuleRepo(dataData) + alertRecordRepo := business.NewAlertRecordRepo(dataData) accountRepo := business.NewAccountRepo(dataData) accountUsecase := business2.NewAccountUsecase(accountRepo) + alertRecordUsecase := business2.NewAlertRecordUsecase(alertRecordRepo, accountUsecase) + alertRecordService := business3.NewAlertRecordService(alertRecordUsecase) + alertRecord := handler.NewAlertRecord(alertRecordService) + alertRuleRepo := business.NewAlertRuleRepo(dataData) alertRuleUsecase := business2.NewAlertRuleUsecase(alertRuleRepo, accountUsecase) alertRuleService := business3.NewAlertRuleService(alertRuleUsecase) alertRule := handler.NewAlertRule(alertRuleService) @@ -205,6 +209,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) set := &handler.Set{ + AlertRecord: alertRecord, AlertRule: alertRule, BusinessPromote: businessPromote, Business: handlerBusiness, diff --git a/docs/sw6-to-kra/alert-attachment-migration.md b/docs/sw6-to-kra/alert-attachment-migration.md new file mode 100644 index 0000000..63207fd --- /dev/null +++ b/docs/sw6-to-kra/alert-attachment-migration.md @@ -0,0 +1,15 @@ +# 预警日志附件的文件 ID 迁移补充 + +现有 `sw_to_kra_migrate.sql` 为 `sys_oss.oss_id` 建立 `entity='file'` 映射并导入 `media_file_upload_and_downloads`,同时只更新预警操作日志的操作人 ID,没有转换 `attachment_ids` 中的文件 ID。日志数字数组仍引用旧文件,不能直接当成 Kra 文件 ID 查询。 + +代码迁移步骤 `202611030007_business_alert_attachments` 在服务启动的迁移流程中执行,不修改用户提供的 SQL。最终数据库复制验收时,先完成原 SQL,再启动包含本步骤的新服务。 + +处理规则: + +1. 新库没有旧日志备份和映射表时不执行转换。 +2. 按日志 ID 与 `bak_sw2kra_20261103_sys_alert_operation_log` 关联,只处理当前附件与旧备份一致的纯数字数组,兼容 JSON 数字和数字字符串。 +3. 根据文件映射写入新文件 ID,保留原数组顺序。没有旧文件映射的引用原本无法展示,转换后不指向任何同号新文件;原始引用仍在备份中保留。 +4. URL 数组、混合 URL 数组、新日志、已改动的旧日志均不转换。分批查询和事务写入,重复执行不会二次映射。 +5. 运行时附件解析支持数字 ID 与 URL;文件查询分批执行,已删除或不存在文件不展示。大数字 ID 在解析及返回过程中不经过浮点数。 + +隔离测试覆盖超出 JavaScript 精确整数范围的旧 ID、失效引用、新旧文件同号、URL、新日志、已转换日志及重复执行。最终实库验收应以旧、新附件解析出的 URL 对比,而不是要求转换后的 JSON 字节仍与旧 ID 数组一致。 diff --git a/docs/sw6-to-kra/alert-records-http-2026-11-03.json b/docs/sw6-to-kra/alert-records-http-2026-11-03.json new file mode 100644 index 0000000..7828306 --- /dev/null +++ b/docs/sw6-to-kra/alert-records-http-2026-11-03.json @@ -0,0 +1,123 @@ +{ + "scope": "既有 sw7,只读查询及必然拒绝的无效写请求;正向流转另用隔离 SQLite 和 TCP HTTP 测试", + "base_url": "http://127.0.0.1:18000", + "checks": [ + { + "method": "GET", + "path": "/sw/alertRecord/list?pageNum=1&pageSize=10", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "total": 0, + "available": true, + "status_count": {} + }, + { + "method": "GET", + "path": "/sw/alertRecord/list?platformCodes=lx,hw&status=5", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "total": 0, + "available": true, + "status_count": {} + }, + { + "method": "GET", + "path": "/sw/alertRule/ruleTypes", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true + }, + { + "method": "GET", + "path": "/sw/business/list?pageNum=1&pageSize=500", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true + }, + { + "method": "GET", + "path": "/sw/alertRecord/list?businessId=10007", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "total": 0, + "available": false, + "status_count": {} + }, + { + "method": "GET", + "path": "/sw/alertRecord/list?businessId=10001", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "total": 0, + "available": true, + "status_count": {} + }, + { + "method": "GET", + "path": "/sw/alertRecord/detail/0", + "http": 200, + "code": 7, + "msg": "预警记录参数不正确", + "passed": true + }, + { + "method": "POST", + "path": "/sw/alertRecord/startProcess", + "http": 200, + "code": 7, + "msg": "预警记录参数不正确", + "passed": true + }, + { + "method": "POST", + "path": "/sw/alertRecord/submitAudit", + "http": 200, + "code": 7, + "msg": "预警记录参数不正确", + "passed": true + }, + { + "method": "POST", + "path": "/sw/alertRecord/approve", + "http": 200, + "code": 7, + "msg": "预警记录参数不正确", + "passed": true + }, + { + "method": "POST", + "path": "/sw/alertRecord/reject", + "http": 200, + "code": 7, + "msg": "预警记录参数不正确", + "passed": true + }, + { + "method": "POST", + "path": "/sw/alertRecord/reProcess", + "http": 200, + "code": 7, + "msg": "预警记录参数不正确", + "passed": true + }, + { + "method": "POST", + "path": "/sw/alertRecord/addNote", + "http": 200, + "code": 7, + "msg": "预警记录参数不正确", + "passed": true + } + ], + "passed": true +} diff --git a/docs/sw6-to-kra/alert-records-page-2026-11-03.json b/docs/sw6-to-kra/alert-records-page-2026-11-03.json new file mode 100644 index 0000000..ffc8840 --- /dev/null +++ b/docs/sw6-to-kra/alert-records-page-2026-11-03.json @@ -0,0 +1,116 @@ +{ + "method": "真实 Chrome 渲染;受控身份、预警数据、写接口和图片上传;未修改真实业务数据", + "results": [ + { + "level": 0, + "stateButtons": [ + { + "status": 1, + "label": "处理" + }, + { + "status": 2, + "label": "继续处理" + }, + { + "status": 3, + "label": "查看" + }, + { + "status": 4, + "label": "重新处理" + }, + { + "status": 5, + "label": "查看" + } + ], + "passed": true, + "pageErrors": [] + }, + { + "level": 1, + "stateButtons": [ + { + "status": 1, + "label": "处理" + }, + { + "status": 2, + "label": "继续处理" + }, + { + "status": 3, + "label": "审核" + }, + { + "status": 4, + "label": "重新处理" + }, + { + "status": 5, + "label": "查看" + } + ], + "passed": true, + "workflowAndAttachments": true, + "pasteDropAndRemove": true, + "pageErrors": [] + }, + { + "level": 2, + "stateButtons": [ + { + "status": 1, + "label": "处理" + }, + { + "status": 2, + "label": "继续处理" + }, + { + "status": 3, + "label": "查看" + }, + { + "status": 4, + "label": "重新处理" + }, + { + "status": 5, + "label": "查看" + } + ], + "passed": true, + "statusCardsAndUnavailable": true, + "pageErrors": [] + }, + { + "level": 3, + "stateButtons": [ + { + "status": 1, + "label": "处理" + }, + { + "status": 2, + "label": "继续处理" + }, + { + "status": 3, + "label": "查看" + }, + { + "status": 4, + "label": "重新处理" + }, + { + "status": 5, + "label": "查看" + } + ], + "passed": true, + "pageErrors": [] + } + ] +} diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index ddce203..c99ef30 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -23,7 +23,7 @@ | --- | --- | --- | | 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 | | 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定和旗下渠道已接入,画像及 OA 同步待迁移 | -| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则已接入,记录和工作流待迁移 | +| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入,生成任务及消息待迁移 | | 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 待实现 | | 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 | | 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 | @@ -63,4 +63,10 @@ - 预警规则七个接口及 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`。 +- 预警记录八个接口与列表、处理抽屉已接入。列表、详情和写入同时按所属一级商务及本人/下级的已通过绑定隔离,渠道键包含平台,保留停用一级渠道的历史预警。`businessId` 筛选取指定商务渠道范围与查看范围的交集,不误用预警归属一级字段。状态汇总仅随范围、平台和指定渠道变化,不受名称、分类、指标及列表状态筛选影响。 +- 完整工作流为未处理→处理中→待审核→已处理,或待审核→审核驳回→处理中后重新提交。审核只允许一级商务,超管等级为零时也不能审核。状态条件在带权限条件的行锁事务中检查,状态日志及可选备注共同提交;独立备注不改状态,保留原接口允许已关闭记录追加备注的行为(旧页面只读态仍不展示备注编辑区)。 +- 备注图片复用 Kra 上传接口,支持选取、拖拽、粘贴、暂存预览和移除;添加备注时才上传。修复旧前后端矛盾:先要求非空备注再上传,部分上传或备注失败重试时复用已上传结果。历史数字附件批量查询新文件表,URL 附件保持原值,异常 JSON 视为空附件;记录/规则/日志大 ID 使用字符串响应,避免浏览器精度丢失。 +- 补充 `202611030007_business_alert_attachments`,针对原 SQL 遗留的日志数字附件做文件 ID 映射。只转换与 `bak_sw2kra_20261103_sys_alert_operation_log` 备份相符的旧日志,新日志、URL 或已变化引用不改动;缺失旧文件不解释为同号新文件,原引用保留在备份。详见 `alert-attachment-migration.md`。 +- 预警记录批次全量 `go test ./...`、前端 ESLint 与生产构建通过。隔离 SQLite + 实际 TCP HTTP 覆盖全部处理动作、驳回重提、跨商务/平台拒绝、大 ID、附件读取与缺表场景;仓储测试覆盖日志失败回滚与重复请求。MySQL 行锁的真实并发验证留待最终隔离旧库验收。`alert-records-http-2026-11-03.json` 记录当前 sw7 的真实会话请求;当前预警零条,未创建真实预警或上传文件。原商务 45 条、绑定 58 条的 HTTP 回归继续通过。 +- `alert-records-page-2026-11-03.json` 记录真实 Chrome 下四个等级、五种状态的入口检查,以及完整抽屉流程、附件延迟上传、状态卡片筛选、缺渠道数据提示。浏览器业务数据、处理写入和上传使用受控替身;正向服务端行为另由前述 TCP HTTP 测试覆盖。 - 当前没有执行最终旧数据库复制与迁移验收。 diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md index 4670fe0..7c17d76 100644 --- a/docs/sw6-to-kra/page-parity.md +++ b/docs/sw6-to-kra/page-parity.md @@ -35,7 +35,7 @@ ## 其他页面 -渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析、排行榜和预警列表,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。 +渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。 ## 预警规则 @@ -51,7 +51,27 @@ | 删除需要确认 | 已实现,后端软删除 | | 预警触发去重说明 | 按最终后端描述平台、渠道、类型的未关闭记录去重;生成任务仍待迁移 | -验证:`alert-rules-http-2026-11-03.json`、`alert-rules-page-2026-11-03.json`,以及隔离数据的真实 TCP HTTP 生命周期测试。预警记录及审核流程仍待迁移。 +验证:`alert-rules-http-2026-11-03.json`、`alert-rules-page-2026-11-03.json`,以及隔离数据的真实 TCP HTTP 生命周期测试。预警检测与生成任务仍待迁移。 + +## 预警列表与处理抽屉 + +来源:`sw/web/src/views/AlertCenter/AlertList.vue`、`components/AlertHandleDialog.vue`,并核对最终 `SysAlertRecordServiceImpl` 和 `SysAlertOperationLogServiceImpl`。 + +| 旧页面行为 | 新页面状态 | +| --- | --- | +| 顶部五种状态卡片,点击筛选、再次点击取消 | 已实现;状态卡片统计保留原后端口径 | +| 平台默认全选;规则名称、类型、状态、归属商务筛选 | 已实现;平台取消全选时清空列表,不误查询全部 | +| 所属平台、触发时间、规则名称、类型、预警对象、归属商务、内容、状态、操作 | 已实现,触发时间按月日时分显示 | +| 行操作按五种状态显示处理、继续处理、审核/查看、重新处理、查看 | 已实现;待审核仅一级显示审核,等级为零的超管显示查看 | +| 右侧处理/详情抽屉展示摘要与不可删除的操作记录 | 已实现,日志按时间、ID 倒序,动作编码映射中文 | +| 只读态隐藏备注与写入按钮 | 已实现;详情最新状态与服务端 canAudit 同时决定可操作性 | +| 开始、提交、驳回、重新处理需要确认;审核通过直接提交 | 已对齐 | +| 操作成功刷新详情与列表,抽屉保持打开 | 已实现,状态变化后按钮和只读态同步更新 | +| 图片选取、拖拽、粘贴、预览、移除,添加备注时才上传 | 已实现;失败重试复用已上传结果,暂存图片阻止直接流转 | +| 旧页面允许只选图片,后端要求非空备注 | 按原后端契约提前校验备注,避免先上传后失败 | +| 缺渠道表无法判断可见范围 | 返回 available=false,卡片显示未接入,不把未知范围当成真实零 | + +验证:`alert-records-http-2026-11-03.json`、`alert-records-page-2026-11-03.json`;后端完整状态流转通过独立 SQLite 和真实 TCP HTTP 测试,浏览器核对四个等级和五种状态的入口。预警检测/生成任务仍未接入,不因列表和工作流完成而宣称整个预警链路完成。 ## 登录页 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index f554e7e..8d82136 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, business.NewAlertRuleUsecase) +var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase) diff --git a/internal/biz/business/alert_record.go b/internal/biz/business/alert_record.go new file mode 100644 index 0000000..554bbe9 --- /dev/null +++ b/internal/biz/business/alert_record.go @@ -0,0 +1,210 @@ +package business + +import ( + "context" + "strings" + "time" + + "github.com/go-kratos/kratos/v3/errors" +) + +var ( + ErrAlertRecordInput = errors.BadRequest("SW_ALERT_RECORD_INPUT", "预警记录参数不正确") + ErrAlertRecordNotFound = errors.NotFound("SW_ALERT_RECORD_NOT_FOUND", "预警记录不存在") + ErrAlertRecordScope = errors.Forbidden("SW_ALERT_RECORD_SCOPE", "无权限查看该预警记录") + ErrAlertAuditLevel = errors.Forbidden("SW_ALERT_AUDIT_LEVEL", "仅1级商务可审核预警") + ErrAlertChannelsUnavailable = errors.BadRequest("SW_ALERT_CHANNELS_UNAVAILABLE", "渠道数据尚未接入,暂时无法核对预警可见范围") + ErrAlertRecordChanged = errors.Conflict("SW_ALERT_RECORD_CHANGED", "预警状态已变化,请刷新后重试") + ErrAlertNoteEmpty = errors.BadRequest("SW_ALERT_NOTE_EMPTY", "备注内容不能为空") +) + +type AlertRecord struct { + ID, RuleID, TargetPromoteID uint64 + PlatformCode, PlatformName, RuleName, RuleType string + MonitorMetric *int + TargetPromoteName string + BusinessID uint + BusinessName, AlertContent string + Status int + CreatedAt, UpdatedAt time.Time +} + +type AlertLog struct { + ID, AlertID uint64 + OperatorID uint + OperatorName string + LogType int + ActionDetail, NoteContent string + // 写入兼容旧文件 ID 与 URL,读取由仓储批量解析成附件 URL。 + AttachmentIDs, Attachments []string + CreatedAt time.Time +} + +type AlertRecordScope struct { + Owner LevelOneScope + Viewer UserScope + Selected *UserScope +} + +type AlertRecordFilter struct { + PlatformCodes []string + TargetPromoteID *uint64 + BusinessID *uint + TargetPromoteName, RuleName, RuleType, BusinessName string + MonitorMetric, Status *int +} + +type AlertRecordPage struct { + Rows []*AlertRecord + Total int64 + StatusCount map[int]int64 + Available bool +} + +type AlertRecordDetail struct { + Record *AlertRecord + Logs []*AlertLog + CanAudit bool +} + +type AlertRecordRepo interface { + List(context.Context, AlertRecordScope, AlertRecordFilter, ...ListOption) (*AlertRecordPage, error) + Detail(context.Context, AlertRecordScope, uint64) (*AlertRecordDetail, error) + // 在同一事务和行锁内校验当前记录、执行业务回调、写入状态与日志。 + WithRecord(context.Context, AlertRecordScope, uint64, func(*AlertRecord) ([]*AlertLog, error)) error +} + +type AlertAction string + +const ( + AlertStart AlertAction = "start" + AlertSubmit AlertAction = "submit" + AlertApprove AlertAction = "pass" + AlertReject AlertAction = "reject" + AlertReprocess AlertAction = "reprocess" +) + +type AlertRecordUsecase struct { + repo AlertRecordRepo + accounts *AccountUsecase +} + +func NewAlertRecordUsecase(repo AlertRecordRepo, accounts *AccountUsecase) *AlertRecordUsecase { + return &AlertRecordUsecase{repo: repo, accounts: accounts} +} + +func alertUserScope(a *Account) UserScope { + if a == nil { + return UserScope{Empty: true} + } + return UserScope{SelfID: a.ID, IncludeSelf: true, OnlySelf: a.Level >= 3, DescendantPath: descendantPath(a)} +} + +func (uc *AlertRecordUsecase) scope(ctx context.Context, selected *uint) (AlertRecordScope, *Account, error) { + actor, account, err := uc.accounts.actor(ctx) + if err != nil { + return AlertRecordScope{}, nil, err + } + scope := AlertRecordScope{Owner: LevelOneOwner(actor, account), Viewer: alertUserScope(account)} + if actor.AuthorityID == 888 { + scope.Viewer = UserScope{All: true} + } + if selected != nil { + target, err := uc.accounts.repo.Find(ctx, *selected) + if err != nil && !errors.IsNotFound(err) { + return scope, nil, err + } + // 指定商务只增加渠道交集条件,不能替换查看者范围或预警归属一级商务。 + s := alertUserScope(target) + scope.Selected = &s + } + return scope, account, nil +} + +func (uc *AlertRecordUsecase) List(ctx context.Context, filter AlertRecordFilter, options ...ListOption) (*AlertRecordPage, error) { + scope, _, err := uc.scope(ctx, filter.BusinessID) + if err != nil { + return nil, err + } + return uc.repo.List(ctx, scope, filter, options...) +} + +func (uc *AlertRecordUsecase) Detail(ctx context.Context, id uint64) (*AlertRecordDetail, error) { + scope, account, err := uc.scope(ctx, nil) + if err != nil { + return nil, err + } + detail, err := uc.repo.Detail(ctx, scope, id) + if err != nil { + return nil, err + } + // 审核只看商务等级,与规则管理的超管例外不同。 + detail.CanAudit = account.Level == 1 + return detail, nil +} + +func alertTransition(status int, action AlertAction) (int, error) { + var from, to int + var message string + switch action { + case AlertStart: + from, to, message = 1, 2, "仅未处理状态的预警可开始处理" + case AlertSubmit: + from, to, message = 2, 3, "仅处理中状态的预警可提交审核" + case AlertApprove: + from, to, message = 3, 5, "仅待审核状态的预警可审核通过" + case AlertReject: + from, to, message = 3, 4, "仅待审核状态的预警可审核驳回" + case AlertReprocess: + from, to, message = 4, 2, "仅审核驳回状态的预警可重新处理" + default: + return status, ErrAlertRecordInput + } + if status != from { + return status, errors.BadRequest("SW_ALERT_RECORD_STATUS", message) + } + return to, nil +} + +func (uc *AlertRecordUsecase) Process(ctx context.Context, id uint64, action AlertAction, note string) error { + scope, account, err := uc.scope(ctx, nil) + if err != nil { + return err + } + if (action == AlertApprove || action == AlertReject) && account.Level != 1 { + return ErrAlertAuditLevel + } + return uc.repo.WithRecord(ctx, scope, id, func(record *AlertRecord) ([]*AlertLog, error) { + status, err := alertTransition(record.Status, action) + if err != nil { + return nil, err + } + record.Status = status + logs := []*AlertLog{{AlertID: id, OperatorID: account.ID, OperatorName: account.Username, LogType: 1, ActionDetail: string(action)}} + if strings.TrimSpace(note) != "" { + logs = append(logs, &AlertLog{AlertID: id, OperatorID: account.ID, OperatorName: account.Username, LogType: 2, NoteContent: note}) + } + return logs, nil + }) +} + +func (uc *AlertRecordUsecase) AddNote(ctx context.Context, id uint64, note string, attachments []string) error { + if strings.TrimSpace(note) == "" { + return ErrAlertNoteEmpty + } + scope, account, err := uc.scope(ctx, nil) + if err != nil { + return err + } + return uc.repo.WithRecord(ctx, scope, id, func(_ *AlertRecord) ([]*AlertLog, error) { + return []*AlertLog{{AlertID: id, OperatorID: account.ID, OperatorName: account.Username, LogType: 2, NoteContent: note, AttachmentIDs: attachments}}, nil + }) +} + +func AlertStatusLabel(status int) string { + return map[int]string{1: "未处理", 2: "处理中", 3: "待审核", 4: "审核驳回", 5: "已处理"}[status] +} + +func AlertLogTypeLabel(logType int) string { + return map[int]string{1: "状态变更操作", 2: "添加独立备注"}[logType] +} diff --git a/internal/biz/business/alert_record_test.go b/internal/biz/business/alert_record_test.go new file mode 100644 index 0000000..7906a2c --- /dev/null +++ b/internal/biz/business/alert_record_test.go @@ -0,0 +1,122 @@ +package business + +import ( + "context" + "testing" +) + +type alertRecordRepoFake struct { + AlertRecordRepo + scope AlertRecordScope + status int + logs []*AlertLog + calls int +} + +func (r *alertRecordRepoFake) List(_ context.Context, scope AlertRecordScope, _ AlertRecordFilter, _ ...ListOption) (*AlertRecordPage, error) { + r.scope = scope + return &AlertRecordPage{}, nil +} +func (r *alertRecordRepoFake) WithRecord(_ context.Context, scope AlertRecordScope, id uint64, fn func(*AlertRecord) ([]*AlertLog, error)) error { + r.scope = scope + r.calls++ + record := &AlertRecord{ID: id, Status: r.status} + logs, err := fn(record) + if err != nil { + return err + } + r.status = record.Status + r.logs = append(r.logs, logs...) + return nil +} +func (r *alertRecordRepoFake) Detail(_ context.Context, _ AlertRecordScope, id uint64) (*AlertRecordDetail, error) { + return &AlertRecordDetail{Record: &AlertRecord{ID: id}}, nil +} + +func alertRecordFixture() (*AlertRecordUsecase, *alertRecordRepoFake) { + a := &accountRepoFake{accounts: map[uint]*Account{ + 1: {ID: 1, Level: 0, IDPath: "0", Username: "root"}, + 7: {ID: 7, Level: 1, IDPath: "0", Username: "owner"}, + 8: {ID: 8, Level: 2, IDPath: "0,7", Username: "child"}, + 9: {ID: 9, Level: 3, IDPath: "0,7,8", Username: "leaf"}, + 99: {ID: 99, Level: 1, IDPath: "0", Username: "other"}, + }} + r := &alertRecordRepoFake{status: 1} + return NewAlertRecordUsecase(r, NewAccountUsecase(a)), r +} + +func TestAlertRecordScopeKeepsOwnerAndIntersectsSelectedBusiness(t *testing.T) { + uc, repo := alertRecordFixture() + selected := uint(99) + if _, err := uc.List(accountContext(8, 902), AlertRecordFilter{BusinessID: &selected}); err != nil { + t.Fatal(err) + } + if repo.scope.Owner.UserID != 7 || repo.scope.Viewer.SelfID != 8 || !repo.scope.Viewer.IncludeSelf || repo.scope.Selected.SelfID != 99 { + t.Fatalf("范围被商务筛选覆盖: %+v", repo.scope) + } + if _, err := uc.List(accountContext(9, 903), AlertRecordFilter{}); err != nil || !repo.scope.Viewer.OnlySelf { + t.Fatal("三级应仅使用自身绑定") + } + if _, err := uc.List(accountContext(1, 888), AlertRecordFilter{BusinessID: &selected}); err != nil || !repo.scope.Owner.All || !repo.scope.Viewer.All || repo.scope.Selected.SelfID != 99 { + t.Fatal("超管指定商务时仍须收窄渠道") + } + missing := uint(404) + if _, err := uc.List(accountContext(7, 901), AlertRecordFilter{BusinessID: &missing}); err != nil || !repo.scope.Selected.Empty { + t.Fatal("不存在的商务应返回空范围") + } +} + +func TestAlertRecordStateMatrix(t *testing.T) { + cases := []struct { + action AlertAction + from, to int + }{{AlertStart, 1, 2}, {AlertSubmit, 2, 3}, {AlertApprove, 3, 5}, {AlertReject, 3, 4}, {AlertReprocess, 4, 2}} + for _, tc := range cases { + for status := 1; status <= 5; status++ { + uc, repo := alertRecordFixture() + repo.status = status + err := uc.Process(accountContext(7, 901), 100, tc.action, "处理说明") + if status == tc.from { + if err != nil || repo.status != tc.to || len(repo.logs) != 2 || repo.logs[0].ActionDetail != string(tc.action) || repo.logs[1].NoteContent != "处理说明" { + t.Fatalf("合法流转失败: %s/%d %+v %v", tc.action, status, repo, err) + } + if repo.logs[0].OperatorID != 7 || repo.logs[0].OperatorName != "owner" { + t.Fatal("操作人须由登录账号决定") + } + } else if err == nil || repo.status != status || len(repo.logs) != 0 { + t.Fatalf("非法流转写入状态或日志: %s/%d", tc.action, status) + } + } + } +} + +func TestAlertRecordAuditRequiresLevelOneIncludingAdmin(t *testing.T) { + for _, id := range []uint{1, 8, 9} { + uc, repo := alertRecordFixture() + authority := uint(901) + if id == 1 { + authority = 888 + } + ctx := accountContext(id, authority) + for _, action := range []AlertAction{AlertApprove, AlertReject} { + if err := uc.Process(ctx, 100, action, ""); err != ErrAlertAuditLevel || repo.calls != 0 { + t.Fatal("非一级商务审核未被拒绝") + } + } + detail, err := uc.Detail(ctx, 100) + if err != nil || detail.CanAudit { + t.Fatal("详情错误授予审核权限") + } + } +} + +func TestAlertRecordIndependentNotePreservesClosedState(t *testing.T) { + uc, repo := alertRecordFixture() + repo.status = 5 + if err := uc.AddNote(accountContext(8, 902), 100, " ", []string{"1"}); err != ErrAlertNoteEmpty { + t.Fatal("空备注不应绕过校验") + } + if err := uc.AddNote(accountContext(8, 902), 100, "后续补充", []string{"1"}); err != nil || repo.status != 5 || len(repo.logs) != 1 || repo.logs[0].LogType != 2 { + t.Fatal("独立备注改变了状态") + } +} diff --git a/internal/data/business/alert_attachment_migration.go b/internal/data/business/alert_attachment_migration.go new file mode 100644 index 0000000..7b6b673 --- /dev/null +++ b/internal/data/business/alert_attachment_migration.go @@ -0,0 +1,87 @@ +package business + +import ( + "encoding/json" + "slices" + "strconv" + + "gorm.io/gorm" +) + +// 旧迁移 SQL 保留日志附件 JSON。只转换有原始备份且尚未变更的旧日志,避免误映射新文件 ID。 +func migrateAlertAttachments(db *gorm.DB) error { + tables, err := db.Migrator().GetTables() + if err != nil { + return err + } + for _, name := range []string{"sys_alert_operation_log", "sw_kra_id_map", "bak_sw2kra_20261103_sys_alert_operation_log"} { + if !slices.Contains(tables, name) { + return nil + } + } + var lastID uint64 + for { + var rows []struct { + ID uint64 + Current *string + Legacy *string + } + if err := db.Table("sys_alert_operation_log l").Select("l.id,l.attachment_ids AS current,b.attachment_ids AS legacy").Joins("JOIN bak_sw2kra_20261103_sys_alert_operation_log b ON b.id=l.id").Where("l.id > ?", lastID).Order("l.id").Limit(500).Scan(&rows).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + pending := map[uint64][]int64{} + oldIDs := []string{} + for _, row := range rows { + lastID = row.ID + current, ids := decodeAlertAttachments(row.Current) + legacy, legacyIDs := decodeAlertAttachments(row.Legacy) + if len(ids) == 0 || len(legacyIDs) == 0 || !slices.Equal(current, legacy) { + continue + } + pending[row.ID] = ids + for _, id := range ids { + oldIDs = append(oldIDs, strconv.FormatInt(id, 10)) + } + } + if len(pending) == 0 { + continue + } + mapped := map[int64]int64{} + for start := 0; start < len(oldIDs); start += 500 { + var mappings []struct { + OldID string + NewID int64 + } + if err := db.Table("sw_kra_id_map").Select("old_id,new_id").Where("entity = ? AND old_id IN ?", "file", oldIDs[start:min(start+500, len(oldIDs))]).Scan(&mappings).Error; err != nil { + return err + } + for _, m := range mappings { + id, err := strconv.ParseInt(m.OldID, 10, 64) + if err == nil { + mapped[id] = m.NewID + } + } + } + if err := db.Transaction(func(tx *gorm.DB) error { + for logID, ids := range pending { + newIDs := []int64{} + for _, id := range ids { + // 缺失旧文件原本不展示,不能把旧 ID 当成同号的新文件;原始引用仍保留在备份中。 + if newID, ok := mapped[id]; ok { + newIDs = append(newIDs, newID) + } + } + raw, _ := json.Marshal(newIDs) + if err := tx.Table("sys_alert_operation_log").Where("id = ?", logID).Update("attachment_ids", string(raw)).Error; err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + } +} diff --git a/internal/data/business/alert_record.go b/internal/data/business/alert_record.go new file mode 100644 index 0000000..591d169 --- /dev/null +++ b/internal/data/business/alert_record.go @@ -0,0 +1,329 @@ +package business + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +type alertRecordRepo struct{ data Provider } + +func NewAlertRecordRepo(data Provider) business.AlertRecordRepo { return &alertRecordRepo{data: data} } + +func toBizAlertRecord(po alertRecordPO) *business.AlertRecord { + return &business.AlertRecord{ID: po.ID, RuleID: po.RuleID, PlatformCode: po.PlatformCode, PlatformName: po.PlatformName, RuleName: po.RuleName, RuleType: po.RuleType, MonitorMetric: po.MonitorMetric, TargetPromoteID: po.TargetPromoteID, TargetPromoteName: po.TargetPromoteName, BusinessID: po.BusinessID, BusinessName: po.BusinessName, AlertContent: po.AlertContent, Status: po.Status, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt} +} + +func alertBindingQuery(db *gorm.DB, scope business.UserScope) *gorm.DB { + q := db.Table("sys_user_bind_platform b").Where("b.status = ? AND b.source_db IN ? AND b.platform_busier_id IS NOT NULL", "0", []string{"lx", "hw", "yq"}) + if !scope.All { + q = q.Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL") + q = applyUserScope(q, scope, "u.") + } + return q +} + +func alertChannelScopes(scope business.AlertRecordScope) []business.UserScope { + var scopes []business.UserScope + if !scope.Viewer.All { + scopes = append(scopes, scope.Viewer) + } + if scope.Selected != nil { + scopes = append(scopes, *scope.Selected) + } + return scopes +} + +// 先判断没有绑定的确定空集,再检查渠道表;缺表不能放宽权限或伪造统计。 +func prepareAlertScope(db *gorm.DB, scope business.AlertRecordScope) (empty, available bool, err error) { + if scope.Owner.Empty { + return true, true, nil + } + scopes := alertChannelScopes(scope) + for _, s := range scopes { + if s.Empty { + return true, true, nil + } + var count int64 + if err := alertBindingQuery(db, s).Count(&count).Error; err != nil { + return false, false, business.ErrStorage + } + if count == 0 { + return true, true, nil + } + } + if len(scopes) == 0 { + return false, true, nil + } + available, err = businessTableAvailable(db, "tab_promote") + return false, available, err +} + +func alertScopedQuery(db *gorm.DB, scope business.AlertRecordScope) *gorm.DB { + q := applyOwnerScope(db.Table("sys_alert_record r"), scope.Owner, "r.business_id") + for _, s := range alertChannelScopes(scope) { + bindings := alertBindingQuery(db, s).Select("1").Where("b.source_db=p.source_db AND b.platform_busier_id=p.busier_id") + // 两个范围独立取交集;只限制一级渠道,停用渠道的历史预警仍可见。 + channels := db.Table("tab_promote p").Select("1").Where("p.id=r.target_promote_id AND p.source_db=r.platform_code AND p.promote_level = ?", 1).Where("EXISTS (?)", bindings) + q = q.Where("EXISTS (?)", channels) + } + return q +} + +func (r *alertRecordRepo) List(ctx context.Context, scope business.AlertRecordScope, filter business.AlertRecordFilter, options ...business.ListOption) (*business.AlertRecordPage, error) { + db := r.data.DB().WithContext(ctx) + page := &business.AlertRecordPage{Rows: []*business.AlertRecord{}, StatusCount: map[int]int64{}, Available: true} + empty, available, err := prepareAlertScope(db, scope) + if err != nil { + return nil, err + } + page.Available = available + if empty || !available { + return page, nil + } + base := func() *gorm.DB { + q := alertScopedQuery(db, scope) + if len(filter.PlatformCodes) > 0 { + q = q.Where("r.platform_code IN ?", filter.PlatformCodes) + } + if filter.TargetPromoteID != nil { + q = q.Where("r.target_promote_id = ?", *filter.TargetPromoteID) + } + return q + } + q := base() + for _, f := range []struct{ column, value string }{{"target_promote_name", filter.TargetPromoteName}, {"rule_name", filter.RuleName}, {"business_name", filter.BusinessName}} { + if f.value != "" { + q = q.Where("r."+f.column+" LIKE ?", "%"+f.value+"%") + } + } + if filter.RuleType != "" { + q = q.Where("r.rule_type = ?", filter.RuleType) + } + if filter.MonitorMetric != nil { + q = q.Where("r.monitor_metric = ?", *filter.MonitorMetric) + } + if filter.Status != nil { + q = q.Where("r.status = ?", *filter.Status) + } + if err := q.Count(&page.Total).Error; err != nil { + return nil, business.ErrStorage + } + o := business.NewListOptions(options...) + var pos []alertRecordPO + if err := q.Select("r.*").Order("r.created_at DESC,r.id DESC").Offset(o.Offset).Limit(o.Limit).Scan(&pos).Error; err != nil { + return nil, business.ErrStorage + } + for _, po := range pos { + page.Rows = append(page.Rows, toBizAlertRecord(po)) + } + // 顶部汇总忽略名称、类型、指标与状态筛选,只保留数据范围、平台和指定渠道。 + var counts []struct { + Status int + Count int64 + } + if err := base().Select("r.status,COUNT(*) AS count").Group("r.status").Scan(&counts).Error; err != nil { + return nil, business.ErrStorage + } + for _, c := range counts { + page.StatusCount[c.Status] = c.Count + } + return page, nil +} + +func findScopedAlert(db *gorm.DB, scope business.AlertRecordScope, id uint64, lock bool) (*alertRecordPO, error) { + empty, available, err := prepareAlertScope(db, scope) + if err != nil { + return nil, err + } + if empty { + return nil, business.ErrAlertRecordScope + } + if !available { + return nil, business.ErrAlertChannelsUnavailable + } + // 锁定读取本身带权限条件,避免先读旧快照的权限再锁到已换归属的记录。 + q := alertScopedQuery(db, scope).Select("r.*") + if lock { + q = q.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var po alertRecordPO + if err := q.Where("r.id = ?", id).Take(&po).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + var count int64 + if err := db.Model(&alertRecordPO{}).Where("id = ?", id).Count(&count).Error; err != nil { + return nil, business.ErrStorage + } + if count > 0 { + return nil, business.ErrAlertRecordScope + } + return nil, business.ErrAlertRecordNotFound + } + return nil, business.ErrStorage + } + return &po, nil +} + +func (r *alertRecordRepo) Detail(ctx context.Context, scope business.AlertRecordScope, id uint64) (*business.AlertRecordDetail, error) { + var detail *business.AlertRecordDetail + err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + po, err := findScopedAlert(tx, scope, id, false) + if err != nil { + return err + } + logs, err := alertLogs(tx, id) + if err != nil { + return err + } + detail = &business.AlertRecordDetail{Record: toBizAlertRecord(*po), Logs: logs} + return nil + }) + return detail, storageError(err) +} + +func (r *alertRecordRepo) WithRecord(ctx context.Context, scope business.AlertRecordScope, id uint64, fn func(*business.AlertRecord) ([]*business.AlertLog, error)) error { + return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + po, err := findScopedAlert(tx, scope, id, true) + if err != nil { + return err + } + record := toBizAlertRecord(*po) + logs, err := fn(record) + if err != nil { + return err + } + now := time.Now() + if record.Status != po.Status { + result := tx.Model(&alertRecordPO{}).Where("id = ? AND status = ?", id, po.Status).Updates(map[string]any{"status": record.Status, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return business.ErrAlertRecordChanged + } + } + for _, log := range logs { + attachments := encodeAlertAttachments(log.AttachmentIDs) + row := alertLogPO{AlertID: id, OperatorID: log.OperatorID, OperatorName: log.OperatorName, LogType: log.LogType, ActionDetail: log.ActionDetail, NoteContent: log.NoteContent, AttachmentIDs: attachments, CreatedAt: now} + if err := tx.Create(&row).Error; err != nil { + return err + } + } + return nil + })) +} + +func encodeAlertAttachments(values []string) *string { + if len(values) == 0 { + return nil + } + ids := make([]int64, 0, len(values)) + for _, value := range values { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil { + ids = nil + break + } + ids = append(ids, id) + } + var raw []byte + if ids != nil { + raw, _ = json.Marshal(ids) + } else { + raw, _ = json.Marshal(values) + } + value := string(raw) + return &value +} + +func decodeAlertAttachments(raw *string) (values []string, ids []int64) { + if raw == nil { + return nil, nil + } + var entries []json.RawMessage + if json.Unmarshal([]byte(*raw), &entries) != nil { + return nil, nil + } + allNumeric := true + for _, entry := range entries { + var value string + if json.Unmarshal(entry, &value) != nil { + var n json.Number + if json.Unmarshal(entry, &n) != nil || n == "" { + return nil, nil + } + value = n.String() + } + values = append(values, value) + id, err := strconv.ParseInt(value, 10, 64) + if err != nil { + allNumeric = false + } else { + ids = append(ids, id) + } + } + if !allNumeric { + ids = nil + } + return values, ids +} + +func alertLogs(db *gorm.DB, alertID uint64) ([]*business.AlertLog, error) { + var pos []alertLogPO + if err := db.Where("alert_id = ?", alertID).Order("created_at DESC,id DESC").Find(&pos).Error; err != nil { + return nil, business.ErrStorage + } + logs := make([]*business.AlertLog, 0, len(pos)) + idsByLog := map[uint64][]int64{} + ids := map[int64]bool{} + for _, po := range pos { + values, numeric := decodeAlertAttachments(po.AttachmentIDs) + log := &business.AlertLog{ID: po.ID, AlertID: po.AlertID, OperatorID: po.OperatorID, OperatorName: po.OperatorName, LogType: po.LogType, ActionDetail: po.ActionDetail, NoteContent: po.NoteContent, CreatedAt: po.CreatedAt, Attachments: []string{}} + if len(numeric) > 0 { + idsByLog[po.ID] = numeric + for _, id := range numeric { + ids[id] = true + } + } else if len(values) > 0 { + log.Attachments = values + } + logs = append(logs, log) + } + if len(ids) == 0 { + return logs, nil + } + keys := make([]int64, 0, len(ids)) + for id := range ids { + keys = append(keys, id) + } + // 一次批量查询代替旧实现逐日志、逐附件查询。 + urls := map[int64]string{} + for start := 0; start < len(keys); start += 500 { + var files []struct { + ID int64 + URL string + } + if err := db.Table("media_file_upload_and_downloads").Select("id,url").Where("id IN ? AND deleted_at IS NULL", keys[start:min(start+500, len(keys))]).Scan(&files).Error; err != nil { + return nil, business.ErrStorage + } + for _, file := range files { + urls[file.ID] = file.URL + } + } + for _, log := range logs { + seen := map[int64]bool{} + for _, id := range idsByLog[log.ID] { + if url, exists := urls[id]; exists && !seen[id] { + log.Attachments = append(log.Attachments, url) + seen[id] = true + } + } + } + return logs, nil +} diff --git a/internal/data/business/alert_record_test.go b/internal/data/business/alert_record_test.go new file mode 100644 index 0000000..a5007b1 --- /dev/null +++ b/internal/data/business/alert_record_test.go @@ -0,0 +1,215 @@ +package business + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "gorm.io/gorm" + "kra/internal/biz/business" +) + +func prepareAlertRecords(t *testing.T) (*gorm.DB, business.AlertRecordRepo) { + t.Helper() + db := prepareAccounts(t) + for _, sql := range []string{ + "INSERT INTO sys_users(id,username,level,parent_id,id_path) VALUES(7,'owner',1,0,'0'),(8,'child',2,7,'0,7'),(9,'leaf',3,8,'0,7,8'),(10,'sibling',2,7,'0,7'),(99,'other',1,0,'0')", + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(10,'hw',14,'0'),(9,'hw',15,'3'),(99,'yq',14,'0')", + "INSERT INTO tab_promote VALUES(100,'lx',14,1,0),(101,'lx',14,2,1),(100,'hw',14,1,1),(102,'hw',15,1,1),(100,'yq',14,1,1)", + "CREATE TABLE media_file_upload_and_downloads(id INTEGER PRIMARY KEY,url TEXT,deleted_at DATETIME)", + "INSERT INTO media_file_upload_and_downloads(id,url) VALUES(1,'/files/one.png'),(2,'/files/two.png')", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + stamp := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC) + pos := []alertRecordPO{ + {ID: 1, PlatformCode: "lx", TargetPromoteID: 100, BusinessID: 7, RuleName: "注册", RuleType: "register_anomaly", Status: 1, CreatedAt: stamp, UpdatedAt: stamp}, + {ID: 2, PlatformCode: "lx", TargetPromoteID: 100, BusinessID: 7, RuleName: "充值", RuleType: "recharge_anomaly", Status: 3, CreatedAt: stamp, UpdatedAt: stamp}, + {ID: 3, PlatformCode: "hw", TargetPromoteID: 100, BusinessID: 7, Status: 2, CreatedAt: stamp}, + {ID: 4, PlatformCode: "yq", TargetPromoteID: 100, BusinessID: 99, Status: 5, CreatedAt: stamp}, + {ID: 5, PlatformCode: "lx", TargetPromoteID: 100, BusinessID: 99, Status: 1, CreatedAt: stamp}, + {ID: 6, PlatformCode: "lx", TargetPromoteID: 101, BusinessID: 7, Status: 1, CreatedAt: stamp}, + {ID: 7, PlatformCode: "hw", TargetPromoteID: 102, BusinessID: 7, Status: 1, CreatedAt: stamp}, + } + if err := db.Create(&pos).Error; err != nil { + t.Fatal(err) + } + return db, NewAlertRecordRepo(testData{db}) +} + +func alertTestScope(id uint) business.AlertRecordScope { + return business.AlertRecordScope{Owner: business.LevelOneScope{UserID: 7}, Viewer: business.UserScope{SelfID: id, IncludeSelf: true, OnlySelf: id == 9, DescendantPath: map[uint]string{7: "0,7", 8: "0,7,8", 9: "0,7,8,9"}[id]}} +} + +func TestAlertRecordsScopeAndSummaryUseDifferentFilters(t *testing.T) { + _, repo := prepareAlertRecords(t) + ctx := context.Background() + for _, id := range []uint{8, 9} { + page, err := repo.List(ctx, alertTestScope(id), business.AlertRecordFilter{}) + if err != nil || page.Total != 2 || len(page.Rows) != 2 || page.Rows[0].ID != 2 || page.Rows[1].ID != 1 { + t.Fatalf("渠道状态、平台隔离、一级归属或排序错误: %+v %v", page, err) + } + } + scope := alertTestScope(7) + status := 1 + page, err := repo.List(ctx, scope, business.AlertRecordFilter{RuleName: "注册", Status: &status}) + if err != nil || page.Total != 1 || len(page.StatusCount) != 3 || page.StatusCount[3] != 1 || page.StatusCount[2] != 1 { + t.Fatalf("汇总错误继承名称或状态筛选: %+v %v", page, err) + } + selected := alertTestScope(8).Viewer + scope.Selected = &selected + page, err = repo.List(ctx, scope, business.AlertRecordFilter{}) + if err != nil || page.Total != 2 { + t.Fatalf("二级商务筛选误用了 record.business_id: %+v %v", page, err) + } + outside := business.UserScope{SelfID: 99, OnlySelf: true} + scope.Selected = &outside + page, err = repo.List(ctx, scope, business.AlertRecordFilter{}) + if err != nil || page.Total != 0 || len(page.StatusCount) != 0 { + t.Fatalf("指定其他商务绕过查看范围: %+v %v", page, err) + } + root := business.AlertRecordScope{Owner: business.LevelOneScope{All: true}, Viewer: business.UserScope{All: true}, Selected: &selected} + page, err = repo.List(ctx, root, business.AlertRecordFilter{}) + if err != nil || page.Total != 3 { + t.Fatalf("超管指定商务须收窄渠道但不增加一级归属约束: %+v %v", page, err) + } + if _, err := repo.Detail(ctx, alertTestScope(8), 3); err != business.ErrAlertRecordScope { + t.Fatal("同号不同平台详情被放行") + } + if _, err := repo.Detail(ctx, alertTestScope(8), 5); err != business.ErrAlertRecordScope { + t.Fatal("异常归属预警跨一级商务泄漏") + } +} + +func TestAlertRecordsMissingChannelsFailClosed(t *testing.T) { + db, repo := prepareAlertRecords(t) + if err := db.Exec("DROP TABLE tab_promote").Error; err != nil { + t.Fatal(err) + } + ctx := context.Background() + page, err := repo.List(ctx, alertTestScope(8), business.AlertRecordFilter{}) + if err != nil || page.Available || page.Total != 0 { + t.Fatalf("缺渠道表未明确不可用: %+v %v", page, err) + } + if _, err := repo.Detail(ctx, alertTestScope(8), 1); err != business.ErrAlertChannelsUnavailable { + t.Fatalf("详情应拒绝未知范围: %v", err) + } + root := business.AlertRecordScope{Owner: business.LevelOneScope{All: true}, Viewer: business.UserScope{All: true}} + page, err = repo.List(ctx, root, business.AlertRecordFilter{}) + if err != nil || !page.Available || page.Total != 7 { + t.Fatal("不限范围的超管查询不应依赖渠道表") + } + root.Selected = &business.UserScope{SelfID: 500, OnlySelf: true} + page, err = repo.List(ctx, root, business.AlertRecordFilter{}) + if err != nil || !page.Available || page.Total != 0 { + t.Fatal("无绑定应是确定空集") + } +} + +func TestAlertRecordTransactionRollsBackWhenLogFails(t *testing.T) { + db, repo := prepareAlertRecords(t) + if err := db.Exec("CREATE TRIGGER reject_note BEFORE INSERT ON sys_alert_operation_log WHEN NEW.log_type = 2 BEGIN SELECT RAISE(ABORT, 'test failure'); END").Error; err != nil { + t.Fatal(err) + } + err := repo.WithRecord(context.Background(), alertTestScope(8), 1, func(r *business.AlertRecord) ([]*business.AlertLog, error) { + r.Status = 2 + return []*business.AlertLog{{LogType: 1, ActionDetail: "start"}, {LogType: 2, NoteContent: "备注"}}, nil + }) + if err == nil { + t.Fatal("日志失败应回滚") + } + var record alertRecordPO + if err := db.First(&record, 1).Error; err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&alertLogPO{}).Count(&count) + if record.Status != 1 || count != 0 { + t.Fatal("留下半次状态流转或日志") + } +} + +func TestAlertRecordRepeatedConcurrentActionCreatesOneLog(t *testing.T) { + db, repo := prepareAlertRecords(t) + pool, _ := db.DB() + // SQLite 用单连接串行事务;生产 MySQL 使用同一行锁回调,状态必须在锁内校验。 + pool.SetMaxOpenConns(1) + var success atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 12; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := repo.WithRecord(context.Background(), alertTestScope(8), 1, func(r *business.AlertRecord) ([]*business.AlertLog, error) { + if r.Status != 1 { + return nil, business.ErrAlertRecordChanged + } + r.Status = 2 + return []*business.AlertLog{{LogType: 1, ActionDetail: "start"}}, nil + }) + if err == nil { + success.Add(1) + } + }() + } + wg.Wait() + var count int64 + db.Model(&alertLogPO{}).Count(&count) + if success.Load() != 1 || count != 1 { + t.Fatalf("重复请求写入多次: successes=%d logs=%d", success.Load(), count) + } +} + +func TestAlertRecordLogAttachmentsAndStableOrder(t *testing.T) { + db, repo := prepareAlertRecords(t) + stamp := time.Now() + for i, raw := range []string{`[2,"1",999,2]`, `["/legacy/a.png","https://files.invalid/b.png"]`, `{"bad":true}`} { + value := raw + if err := db.Create(&alertLogPO{ID: uint64(i + 1), AlertID: 1, LogType: 2, AttachmentIDs: &value, CreatedAt: stamp}).Error; err != nil { + t.Fatal(err) + } + } + detail, err := repo.Detail(context.Background(), alertTestScope(8), 1) + if err != nil { + t.Fatal(err) + } + if len(detail.Logs) != 3 || detail.Logs[0].ID != 3 || len(detail.Logs[0].Attachments) != 0 || len(detail.Logs[1].Attachments) != 2 || len(detail.Logs[2].Attachments) != 2 || detail.Logs[2].Attachments[0] != "/files/two.png" { + t.Fatalf("附件或顺序错误: %+v", detail.Logs) + } +} + +func TestAlertAttachmentMigrationUsesLegacyBackupAndIsIdempotent(t *testing.T) { + db, _ := prepareAlertRecords(t) + for _, sql := range []string{ + "CREATE TABLE bak_sw2kra_20261103_sys_alert_operation_log(id INTEGER,attachment_ids TEXT)", + "INSERT INTO sw_kra_id_map(entity,old_id,new_id) VALUES('file','9007199254740993',2),('file','1',1)", + `INSERT INTO sys_alert_operation_log(id,alert_id,attachment_ids) VALUES(10,1,'[9007199254740993,999]'),(11,1,'["/legacy.png"]'),(12,1,'[1]'),(13,1,'[2]')`, + `INSERT INTO bak_sw2kra_20261103_sys_alert_operation_log VALUES(10,'[9007199254740993,999]'),(11,'["/legacy.png"]'),(13,'[9007199254740993]')`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateAlertAttachments(db); err != nil { + t.Fatal(err) + } + } + var rows []alertLogPO + db.Order("id").Find(&rows) + if len(rows) != 4 { + t.Fatal("日志丢失") + } + var ids []int64 + if json.Unmarshal([]byte(*rows[0].AttachmentIDs), &ids) != nil || len(ids) != 1 || ids[0] != 2 { + t.Fatal("大 ID 映射或失效引用处理错误") + } + if *rows[1].AttachmentIDs != `["/legacy.png"]` || *rows[2].AttachmentIDs != `[1]` || *rows[3].AttachmentIDs != `[2]` { + t.Fatal("改动了 URL、新日志或已经转换的附件") + } +} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index 914e8d1..fa7050c 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: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error { + return []migration.Step{{ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {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{}) diff --git a/internal/data/data.go b/internal/data/data.go index 3c5a0c9..c84aae3 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -35,6 +35,7 @@ var ProviderSet = wire.NewSet( databusiness.NewPasswordResetRepo, databusiness.NewBusinessPromoteRepo, databusiness.NewAlertRuleRepo, + databusiness.NewAlertRecordRepo, 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 b8a08b2..e83bc03 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 != 20 { - t.Fatalf("migration versions = %d, want 20", versions) + if versions != 21 { + t.Fatalf("migration versions = %d, want 21", 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 c7a5122..a9756d9 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -9,6 +9,7 @@ import ( func Definition() module.Definition { return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{ Menus: []module.Menu{ + {Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: "warn", Sort: 1}, {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}, diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 2912de3..284e256 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) != 20 { - t.Fatalf("module migrations = %d, want 20", len(got)) + if got := catalog.MigrationSteps(); len(got) != 21 { + t.Fatalf("module migrations = %d, want 21", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 8 || apis != 36 { - t.Fatalf("admin surface = %d menus/%d APIs, want 8/36", menus, apis) + if menus != 9 || apis != 44 { + t.Fatalf("admin surface = %d menus/%d APIs, want 9/44", 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 7911217..eec5cc9 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -41,6 +41,14 @@ 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/alertRecord/list": {group: "预警记录", description: "查询预警列表及状态统计"}, + "GET /sw/alertRecord/detail/:id": {group: "预警记录", description: "查询预警详情及操作记录"}, + "POST /sw/alertRecord/startProcess": {group: "预警记录", description: "开始处理预警", audit: true}, + "POST /sw/alertRecord/submitAudit": {group: "预警记录", description: "提交预警审核", audit: true}, + "POST /sw/alertRecord/approve": {group: "预警记录", description: "审核通过预警", audit: true}, + "POST /sw/alertRecord/reject": {group: "预警记录", description: "审核驳回预警", audit: true}, + "POST /sw/alertRecord/reProcess": {group: "预警记录", description: "重新处理预警", audit: true}, + "POST /sw/alertRecord/addNote": {group: "预警记录", description: "添加预警备注及附件", audit: true}, "GET /sw/alertRule/list": {group: "预警规则", description: "查询预警规则"}, "GET /sw/alertRule/ruleTypes": {group: "预警规则", description: "查询历史预警分类"}, "GET /sw/alertRule/:id": {group: "预警规则", description: "查询规则详情"}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 832975b..5b18854 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{ + AlertRecord: &handler.AlertRecord{}, AlertRule: &handler.AlertRule{}, BusinessPromote: &handler.BusinessPromote{}, Business: &handler.Business{}, @@ -100,7 +101,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":219`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":227`) { t.Fatalf("startup route summary is missing: %s", text) } } diff --git a/internal/server/handler/alert_record.go b/internal/server/handler/alert_record.go new file mode 100644 index 0000000..720b41a --- /dev/null +++ b/internal/server/handler/alert_record.go @@ -0,0 +1,76 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/go-kratos/kratos/v3/errors" + businessbiz "kra/internal/biz/business" + "kra/internal/service/business" + "kra/internal/service/dto" +) + +type AlertRecord struct{ service *business.AlertRecordService } + +func NewAlertRecord(service *business.AlertRecordService) *AlertRecord { + return &AlertRecord{service: service} +} + +func (h *AlertRecord) List(c *gin.Context) { + var req dto.AlertRecordQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "查询参数不正确") + return + } + page, err := h.service.List(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, page) +} + +func (h *AlertRecord) Detail(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + Fail(c, "预警记录 ID 不正确") + return + } + detail, err := h.service.Detail(c.Request.Context(), id) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, detail) +} + +func (h *AlertRecord) Process(action businessbiz.AlertAction) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + var req dto.AlertProcessRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "预警记录参数不正确") + return + } + if err := h.service.Process(c.Request.Context(), &req, action); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) + } +} + +func (h *AlertRecord) AddNote(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + var req dto.AlertNoteRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "预警记录参数不正确") + return + } + if err := h.service.AddNote(c.Request.Context(), &req); 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 865e450..95e1c26 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -9,6 +9,7 @@ var ProviderSet = wire.NewSet( NewBusinessPassword, NewBusinessPromote, NewAlertRule, + NewAlertRecord, 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 9a0d3df..bfffbef 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,6 +1,7 @@ package handler type Set struct { + AlertRecord *AlertRecord AlertRule *AlertRule BusinessPromote *BusinessPromote Business *Business diff --git a/internal/server/router/alert_record.go b/internal/server/router/alert_record.go new file mode 100644 index 0000000..38af353 --- /dev/null +++ b/internal/server/router/alert_record.go @@ -0,0 +1,19 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/biz/business" + "kra/internal/server/handler" +) + +func RegisterAlertRecord(private *gin.RouterGroup, h *handler.AlertRecord) { + g := private.Group("/sw/alertRecord") + g.GET("/list", h.List) + g.GET("/detail/:id", h.Detail) + g.POST("/startProcess", h.Process(business.AlertStart)) + g.POST("/submitAudit", h.Process(business.AlertSubmit)) + g.POST("/approve", h.Process(business.AlertApprove)) + g.POST("/reject", h.Process(business.AlertReject)) + g.POST("/reProcess", h.Process(business.AlertReprocess)) + g.POST("/addNote", h.AddNote) +} diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 6c89fc3..796c809 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() { RegisterAlertRecord(private, r.handlers.AlertRecord) }, func() { RegisterAlertRule(private, r.handlers.AlertRule) }, func() { private.GET("/sw/business/:userId/promotes", r.handlers.BusinessPromote.List) }, func() { RegisterBusiness(private, r.handlers.Business) }, diff --git a/internal/service/business/alert_record.go b/internal/service/business/alert_record.go new file mode 100644 index 0000000..ed0db1b --- /dev/null +++ b/internal/service/business/alert_record.go @@ -0,0 +1,133 @@ +package business + +import ( + "context" + "encoding/json" + "strconv" + "strings" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type AlertRecordUsecase interface { + List(context.Context, business.AlertRecordFilter, ...business.ListOption) (*business.AlertRecordPage, error) + Detail(context.Context, uint64) (*business.AlertRecordDetail, error) + Process(context.Context, uint64, business.AlertAction, string) error + AddNote(context.Context, uint64, string, []string) error +} + +type AlertRecordService struct{ uc AlertRecordUsecase } + +func NewAlertRecordService(uc *business.AlertRecordUsecase) *AlertRecordService { + return &AlertRecordService{uc: uc} +} + +func alertRecordResponse(r *business.AlertRecord) *dto.AlertRecordResponse { + metricLabel := "" + if r.MonitorMetric != nil { + metricLabel = business.AlertMetricLabel(*r.MonitorMetric) + } + return &dto.AlertRecordResponse{ID: r.ID, RuleID: r.RuleID, PlatformCode: r.PlatformCode, PlatformName: r.PlatformName, RuleName: r.RuleName, RuleType: r.RuleType, RuleTypeLabel: business.AlertRuleTypeLabel(r.RuleType), MonitorMetric: r.MonitorMetric, MonitorMetricLabel: metricLabel, TargetPromoteID: r.TargetPromoteID, TargetPromoteName: r.TargetPromoteName, BusinessID: r.BusinessID, BusinessName: r.BusinessName, AlertContent: r.AlertContent, Status: r.Status, StatusLabel: business.AlertStatusLabel(r.Status), CreatedAt: localTime(&r.CreatedAt), UpdatedAt: localTime(&r.UpdatedAt)} +} + +func (s *AlertRecordService) List(ctx context.Context, req *dto.AlertRecordQuery) (*dto.AlertRecordPage, error) { + if req == nil { + return nil, business.ErrAlertRecordInput + } + if req.MonitorMetric != nil && (*req.MonitorMetric < 1 || *req.MonitorMetric > 3) { + return nil, business.ErrAlertRecordInput + } + if req.Status != nil && (*req.Status < 1 || *req.Status > 5) { + return nil, business.ErrAlertRecordInput + } + 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.ErrAlertRecordInput + } + filter := business.AlertRecordFilter{TargetPromoteID: req.TargetPromoteID, BusinessID: req.BusinessID, TargetPromoteName: strings.TrimSpace(req.TargetPromoteName), RuleName: strings.TrimSpace(req.RuleName), RuleType: strings.TrimSpace(req.RuleType), BusinessName: strings.TrimSpace(req.BusinessName), MonitorMetric: req.MonitorMetric, Status: req.Status} + seen := map[string]bool{} + for _, code := range strings.Split(req.PlatformCodes, ",") { + code = strings.TrimSpace(code) + if code != "" && !seen[code] { + filter.PlatformCodes = append(filter.PlatformCodes, code) + seen[code] = true + } + } + result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size)) + if err != nil { + return nil, err + } + out := &dto.AlertRecordPage{PageData: dto.AlertRecordRows{Rows: []*dto.AlertRecordResponse{}, Total: result.Total}, StatusCount: result.StatusCount, Available: result.Available} + for _, row := range result.Rows { + out.PageData.Rows = append(out.PageData.Rows, alertRecordResponse(row)) + } + return out, nil +} + +func (s *AlertRecordService) Detail(ctx context.Context, id uint64) (*dto.AlertRecordDetail, error) { + if id == 0 { + return nil, business.ErrAlertRecordInput + } + detail, err := s.uc.Detail(ctx, id) + if err != nil { + return nil, err + } + out := &dto.AlertRecordDetail{AlertRecord: alertRecordResponse(detail.Record), OperationLogs: []*dto.AlertLogResponse{}, CanAudit: detail.CanAudit} + for _, l := range detail.Logs { + out.OperationLogs = append(out.OperationLogs, &dto.AlertLogResponse{ID: l.ID, AlertID: l.AlertID, OperatorID: l.OperatorID, OperatorName: l.OperatorName, LogType: l.LogType, LogTypeLabel: business.AlertLogTypeLabel(l.LogType), ActionDetail: l.ActionDetail, NoteContent: l.NoteContent, Attachments: l.Attachments, CreatedAt: localTime(&l.CreatedAt)}) + } + return out, nil +} + +func (s *AlertRecordService) Process(ctx context.Context, req *dto.AlertProcessRequest, action business.AlertAction) error { + if req == nil || len(req.Note) > 65535 { + return business.ErrAlertRecordInput + } + id, err := strconv.ParseUint(req.AlertID.String(), 10, 64) + if err != nil || id == 0 { + return business.ErrAlertRecordInput + } + return s.uc.Process(ctx, id, action, req.Note) +} + +func (s *AlertRecordService) AddNote(ctx context.Context, req *dto.AlertNoteRequest) error { + if req == nil || len(req.NoteContent) > 65535 { + return business.ErrAlertRecordInput + } + id, err := strconv.ParseUint(req.AlertID.String(), 10, 64) + if err != nil || id == 0 { + return business.ErrAlertRecordInput + } + if strings.TrimSpace(req.NoteContent) == "" { + return business.ErrAlertNoteEmpty + } + values := make([]string, 0, len(req.AttachmentIDs)) + for _, raw := range req.AttachmentIDs { + var value string + if json.Unmarshal(raw, &value) != nil { + var number json.Number + if json.Unmarshal(raw, &number) != nil { + return business.ErrAlertRecordInput + } + if _, err := number.Int64(); err != nil { + return business.ErrAlertRecordInput + } + value = number.String() + } + if value == "" { + return business.ErrAlertRecordInput + } + values = append(values, value) + } + return s.uc.AddNote(ctx, id, req.NoteContent, values) +} diff --git a/internal/service/business/alert_record_test.go b/internal/service/business/alert_record_test.go new file mode 100644 index 0000000..1952f8e --- /dev/null +++ b/internal/service/business/alert_record_test.go @@ -0,0 +1,79 @@ +package business + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type alertRecordUsecaseFake struct { + AlertRecordUsecase + id uint64 + filter business.AlertRecordFilter + attachments []string +} + +func (f *alertRecordUsecaseFake) Process(_ context.Context, id uint64, _ business.AlertAction, _ string) error { + f.id = id + return nil +} +func (f *alertRecordUsecaseFake) AddNote(_ context.Context, id uint64, _ string, attachments []string) error { + f.id = id + f.attachments = attachments + return nil +} +func (f *alertRecordUsecaseFake) List(_ context.Context, filter business.AlertRecordFilter, _ ...business.ListOption) (*business.AlertRecordPage, error) { + f.filter = filter + return &business.AlertRecordPage{Rows: []*business.AlertRecord{}, StatusCount: map[int]int64{}, Available: true}, nil +} + +func TestAlertRecordServiceAcceptsExactIDsAndAttachmentFormats(t *testing.T) { + f := &alertRecordUsecaseFake{} + s := &AlertRecordService{uc: f} + for _, raw := range []string{`{"alertId":"9007199254740993"}`, `{"alertId":9007199254740993}`} { + var req dto.AlertProcessRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatal(err) + } + if err := s.Process(context.Background(), &req, business.AlertStart); err != nil || f.id != 9007199254740993 { + t.Fatal("预警 ID 在 DTO 边界失真") + } + } + var req dto.AlertNoteRequest + if err := json.Unmarshal([]byte(`{"alertId":"10","noteContent":"备注","attachmentIds":[9007199254740993,"2","/files/a.png"]}`), &req); err != nil { + t.Fatal(err) + } + if err := s.AddNote(context.Background(), &req); err != nil || !reflect.DeepEqual(f.attachments, []string{"9007199254740993", "2", "/files/a.png"}) { + t.Fatalf("附件解析错误: %+v %v", f.attachments, err) + } + for _, raw := range []string{`null`, `1.5`, `true`, `{"url":"bad"}`} { + req.AttachmentIDs = []json.RawMessage{json.RawMessage(raw)} + if err := s.AddNote(context.Background(), &req); err == nil { + t.Fatalf("错误附件值被接受: %s", raw) + } + } +} + +func TestAlertRecordServiceQueryNormalizationAndIDResponse(t *testing.T) { + f := &alertRecordUsecaseFake{} + s := &AlertRecordService{uc: f} + _, err := s.List(context.Background(), &dto.AlertRecordQuery{PlatformCodes: " lx, hw,lx, ", RuleName: " 注册 "}) + if err != nil || !reflect.DeepEqual(f.filter.PlatformCodes, []string{"lx", "hw"}) || f.filter.RuleName != "注册" { + t.Fatal("查询参数未归一化") + } + raw, err := json.Marshal(alertRecordResponse(&business.AlertRecord{ID: 9007199254740993, RuleID: 9007199254740995, Status: 3})) + if err != nil { + t.Fatal(err) + } + var value map[string]any + if err = json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + if value["id"] != "9007199254740993" || value["ruleId"] != "9007199254740995" || value["statusLabel"] != "待审核" || value["warningColor"] != nil { + t.Fatalf("响应丢失精度或改变快照语义: %s", raw) + } +} diff --git a/internal/service/dto/alert_record.go b/internal/service/dto/alert_record.go new file mode 100644 index 0000000..acb22e0 --- /dev/null +++ b/internal/service/dto/alert_record.go @@ -0,0 +1,81 @@ +package dto + +import "encoding/json" + +type AlertRecordQuery struct { + Page int `form:"pageNum"` + PageSize int `form:"pageSize"` + PlatformCodes string `form:"platformCodes"` + TargetPromoteID *uint64 `form:"targetPromoteId"` + BusinessID *uint `form:"businessId"` + TargetPromoteName string `form:"targetPromoteName"` + RuleName string `form:"ruleName"` + RuleType string `form:"ruleType"` + BusinessName string `form:"businessName"` + MonitorMetric *int `form:"monitorMetric"` + Status *int `form:"status"` +} + +type AlertProcessRequest struct { + AlertID json.Number `json:"alertId"` + Note string `json:"note"` +} + +type AlertNoteRequest struct { + AlertID json.Number `json:"alertId"` + NoteContent string `json:"noteContent"` + AttachmentIDs []json.RawMessage `json:"attachmentIds"` +} + +type AlertRecordResponse struct { + ID uint64 `json:"id,string"` + PlatformCode string `json:"platformCode"` + PlatformName string `json:"platformName"` + RuleID uint64 `json:"ruleId,string"` + RuleName string `json:"ruleName"` + RuleType string `json:"ruleType"` + RuleTypeLabel string `json:"ruleTypeLabel"` + MonitorMetric *int `json:"monitorMetric"` + MonitorMetricLabel string `json:"monitorMetricLabel"` + // 旧视图声明了颜色,但记录没有对应快照字段,保留空值。 + WarningColor *string `json:"warningColor"` + TargetPromoteID uint64 `json:"targetPromoteId"` + TargetPromoteName string `json:"targetPromoteName"` + BusinessID uint `json:"businessId"` + BusinessName string `json:"businessName"` + AlertContent string `json:"alertContent"` + Status int `json:"status"` + StatusLabel string `json:"statusLabel"` + CreatedAt *string `json:"createdAt"` + UpdatedAt *string `json:"updatedAt"` +} + +type AlertLogResponse struct { + ID uint64 `json:"id,string"` + AlertID uint64 `json:"alertId,string"` + OperatorID uint `json:"operatorId"` + OperatorName string `json:"operatorName"` + LogType int `json:"logType"` + LogTypeLabel string `json:"logTypeLabel"` + ActionDetail string `json:"actionDetail"` + NoteContent string `json:"noteContent"` + Attachments []string `json:"attachments"` + CreatedAt *string `json:"createdAt"` +} + +type AlertRecordRows struct { + Rows []*AlertRecordResponse `json:"rows"` + Total int64 `json:"total"` +} + +type AlertRecordPage struct { + PageData AlertRecordRows `json:"pageData"` + StatusCount map[int]int64 `json:"statusCount"` + Available bool `json:"available"` +} + +type AlertRecordDetail struct { + AlertRecord *AlertRecordResponse `json:"alertRecord"` + OperationLogs []*AlertLogResponse `json:"operationLogs"` + CanAudit bool `json:"canAudit"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 7eab991..8ba8cf2 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -18,6 +18,7 @@ var ProviderSet = wire.NewSet( businessservice.NewPasswordResetService, businessservice.NewBusinessPromoteService, businessservice.NewAlertRuleService, + businessservice.NewAlertRecordService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/scripts/sw_alert_http_smoke.py b/scripts/sw_alert_http_smoke.py new file mode 100644 index 0000000..09a73a0 --- /dev/null +++ b/scripts/sw_alert_http_smoke.py @@ -0,0 +1,58 @@ +"""实测运行实例的预警查询和无效写请求,不修改真实预警或上传文件。""" + +import argparse +import json +import urllib.request +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:18000") + parser.add_argument("--token-file", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + token = args.token_file.read_text(encoding="utf-8").strip() + checks = [] + + def call(method, path, body=None, expected=0): + request = urllib.request.Request( + args.base_url + path, method=method, + headers={"x-token": token, "Content-Type": "application/json"}, + data=json.dumps(body).encode() if body is not None else None, + ) + with urllib.request.urlopen(request, timeout=30) as response: + value = json.load(response) + result = {"method": method, "path": path, "http": response.status, + "code": value.get("code"), "msg": value.get("msg"), + "passed": response.status == 200 and value.get("code") == expected} + data = value.get("data", {}) + if isinstance(data, dict) and "pageData" in data: + result.update(total=data["pageData"]["total"], available=data.get("available"), + status_count=data["statusCount"]) + checks.append(result) + print(json.dumps(result, ensure_ascii=False), flush=True) + return data + + call("GET", "/sw/alertRecord/list?pageNum=1&pageSize=10") + call("GET", "/sw/alertRecord/list?platformCodes=lx,hw&status=5") + call("GET", "/sw/alertRule/ruleTypes") + accounts = call("GET", "/sw/business/list?pageNum=1&pageSize=500") + bound = next((row for row in accounts.get("rows", []) if row["bindCount"] > 0), None) + unbound = next((row for row in accounts.get("rows", []) if row["bindCount"] == 0), None) + for account in [bound, unbound]: + if account: + call("GET", f"/sw/alertRecord/list?businessId={account['userId']}") + call("GET", "/sw/alertRecord/detail/0", expected=7) + for action in ["startProcess", "submitAudit", "approve", "reject", "reProcess", "addNote"]: + call("POST", f"/sw/alertRecord/{action}", {"alertId": 0}, expected=7) + report = {"scope": "既有 sw7,只读查询及必然拒绝的无效写请求;正向流转另用隔离 SQLite 和 TCP HTTP 测试", + "base_url": args.base_url, "checks": checks, + "passed": all(row["passed"] for row in checks)} + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if not report["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_alert_records_smoke.cjs b/scripts/sw_alert_records_smoke.cjs new file mode 100644 index 0000000..cee19e7 --- /dev/null +++ b/scripts/sw_alert_records_smoke.cjs @@ -0,0 +1,156 @@ +// 独立 Chrome 核对预警页面;预警写入和附件上传使用受控接口,不修改真实业务数据。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') +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 = [] + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=', 'base64') + try { + for (const level of [0, 1, 2, 3]) { + const context = await browser.newContext({ viewport: { width: 1500, height: 1080 } }) + await context.addInitScript(value => localStorage.setItem('token', value), token) + const page = await context.newPage() + const pageErrors = [] + const writes = [] + const queries = [] + let uploads = 0 + let unavailable = false + const rows = [1, 2, 3, 4, 5].map(status => ({ + id: String(100 + status), platformCode: 'lx', platformName: '龙翔', ruleName: `测试规则-${status}`, + ruleType: '测试分类', targetPromoteName: `渠道-${status}`, businessName: '测试一级商务', + alertContent: '测试预警内容', status, createdAt: '2026-11-03 10:20:30', updatedAt: '2026-11-03 10:20:30' + })) + const logs = new Map(rows.map(row => [row.id, []])) + const log = (id, action, note, attachments = []) => logs.get(id).unshift({ + id: String(writes.length), operatorName: '页面测试账号', logType: action ? 1 : 2, + logTypeLabel: action ? '状态变更操作' : '添加独立备注', actionDetail: action || '', + noteContent: note || '', attachments, createdAt: '2026-11-03 10:30:00' + }) + page.on('pageerror', error => pageErrors.push(error.message)) + await page.route('http://127.0.0.1:8080/api/**', async route => { + const request = route.request() + const url = new URL(request.url()) + const api = url.pathname.slice(4) + const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } }) + if (api === '/sw/alertRule/ruleTypes') return ok(['测试分类']) + if (api === '/sw/alertRecord/list') { + queries.push(url.search) + let filtered = rows.filter(row => !url.searchParams.get('status') || row.status === Number(url.searchParams.get('status'))) + if (url.searchParams.get('ruleName')) filtered = filtered.filter(row => row.ruleName.includes(url.searchParams.get('ruleName'))) + return ok({ pageData: { rows: unavailable ? [] : filtered, total: unavailable ? 0 : filtered.length }, statusCount: unavailable ? {} : { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1 }, available: !unavailable }) + } + if (api.startsWith('/sw/alertRecord/detail/')) { + const id = api.split('/').at(-1) + return ok({ alertRecord: rows.find(row => row.id === id), operationLogs: logs.get(id), canAudit: level === 1 }) + } + if (api === '/fileUploadAndDownload/upload') { + uploads++ + return ok({ file: { ID: 800 + uploads, url: `/files/verify-${uploads}.png`, name: 'test.png' } }) + } + if (api.startsWith('/sw/alertRecord/') && request.method() === 'POST') { + const payload = request.postDataJSON() + const id = String(payload.alertId) + writes.push({ path: api, body: payload }) + const action = api.split('/').at(-1) + if (action === 'addNote') log(id, '', payload.noteContent, ['/files/verify-1.png']) + else { + rows.find(row => row.id === id).status = { startProcess: 2, submitAudit: 3, reject: 4, reProcess: 2, approve: 5 }[action] + log(id, { startProcess: 'start', submitAudit: 'submit', reject: 'reject', reProcess: 'reprocess', approve: 'pass' }[action], payload.note) + } + return ok({}) + } + const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.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 }) + return route.fulfill({ response, json: body }) + } + return route.fulfill({ response }) + }) + await page.route('**/files/verify-*.png', route => route.fulfill({ contentType: 'image/png', body: png })) + await page.goto('http://127.0.0.1:8080/#/layout/sw-AlertCenter/AlertList') + await page.getByText('测试规则-1', { exact: true }).waitFor() + const result = { level, stateButtons: [], passed: true } + const tableRow = status => page.locator('.el-table__body-wrapper tr').filter({ hasText: `测试规则-${status}` }) + const dialog = page.getByRole('dialog', { name: '预警处理 / 详情', exact: true }) + const close = async () => { await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }) } + for (const status of [1, 2, 3, 4, 5]) { + const expected = { 1: '处理', 2: '继续处理', 3: level === 1 ? '审核' : '查看', 4: '重新处理', 5: '查看' }[status] + await tableRow(status).getByRole('button', { name: expected, exact: true }).click() + await dialog.getByText('测试预警内容', { exact: true }).waitFor() + assert.equal(await dialog.getByRole('button', { name: '审核通过', exact: true }).count(), status === 3 && level === 1 ? 1 : 0) + assert.equal(await dialog.getByPlaceholder('填写处理备注...').count(), status === 5 || (status === 3 && level !== 1) ? 0 : 1) + result.stateButtons.push({ status, label: expected }) + await close() + } + if (level === 1) { + await tableRow(1).getByRole('button', { name: '处理', exact: true }).click() + await dialog.getByPlaceholder('填写处理备注...').waitFor() + await dialog.locator('input[type="file"]').setInputFiles({ name: 'test.png', mimeType: 'image/png', buffer: png }) + await dialog.locator('textarea').evaluate((element, bytes) => { + const transfer = new DataTransfer() + transfer.items.add(new File([new Uint8Array(bytes)], 'paste.png', { type: 'image/png' })) + element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: transfer })) + }, [...png]) + await dialog.locator('.alert-upload-zone').evaluate((element, bytes) => { + const transfer = new DataTransfer() + transfer.items.add(new File([new Uint8Array(bytes)], 'drop.png', { type: 'image/png' })) + element.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })) + }, [...png]) + assert.equal(await dialog.locator('.pending-image').count(), 3) + await dialog.locator('.pending-image').first().getByRole('button', { name: '移除', exact: true }).click() + await dialog.locator('.pending-image').first().getByRole('button', { name: '移除', exact: true }).click() + assert.equal(await dialog.locator('.pending-image').count(), 1) + assert.equal(uploads, 0) + await dialog.getByRole('button', { name: '添加备注', exact: true }).click() + await page.getByText('请填写处理备注后再提交附件', { exact: true }).waitFor() + assert.equal(uploads, 0) + await dialog.getByPlaceholder('填写处理备注...').fill('测试备注和图片') + await dialog.getByRole('button', { name: '开始处理', exact: true }).click() + await page.getByText('请先点击「添加备注」上传图片,或移除暂存图片后再操作', { exact: true }).waitFor() + assert.equal(writes.length, 0) + await dialog.getByRole('button', { name: '添加备注', exact: true }).click() + await dialog.getByText('备注:测试备注和图片', { exact: true }).waitFor() + assert.equal(uploads, 1) + assert.deepEqual(writes[0].body.attachmentIds, ['801']) + for (const [button, next, confirmation] of [ + ['开始处理', '提交审核', true], ['提交审核', '审核通过', true], ['审核驳回', '重新处理', true], + ['重新处理', '提交审核', true], ['提交审核', '审核通过', true], ['审核通过', null, false] + ]) { + await dialog.getByRole('button', { name: button, exact: true }).click() + if (confirmation) await page.getByRole('dialog', { name: '操作确认', exact: true }).getByRole('button', { name: '确定', exact: true }).click() + if (next) await dialog.getByRole('button', { name: next, exact: true }).waitFor() + else await dialog.getByPlaceholder('填写处理备注...').waitFor({ state: 'hidden' }) + } + assert.equal(writes.length, 7) + await page.screenshot({ path: path.join(process.env.TEMP, 'sw-alert-record-workflow.png'), animations: 'disabled' }) + await close() + result.workflowAndAttachments = true + result.pasteDropAndRemove = true + } + if (level === 2) { + await page.getByRole('button', { name: '未处理 1', exact: true }).click() + await tableRow(2).waitFor({ state: 'hidden' }) + assert.ok(queries.at(-1).includes('status=1')) + await page.getByRole('button', { name: '未处理 1', exact: true }).click() + await tableRow(2).waitFor() + assert.ok(!queries.at(-1).includes('status=1')) + unavailable = true + await page.locator('.kra-search-box').getByRole('button', { name: '搜索', exact: true }).click() + await page.getByText('渠道数据尚未接入,暂时无法核对预警可见范围。', { exact: true }).waitFor() + assert.equal(await page.getByText('未接入', { exact: true }).count(), 5) + result.statusCardsAndUnavailable = true + } + assert.deepEqual(pageErrors, []) + result.pageErrors = pageErrors + results.push(result) + console.log(JSON.stringify(result)) + await context.close() + } + await fs.writeFile('docs/sw6-to-kra/alert-records-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;受控身份、预警数据、写接口和图片上传;未修改真实业务数据', results }, null, 2) + '\n') + } finally { await browser.close() } +})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/web/src/api/alert.js b/web/src/api/alert.js index 22000e5..30a0486 100644 --- a/web/src/api/alert.js +++ b/web/src/api/alert.js @@ -6,3 +6,8 @@ export const createAlertRule = (data) => service({ url: '/sw/alertRule/add', met 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 } }) + +export const listAlertRecords = (params) => service({ url: '/sw/alertRecord/list', method: 'get', params }) +export const getAlertRecord = (id) => service({ url: `/sw/alertRecord/detail/${id}`, method: 'get' }) +export const processAlertRecord = (action, data) => service({ url: `/sw/alertRecord/${action}`, method: 'post', data }) +export const addAlertNote = (data) => service({ url: '/sw/alertRecord/addNote', method: 'post', data }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 92979fa..4e73d7c 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/AlertList.vue": "SwAlertList", "/src/view/sw/AlertRules.vue": "SwAlertRules", "/src/view/sw/BusinessList.vue": "SwBusinessList", "/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings", diff --git a/web/src/view/sw/AlertList.vue b/web/src/view/sw/AlertList.vue new file mode 100644 index 0000000..a480351 --- /dev/null +++ b/web/src/view/sw/AlertList.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/src/view/sw/alert-display.js b/web/src/view/sw/alert-display.js new file mode 100644 index 0000000..f7b00cf --- /dev/null +++ b/web/src/view/sw/alert-display.js @@ -0,0 +1,4 @@ +export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理' } +export const alertStatusTone = (status) => ({ 1: 'danger', 2: 'warning', 3: 'info', 4: 'danger', 5: 'success' })[status] || 'info' +export const alertShortTime = (value) => value ? value.replace('T', ' ').slice(5, 16) : '—' +export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理' } diff --git a/web/src/view/sw/components/AlertHandleDrawer.vue b/web/src/view/sw/components/AlertHandleDrawer.vue new file mode 100644 index 0000000..a3adf14 --- /dev/null +++ b/web/src/view/sw/components/AlertHandleDrawer.vue @@ -0,0 +1,157 @@ + + + + +