package notification import ( "context" "fmt" "strconv" "time" "github.com/google/uuid" "go.uber.org/zap" "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/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // CleanupService 按通知类别的保留期限分批删除过期通知事实。 type CleanupService struct { db *gorm.DB logger *zap.Logger auditWriter *audit.Writer now func() time.Time } // NewCleanupService 创建通知保留清理服务。 func NewCleanupService(db *gorm.DB, logger *zap.Logger, auditWriters ...*audit.Writer) *CleanupService { if logger == nil { logger = zap.NewNop() } service := &CleanupService{db: db, logger: logger, now: time.Now} if len(auditWriters) > 0 { service.auditWriter = auditWriters[0] } return service } // Run 按类别、创建时间和稳定主键执行有界分批清理。 func (s *CleanupService) Run(ctx context.Context) error { policies := []struct { category string days int }{ {constants.NotificationCategoryApproval, constants.NotificationApprovalRetentionDays}, {constants.NotificationCategoryExpiry, constants.NotificationExpiryRetentionDays}, {constants.NotificationCategorySync, constants.NotificationSyncRetentionDays}, {constants.NotificationCategorySystem, constants.NotificationSystemRetentionDays}, } for _, policy := range policies { deleted, err := s.cleanupCategory(ctx, policy.category, s.now().UTC().AddDate(0, 0, -policy.days)) if err != nil { return err } s.logger.Info("站内通知保留清理完成", zap.String("category", policy.category), zap.Int64("deleted_count", deleted)) } return nil } func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) { if s.auditWriter == nil { return 0, errors.New(errors.CodeInvalidStatus, "通知清理统一审计接缝未配置") } var total int64 for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ { var deleted []*model.Notification if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("category = ? AND created_at < ?", category, cutoff). Order("created_at ASC, id ASC").Limit(constants.NotificationCleanupBatchSize).Find(&deleted).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询待清理站内通知失败") } if len(deleted) == 0 { return nil } ids := make([]uint, 0, len(deleted)) for _, notification := range deleted { ids = append(ids, notification.ID) } result := tx.WithContext(ctx).Where("id IN ?", ids).Delete(&model.Notification{}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败") } if result.RowsAffected != int64(len(deleted)) { return errors.New(errors.CodeInvalidStatus, "通知清理数量发生并发变化") } return s.appendCleanupAudit(ctx, tx, category, cutoff, deleted) }); err != nil { return total, err } total += int64(len(deleted)) if len(deleted) < constants.NotificationCleanupBatchSize { break } } return total, nil } func (s *CleanupService) appendCleanupAudit(ctx context.Context, tx *gorm.DB, category string, cutoff time.Time, notifications []*model.Notification) error { firstID, lastID := notifications[0].ID, notifications[len(notifications)-1].ID key := fmt.Sprintf("%s:%s:%d:%d", category, cutoff.UTC().Format(time.RFC3339), firstID, lastID) rootID := "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("notification-cleanup:"+key)).String() children := make([]audit.AppendInput, 0, len(notifications)) for _, notification := range notifications { children = append(children, audit.AppendInput{ EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "cleanup"), ActionCode: constants.AuditActionNotificationCleanupItem, Summary: "清理单条过期通知", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, Resources: []audit.ResourceInput{audit.NotificationResource(notification, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget, map[string]any{"exists": true}, map[string]any{"deleted": true})}, }) } return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{ Root: audit.AppendInput{ EventID: rootID, ActionCode: constants.AuditActionNotificationCleanup, Summary: "清理过期通知", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, BatchTotal: len(notifications), SuccessCount: len(notifications), Resources: []audit.ResourceInput{{ Type: constants.AuditResourceNotificationCleanupBatch, Key: rootID, DisplayName: "通知清理批次", Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask, IdentitySnapshot: map[string]any{ "category": category, "cutoff": cutoff.UTC(), "deleted_count": len(notifications), "first_id": firstID, "last_id": lastID, }, SubjectVisibility: constants.AuditSubjectInternalOnly, }}, Metadata: map[string]any{"first_id": strconv.FormatUint(uint64(firstID), 10), "last_id": strconv.FormatUint(uint64(lastID), 10)}, }, Children: children, }) }