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("独立备注改变了状态")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user