feat: 迁移预警处理审核与备注附件流程

This commit is contained in:
Yvan
2026-11-03 20:40:16 +08:00
parent 7203c34409
commit 838c2c496f
36 changed files with 2312 additions and 13 deletions
+161
View File
@@ -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)
}
+6 -1
View File
@@ -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,
@@ -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 数组一致。
@@ -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
}
@@ -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": []
}
]
}
+7 -1
View File
@@ -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 测试覆盖。
- 当前没有执行最终旧数据库复制与迁移验收。
+22 -2
View File
@@ -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 测试,浏览器核对四个等级和五种状态的入口。预警检测/生成任务仍未接入,不因列表和工作流完成而宣称整个预警链路完成。
## 登录页
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, 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)
+210
View File
@@ -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]
}
+122
View File
@@ -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("独立备注改变了状态")
}
}
@@ -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
}
}
}
+329
View File
@@ -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
}
+215
View File
@@ -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、新日志或已经转换的附件")
}
}
+1 -1
View File
@@ -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{})
+1
View File
@@ -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)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 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 {
+1
View File
@@ -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},
+4 -4
View File
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 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))
+8
View File
@@ -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: "查询规则详情"},
+2 -1
View File
@@ -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)
}
}
+76
View File
@@ -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)
}
+1
View File
@@ -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,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
AlertRecord *AlertRecord
AlertRule *AlertRule
BusinessPromote *BusinessPromote
Business *Business
+19
View File
@@ -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)
}
+1
View File
@@ -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) },
+133
View File
@@ -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)
}
@@ -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)
}
}
+81
View File
@@ -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"`
}
+1
View File
@@ -18,6 +18,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewPasswordResetService,
businessservice.NewBusinessPromoteService,
businessservice.NewAlertRuleService,
businessservice.NewAlertRecordService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
+58
View File
@@ -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()
+156
View File
@@ -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 })
+5
View File
@@ -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 })
+1
View File
@@ -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",
+106
View File
@@ -0,0 +1,106 @@
<template>
<div>
<div class="alert-summary-grid">
<button v-for="(label, value) in statuses" :key="value" class="alert-summary-card" :class="{ active: query.status === Number(value) }" :aria-pressed="query.status === Number(value)" @click="selectStatus(Number(value))">
<span>{{ label }}</span><strong>{{ available ? (counts[value] || 0) : '未接入' }}</strong>
</button>
</div>
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="platforms" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px"><el-option v-for="(label, value) in platformOptions" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="规则名称"><el-input v-model="query.ruleName" placeholder="规则名称" clearable /></el-form-item>
<el-form-item label="规则类型"><el-select v-model="query.ruleType" clearable placeholder="全部类型" style="width: 160px"><el-option v-for="type in types" :key="type" :label="type" :value="type" /></el-select></el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width: 150px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item label="归属商务"><el-input v-model="query.businessName" clearable placeholder="归属商务" /></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入,暂时无法核对预警可见范围。" />
<el-alert v-else-if="!isAdmin" class="mb-4" type="info" :closable="false" title="仅展示当前账号自身及全部下级商务渠道产生的预警;上级、平级其他商务渠道预警不可见。" />
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column prop="platformName" label="所属平台" min-width="100" />
<el-table-column label="触发时间" min-width="120"><template #default="{ row }">{{ shortTime(row.createdAt) }}</template></el-table-column>
<el-table-column prop="ruleName" label="规则名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="ruleType" label="规则类型" min-width="130" show-overflow-tooltip />
<el-table-column prop="targetPromoteName" label="预警对象" min-width="160" show-overflow-tooltip />
<el-table-column prop="businessName" label="归属商务" min-width="130" show-overflow-tooltip />
<el-table-column prop="alertContent" label="预警内容" min-width="280" show-overflow-tooltip />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusTone(row.status)" effect="plain">{{ row.statusLabel || statuses[row.status] }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="115" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" link type="primary" @click="open(row)">{{ actionLabel(row.status) }}</el-button></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
<alert-handle-drawer v-model="drawer" :alert-id="currentID" :can-process="canProcess" :can-audit="canAudit" @success="load" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listAlertRecords, getAlertRuleTypes } from '@/api/alert'
import AlertHandleDrawer from './components/AlertHandleDrawer.vue'
import { alertStatuses as statuses, alertStatusTone as statusTone, alertShortTime as shortTime } from './alert-display'
defineOptions({ name: 'SwAlertList' })
const user = useUserStore()
const buttons = useBtnAuth()
const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
const canProcess = computed(() => isAdmin.value || !!buttons['sw:alertRecord:process'])
const canQuery = computed(() => isAdmin.value || !!buttons['sw:alertRecord:query'])
const canAudit = computed(() => Number(user.userInfo.level) === 1 && (isAdmin.value || !!buttons['sw:alertRecord:audit']))
const platformOptions = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const platforms = ref(Object.keys(platformOptions))
const types = ref([])
const query = reactive({ pageNum: 1, pageSize: 10, ruleName: '', ruleType: '', status: undefined, businessName: '' })
const rows = ref([])
const total = ref(0)
const counts = ref({})
const available = ref(true)
const loading = ref(false)
const drawer = ref(false)
const currentID = ref('')
let requestVersion = 0
const clear = () => { rows.value = []; total.value = 0; counts.value = {} }
const load = async () => {
const version = ++requestVersion
if (!platforms.value.length) { clear(); available.value = true; loading.value = false; return }
loading.value = true
try {
const res = await listAlertRecords({ ...query, ruleName: query.ruleName.trim(), businessName: query.businessName.trim(), platformCodes: platforms.value.join(',') })
if (version !== requestVersion) return
if (res.code !== 0) { clear(); return }
rows.value = res.data.pageData.rows
total.value = res.data.pageData.total
counts.value = res.data.statusCount
available.value = res.data.available !== false
} catch { if (version === requestVersion) clear() } finally { if (version === requestVersion) loading.value = false }
}
const search = () => {
if (!platforms.value.length) ElMessage.warning('请先选择平台')
query.pageNum = 1
return load()
}
const selectStatus = (status) => { query.status = query.status === status ? undefined : status; return search() }
const actionLabel = (status) => {
if (status === 3) return canAudit.value ? '审核' : '查看'
if (!canProcess.value) return '查看'
return { 1: '处理', 2: '继续处理', 4: '重新处理' }[status] || '查看'
}
const open = (row) => { currentID.value = row.id; drawer.value = true }
onMounted(async () => {
void load()
const res = await getAlertRuleTypes()
if (res.code === 0) types.value = res.data
})
</script>
<style scoped>
.alert-summary-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
.alert-summary-card { display: flex; flex-direction: column; gap: 12px; text-align: left; padding: 20px; background: var(--el-bg-color); color: var(--el-text-color-regular); border: 1px solid var(--el-border-color-light); border-radius: 8px; cursor: pointer; }
.alert-summary-card strong { font-size: 26px; color: var(--el-text-color-primary); }
.alert-summary-card.active, .alert-summary-card:focus-visible { border-color: var(--el-color-primary); outline: 1px solid var(--el-color-primary); }
@media (max-width: 900px) { .alert-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
+4
View File
@@ -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: '重新处理' }
@@ -0,0 +1,157 @@
<template>
<el-drawer v-model="visible" append-to-body destroy-on-close title="预警处理 / 详情" size="min(560px, 100vw)" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" @closed="reset">
<div v-loading="loading" tabindex="0" @paste="pasteImages">
<template v-if="current">
<h3 class="mb-3">渠道:{{ current.targetPromoteName.replace(/^渠道[::]\s*/, '') || '—' }} — {{ current.ruleName || '预警' }}</h3>
<el-descriptions :column="1" class="mb-4">
<el-descriptions-item label="归属商务">{{ current.businessName || '—' }}</el-descriptions-item>
<el-descriptions-item label="触发时间">{{ shortTime(current.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="当前状态"><el-tag :type="statusTone(current.status)">{{ current.statusLabel || statuses[current.status] }}</el-tag></el-descriptions-item>
<el-descriptions-item label="预警内容">{{ current.alertContent || '—' }}</el-descriptions-item>
</el-descriptions>
<section v-if="canNote" class="mb-5">
<p class="mb-2">处理备注与图片附件</p>
<el-input v-model="note" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="填写处理备注..." :disabled="busy" />
<div class="alert-upload-zone" @dragover.prevent @drop.prevent="dropImages">
<el-button :disabled="busy" @click="fileInput.click()">上传图片(可多选)</el-button>
<input ref="fileInput" class="hidden" type="file" accept="image/*" multiple @change="pickImages" />
<p class="upload-hint">支持多选、拖拽或在备注区粘贴截图;图片先暂存,点击「添加备注」上传并提交。</p>
<div class="alert-images">
<div v-for="(item, index) in pending" :key="item.url" class="pending-image">
<el-image :src="item.url" :alt="item.file.name" :preview-src-list="pending.map(image => image.url)" :initial-index="index" preview-teleported fit="cover" />
<el-button size="small" :disabled="busy" @click="removeImage(index)">移除</el-button>
</div>
</div>
<el-button type="primary" link :loading="savingNote" :disabled="busy && !savingNote" @click="addNote">添加备注</el-button>
</div>
</section>
<p class="mb-4">操作记录(全流程追溯 · 不可删除)</p>
<el-empty v-if="!logs.length" description="暂无操作记录" :image-size="70" />
<el-timeline v-else>
<el-timeline-item v-for="log in logs" :key="log.id" :timestamp="shortTime(log.createdAt)" placement="top" type="primary">
<strong>{{ log.operatorName || '—' }}</strong>
<p>{{ actions[log.actionDetail] || log.logTypeLabel || log.actionDetail }}</p>
<p v-if="log.noteContent" class="alert-note">备注:{{ log.noteContent }}</p>
<div v-if="log.attachments.length" class="alert-images">
<el-image v-for="(url, index) in log.attachments" :key="index" :src="fileURL(url)" alt="附件" :preview-src-list="log.attachments.map(fileURL)" :initial-index="index" preview-teleported fit="cover" />
</div>
</el-timeline-item>
</el-timeline>
</template>
<el-empty v-else-if="!loading" description="暂时无法读取预警详情" />
</div>
<template #footer>
<el-button :disabled="busy" @click="visible = false">关闭</el-button>
<template v-if="current && !loading">
<el-button v-if="canProcess && current.status === 1" type="primary" :disabled="busy" @click="process('startProcess')">开始处理</el-button>
<el-button v-if="canProcess && current.status === 2" type="primary" :disabled="busy" @click="process('submitAudit')">提交审核</el-button>
<template v-if="auditAllowed && current.status === 3">
<el-button type="danger" plain :disabled="busy" @click="process('reject')">审核驳回</el-button>
<el-button type="primary" :disabled="busy" @click="process('approve')">审核通过</el-button>
</template>
<el-button v-if="canProcess && current.status === 4" type="primary" :disabled="busy" @click="process('reProcess')">重新处理</el-button>
</template>
</template>
</el-drawer>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAlertRecord, processAlertRecord, addAlertNote } from '@/api/alert'
import { uploadFile } from '@/api/fileUploadAndDownload'
import { getUrl } from '@/utils/image'
import { alertStatuses as statuses, alertStatusTone as statusTone, alertShortTime as shortTime, alertActions as actions } from '../alert-display'
const props = defineProps({ modelValue: Boolean, alertId: { type: [String, Number], default: '' }, canProcess: Boolean, canAudit: Boolean })
const emit = defineEmits(['update:modelValue', 'success'])
const visible = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
const current = ref(null)
const logs = ref([])
const detailCanAudit = ref(false)
const auditAllowed = computed(() => props.canAudit && detailCanAudit.value)
const canNote = computed(() => props.canProcess && current.value && ([1, 2, 4].includes(current.value.status) || (current.value.status === 3 && auditAllowed.value)))
const loading = ref(false)
const savingNote = ref(false)
const processing = ref(false)
const busy = computed(() => savingNote.value || processing.value)
const note = ref('')
const pending = ref([])
const fileInput = ref()
let requestVersion = 0
// 图片只能作为图片地址展示,不将历史附件内容作为 HTML 或脚本执行。
const fileURL = (url) => /^https?:\/\//i.test(url) || !/^[a-z][a-z\d+.-]*:/i.test(url) ? getUrl(url) : ''
const clearImages = () => { pending.value.forEach(item => URL.revokeObjectURL(item.url)); pending.value = [] }
const reset = () => { ++requestVersion; clearImages(); note.value = ''; current.value = null; logs.value = []; detailCanAudit.value = false; loading.value = false }
const load = async () => {
const version = ++requestVersion
loading.value = true
try {
const res = await getAlertRecord(props.alertId)
if (version !== requestVersion) return
if (res.code === 0) { current.value = res.data.alertRecord; logs.value = res.data.operationLogs; detailCanAudit.value = res.data.canAudit === true }
else { current.value = null; logs.value = [] }
} catch { if (version === requestVersion) { current.value = null; logs.value = [] } } finally { if (version === requestVersion) loading.value = false }
}
const addImages = (files) => {
if (busy.value || !canNote.value) return
const images = Array.from(files).filter(file => file.type.startsWith('image/'))
if (!images.length) { ElMessage.warning('请选择图片文件'); return }
pending.value.push(...images.map(file => ({ file, url: URL.createObjectURL(file), uploaded: '' })))
}
const pickImages = (event) => { addImages(event.target.files); event.target.value = '' }
const dropImages = (event) => addImages(event.dataTransfer.files)
const pasteImages = (event) => {
const files = Array.from(event.clipboardData?.items || []).filter(item => item.type.startsWith('image/')).map(item => item.getAsFile()).filter(Boolean)
if (files.length && canNote.value) { event.preventDefault(); addImages(files) }
}
const removeImage = (index) => { const [image] = pending.value.splice(index, 1); URL.revokeObjectURL(image.url) }
const addNote = async () => {
if (busy.value || !canNote.value) return
if (!note.value.trim()) { ElMessage.warning('请填写处理备注后再提交附件'); return }
savingNote.value = true
try {
for (const item of pending.value) {
if (item.uploaded) continue
const data = new FormData()
data.append('file', item.file)
const res = await uploadFile(data)
const file = res.data?.file
if (res.code !== 0 || !file?.url) throw new Error('图片上传失败')
// 保留已经上传的结果,备注失败重试时不重复上传文件。
item.uploaded = file.ID ? String(file.ID) : file.url
}
const res = await addAlertNote({ alertId: props.alertId, noteContent: note.value.trim(), attachmentIds: pending.value.map(item => item.uploaded) })
if (res.code !== 0) return
clearImages(); note.value = ''; ElMessage.success('备注已添加'); await load()
} catch { ElMessage.error('备注提交失败,请重试') } finally { savingNote.value = false }
}
const confirmations = {
startProcess: '确认发起处理?预警将转为「处理中」状态。',
submitAudit: '确认提交审核?预警将转为「待审核」状态,等待一级商务审核。',
reject: '确认审核驳回?预警需补充处理后再提交。',
reProcess: '确认重新处理?预警将重新转为「处理中」状态。'
}
const process = async (action) => {
if (busy.value) return
if (pending.value.length) { ElMessage.warning('请先点击「添加备注」上传图片,或移除暂存图片后再操作'); return }
processing.value = true
try {
if (confirmations[action] && !await ElMessageBox.confirm(confirmations[action], '操作确认', { confirmButtonText: '确定', cancelButtonText: '取消' }).then(() => true).catch(() => false)) return
const res = await processAlertRecord(action, { alertId: props.alertId, note: note.value.trim() || undefined })
if (res.code !== 0) { await load(); return }
note.value = ''; ElMessage.success('操作成功'); emit('success'); await load()
} finally { processing.value = false }
}
watch(() => [props.modelValue, props.alertId], ([open]) => { if (open) { reset(); void load() } })
onBeforeUnmount(reset)
</script>
<style scoped>
.alert-upload-zone { margin-top: 12px; padding: 12px; border: 1px dashed var(--el-border-color); border-radius: 6px; }
.upload-hint { margin: 10px 0; font-size: 12px; line-height: 1.6; color: var(--el-text-color-secondary); }
.alert-images { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.alert-images .el-image { width: 72px; height: 72px; border-radius: 6px; }
.pending-image { display: flex; flex-direction: column; gap: 4px; }
.alert-note { white-space: pre-wrap; overflow-wrap: anywhere; padding: 10px; background: var(--el-fill-color-light); border-radius: 6px; }
</style>