ReservationWorkflowServiceImpl.java

package org.darkroomlibrary.service.impl;

import org.darkroomlibrary.mapper.BookMapper;
import org.darkroomlibrary.mapper.BookReservationMapper;
import org.darkroomlibrary.mapper.UserMapper;
import org.darkroomlibrary.domain.type.AccountStatus;
import org.darkroomlibrary.domain.model.Book;
import org.darkroomlibrary.domain.model.BookReservation;
import org.darkroomlibrary.domain.model.User;
import org.darkroomlibrary.service.NotificationService;
import org.darkroomlibrary.service.ReservationWorkflowService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;

import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;

@Slf4j
@Service
public class ReservationWorkflowServiceImpl implements ReservationWorkflowService {

    private static final int RECONCILE_BATCH_SIZE = 100;
    private static final int EXPIRATION_BATCH_SIZE = 100;

    @Resource
    private BookReservationMapper bookReservationMapper;

    @Resource
    private BookMapper bookMapper;

    @Resource
    private UserMapper userMapper;

    @Resource
    private NotificationService notificationService;

    @Resource
    private PlatformTransactionManager transactionManager;

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void onBookReturned(Integer bookId) {
        notifyReservationsForBook(bookId);
    }

    private void notifyReservationsForBook(Integer bookId) {
        Book book = bookMapper.findByIdForUpdate(bookId);
        if (book == null || book.getAvailableCount() == null || book.getAvailableCount() <= 0) {
            return;
        }
        notifyAvailableReservations(book);
    }

    private void notifyAvailableReservations(Book book) {
        Integer bookId = book.getId();
        int notifiedCount = bookReservationMapper.countNotifiedByBookId(bookId);
        int remainingCapacity = book.getAvailableCount() - notifiedCount;
        while (remainingCapacity > 0) {
            BookReservation reservation = bookReservationMapper.findFirstWaitingByBookIdForUpdate(bookId);
            if (reservation == null) {
                return;
            }
            User user = userMapper.getById(reservation.getUserId());
            if (!canReceiveReservation(user)) {
                if (bookReservationMapper.expireWaiting(reservation.getId()) == 0) {
                    return;
                }
                log.warn("预约用户不可用,已释放排队记录: reservationId={}, bookId={}, userId={}",
                        reservation.getId(), bookId, reservation.getUserId());
                continue;
            }
            LocalDateTime now = LocalDateTime.now();
            if (bookReservationMapper.markNotified(reservation.getId(), now) == 0) {
                return;
            }
            notificationService.enqueueEmail(
                    user.getUserEmail(),
                    "【暗室藏书】预约到货通知",
                    "您预约的《" + book.getName() + "》已可借阅,请在48小时内处理,逾期将自动释放预约。"
            );
            remainingCapacity--;
        }
    }

    @Override
    @Scheduled(fixedDelay = 600000)
    public void expireOverdueNotifications() {
        LocalDateTime cutoff = LocalDateTime.now().minusHours(48);
        List<Integer> releasedBookIds =
                bookReservationMapper.findExpiredNotifiedBookIds(cutoff, EXPIRATION_BATCH_SIZE);
        int expiredTotal = 0;
        for (Integer bookId : releasedBookIds) {
            try {
                expiredTotal += expireBookNotifications(bookId, cutoff);
            } catch (Exception e) {
                log.warn("释放过期预约失败,将由下次任务重试: bookId={}, error={}",
                        bookId, e.getMessage());
            }
        }
        if (expiredTotal > 0) {
            log.info("已释放过期预约通知: count={}", expiredTotal);
        }
    }

    @Scheduled(fixedDelayString = "${reservation.reconcile-delay-ms:60000}")
    public void reconcileAvailableReservations() {
        List<Integer> bookIds =
                bookReservationMapper.findBooksNeedingNotification(RECONCILE_BATCH_SIZE);
        for (Integer bookId : bookIds) {
            try {
                TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
                transactionTemplate.executeWithoutResult(status -> notifyReservationsForBook(bookId));
            } catch (Exception e) {
                log.warn("预约库存对账失败,将由下次任务重试: bookId={}, error={}",
                        bookId, e.getMessage());
            }
        }
    }

    private int expireBookNotifications(Integer bookId, LocalDateTime cutoff) {
        TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
        Integer expired = transactionTemplate.execute(status -> {
            Book book = bookMapper.findByIdForUpdate(bookId);
            if (book == null) {
                return 0;
            }
            int count = bookReservationMapper.expireNotifiedByBookIdBefore(bookId, cutoff);
            if (count > 0 && book.getAvailableCount() != null && book.getAvailableCount() > 0) {
                notifyAvailableReservations(book);
            }
            return count;
        });
        return expired == null ? 0 : expired;
    }

    private boolean canReceiveReservation(User user) {
        return user != null
                && Objects.equals(user.getAccountStatus(), AccountStatus.NORMAL.code())
                && !Boolean.TRUE.equals(user.getIsLogin())
                && user.getUserEmail() != null
                && !user.getUserEmail().trim().isEmpty();
    }
}