收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -2,10 +2,15 @@ package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -14,29 +19,24 @@ import (
|
||||
|
||||
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
|
||||
type ReadService struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
db *gorm.DB
|
||||
auditWriter *audit.Writer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewReadService 创建单条已读用例。
|
||||
func NewReadService(db *gorm.DB) *ReadService {
|
||||
return &ReadService{db: db, now: time.Now}
|
||||
func NewReadService(db *gorm.DB, auditWriters ...*audit.Writer) *ReadService {
|
||||
service := &ReadService{db: db, now: time.Now}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// MarkRead 仅首次更新当前接收人的未过期未读通知。
|
||||
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
|
||||
if recipientID == 0 || notificationID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
result := s.db.WithContext(ctx).Model(&model.Notification{}).
|
||||
Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
notificationID, constants.NotificationRecipientKindAccount, recipientID, false, now).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新通知已读状态失败")
|
||||
}
|
||||
return nil
|
||||
return s.markOneRead(ctx, constants.NotificationRecipientKindAccount, recipientID, notificationID,
|
||||
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
}
|
||||
|
||||
// MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。
|
||||
@@ -44,18 +44,12 @@ func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request
|
||||
if recipientID == 0 || !isReadAllCategory(request.Category) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
db := s.db.WithContext(ctx).Model(&model.Notification{}).
|
||||
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
constants.NotificationRecipientKindAccount, recipientID, false, now)
|
||||
if request.Category != "" {
|
||||
db = db.Where("category = ?", request.Category)
|
||||
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindAccount, recipientID, request.Category,
|
||||
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := db.Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
|
||||
}
|
||||
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
|
||||
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
|
||||
}
|
||||
|
||||
func isReadAllCategory(category string) bool {
|
||||
@@ -70,17 +64,8 @@ func isReadAllCategory(category string) bool {
|
||||
|
||||
// MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。
|
||||
func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error {
|
||||
if customerID == 0 || notificationID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
|
||||
Where("id = ? AND is_read = ?", notificationID, false).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新个人客户通知已读状态失败")
|
||||
}
|
||||
return nil
|
||||
return s.markOneRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, notificationID,
|
||||
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
|
||||
}
|
||||
|
||||
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
|
||||
@@ -88,14 +73,139 @@ func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint)
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
|
||||
Where("is_read = ?", false).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败")
|
||||
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "",
|
||||
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
|
||||
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
|
||||
}
|
||||
|
||||
func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, recipientID, notificationID uint, actorKind, source string) error {
|
||||
if recipientID == 0 || notificationID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var notification model.Notification
|
||||
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
|
||||
Where("id = ? AND is_read = ?", notificationID, false).Take(¬ification)
|
||||
if stderrors.Is(query.Error, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if query.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, query.Error, "查询通知已读状态失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.Notification{}).Where("id = ? AND is_read = ?", notification.ID, false).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新通知已读状态失败")
|
||||
}
|
||||
return s.appendReadAudit(ctx, tx, ¬ification, now, actorKind, source, "")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string) (int64, error) {
|
||||
if s.auditWriter == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var updated int64
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var notifications []*model.Notification
|
||||
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
|
||||
Where("is_read = ?", false)
|
||||
if category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
if err := query.Order("id ASC").Find(¬ifications).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败")
|
||||
}
|
||||
if len(notifications) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint, 0, len(notifications))
|
||||
for _, notification := range notifications {
|
||||
ids = append(ids, notification.ID)
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.Notification{}).Where("id IN ? AND is_read = ?", ids, false).
|
||||
Updates(map[string]any{"is_read": true, "read_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(notifications)) {
|
||||
return errors.New(errors.CodeInvalidStatus, "通知已读状态发生并发变化")
|
||||
}
|
||||
updated = result.RowsAffected
|
||||
return s.appendReadAllAudit(ctx, tx, notifications, now, recipientKind, recipientID, category, actorKind, source)
|
||||
})
|
||||
return updated, err
|
||||
}
|
||||
|
||||
func (s *ReadService) appendReadAudit(ctx context.Context, tx *gorm.DB, notification *model.Notification, now time.Time, actorKind, source, parentEventID string) error {
|
||||
scopeType, scopeID := notificationScope(notification.RecipientKind, notification.RecipientID)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
|
||||
ActionCode: constants.AuditActionNotificationRead, Summary: "标记通知已读",
|
||||
Actor: audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(notification.RecipientID), 10)},
|
||||
Source: source, ScopeType: scopeType, ScopeID: scopeID,
|
||||
Result: constants.AuditResultSuccess, ParentEventID: parentEventID,
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ReadService) appendReadAllAudit(ctx context.Context, tx *gorm.DB, notifications []*model.Notification, now time.Time, recipientKind string, recipientID uint, category, actorKind, source string) error {
|
||||
rootID := "evt_" + uuid.NewString()
|
||||
actor := audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(recipientID), 10)}
|
||||
scopeType, scopeID := notificationScope(recipientKind, recipientID)
|
||||
children := make([]audit.AppendInput, 0, len(notifications))
|
||||
for _, notification := range notifications {
|
||||
children = append(children, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
|
||||
ActionCode: constants.AuditActionNotificationRead, Summary: "批量标记通知已读",
|
||||
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
|
||||
})
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: rootID, ActionCode: constants.AuditActionNotificationReadAll, Summary: "批量标记通知已读",
|
||||
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
|
||||
BatchTotal: len(notifications), SuccessCount: len(notifications),
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceNotificationReadBatch, Key: rootID, DisplayName: "通知批量已读",
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"recipient_kind": recipientKind, "recipient_id": recipientID,
|
||||
"category": category, "updated_count": len(notifications),
|
||||
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
func notificationScope(recipientKind string, recipientID uint) (string, string) {
|
||||
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
|
||||
return constants.AuditScopePersonalCustomer, strconv.FormatUint(uint64(recipientID), 10)
|
||||
}
|
||||
return constants.AuditScopePlatform, ""
|
||||
}
|
||||
|
||||
func readScope(db *gorm.DB, recipientKind string, recipientID uint, now time.Time) *gorm.DB {
|
||||
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
|
||||
return personalReadScope(db, recipientID, now)
|
||||
}
|
||||
return db.Model(&model.Notification{}).Where(
|
||||
"recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
recipientKind, recipientID, now,
|
||||
)
|
||||
}
|
||||
|
||||
func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
|
||||
|
||||
Reference in New Issue
Block a user