feat: 迁移预警处理审核与备注附件流程
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, 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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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、新日志或已经转换的附件")
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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: "查询规则详情"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
AlertRecord *AlertRecord
|
||||
AlertRule *AlertRule
|
||||
BusinessPromote *BusinessPromote
|
||||
Business *Business
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -18,6 +18,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewPasswordResetService,
|
||||
businessservice.NewBusinessPromoteService,
|
||||
businessservice.NewAlertRuleService,
|
||||
businessservice.NewAlertRecordService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
Reference in New Issue
Block a user