RecycleBinExpiryService.java

package org.darkroomlibrary.service.impl;

import lombok.extern.slf4j.Slf4j;
import org.darkroomlibrary.domain.model.MessageBoard;
import org.darkroomlibrary.domain.type.FileReferenceType;
import org.darkroomlibrary.mapper.BookMapper;
import org.darkroomlibrary.mapper.BookReviewMapper;
import org.darkroomlibrary.mapper.MessageBoardMapper;
import org.darkroomlibrary.service.FileStorageService;
import org.darkroomlibrary.service.support.RecycleBinPolicy;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;

/**
 * Revokes restore authority after the configured retention window.
 *
 * <p>Rows are retained as business tombstones. Message attachments are released only after the
 * same locked transition that marks a reader-deleted message as expired, so a stale cleanup worker
 * cannot delete an attachment belonging to a message that has already been restored.</p>
 */
@Slf4j
@Service
public class RecycleBinExpiryService {

    private final BookMapper bookMapper;
    private final BookReviewMapper bookReviewMapper;
    private final MessageBoardMapper messageBoardMapper;
    private final FileStorageService fileStorageService;
    private final TransactionTemplate transactionTemplate;
    private final RecycleBinPolicy recycleBinPolicy;
    private final int cleanupBatchSize;

    public RecycleBinExpiryService(
            BookMapper bookMapper,
            BookReviewMapper bookReviewMapper,
            MessageBoardMapper messageBoardMapper,
            FileStorageService fileStorageService,
            TransactionTemplate transactionTemplate,
            RecycleBinPolicy recycleBinPolicy,
            @Value("${recycle-bin.cleanup-batch-size:100}") int cleanupBatchSize) {
        this.bookMapper = bookMapper;
        this.bookReviewMapper = bookReviewMapper;
        this.messageBoardMapper = messageBoardMapper;
        this.fileStorageService = fileStorageService;
        this.transactionTemplate = transactionTemplate;
        this.recycleBinPolicy = recycleBinPolicy;
        this.cleanupBatchSize = Math.max(1, Math.min(cleanupBatchSize, 1000));
    }

    @Scheduled(cron = "${recycle-bin.cleanup-cron:0 15 3 * * ?}")
    public void scheduledCleanup() {
        CleanupResult result = cleanupExpiredEntries();
        log.info("回收站恢复资格清理完成: books={}, reviews={}, messages={}",
                result.books(), result.reviews(), result.messages());
    }

    public CleanupResult cleanupExpiredEntries() {
        LocalDateTime now = recycleBinPolicy.now();
        int[] generalCounts = transactionTemplate.execute(status -> new int[]{
                bookMapper.markExpired(now),
                bookReviewMapper.markExpired(now)
        });
        int expiredMessages = 0;
        List<Integer> candidates = messageBoardMapper.findExpirationCandidates(now, cleanupBatchSize);
        for (Integer id : candidates) {
            Boolean expired = transactionTemplate.execute(status -> expireMessage(id, now));
            if (Boolean.TRUE.equals(expired)) {
                expiredMessages++;
            }
        }
        return new CleanupResult(generalCounts[0], generalCounts[1], expiredMessages);
    }

    private boolean expireMessage(Integer id, LocalDateTime now) {
        MessageBoard message = messageBoardMapper.findByIdForUpdate(id);
        if (message == null
                || !Boolean.TRUE.equals(message.getIsDeleted())
                || !Objects.equals(message.getModerationStatus(), 0)
                || message.getExpiredAt() != null
                || message.getRestoreDeadline() == null
                || message.getRestoreDeadline().isAfter(now)) {
            return false;
        }
        if (messageBoardMapper.markExpired(id, message.getRestoreDeadline(), now) != 1) {
            return false;
        }
        fileStorageService.releaseReference(FileReferenceType.MESSAGE_ATTACHMENT, id);
        return true;
    }

    public record CleanupResult(int books, int reviews, int messages) {
    }
}