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" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // ReadService 执行后台账号与个人客户的幂等已读事务脚本。 type ReadService struct { db *gorm.DB auditWriter *audit.Writer now func() time.Time } // NewReadService 创建单条已读用例。 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 { return s.markOneRead(ctx, constants.NotificationRecipientKindAccount, recipientID, notificationID, constants.AuditActorAccount, constants.AuditSourceAdminAPI) } // MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。 func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request dto.NotificationReadAllRequest) (*dto.NotificationReadAllResponse, error) { if recipientID == 0 || !isReadAllCategory(request.Category) { return nil, errors.New(errors.CodeInvalidParam) } count, err := s.markAllRead(ctx, constants.NotificationRecipientKindAccount, recipientID, request.Category, constants.AuditActorAccount, constants.AuditSourceAdminAPI) if err != nil { return nil, err } return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil } func isReadAllCategory(category string) bool { switch category { case "", constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySync, constants.NotificationCategorySystem: return true default: return false } } // MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。 func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error { return s.markOneRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, notificationID, constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) } // MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。 func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) { if customerID == 0 { return nil, errors.New(errors.CodeInvalidParam) } count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "", constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) if err != nil { return nil, err } 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 { return db.Where(`recipient_kind = ? AND recipient_id = ? AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`, constants.NotificationRecipientKindPersonalCustomer, customerID, []string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem}, []string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated}, now, ) }