借书/还书/续借/逾期管理

This commit is contained in:
Zzzz
2026-04-27 21:19:23 +08:00
parent 38b31ddbb9
commit 7502890a77
27 changed files with 2535 additions and 31 deletions
@@ -0,0 +1,339 @@
package com.mzh.library.service.impl;
import com.mzh.library.dao.BorrowRecordDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BookStatus;
import com.mzh.library.entity.BorrowRecord;
import com.mzh.library.entity.BorrowRecordSearchCriteria;
import com.mzh.library.entity.BorrowRecordStatus;
import com.mzh.library.entity.Permission;
import com.mzh.library.entity.Reader;
import com.mzh.library.entity.ReaderStatus;
import com.mzh.library.entity.Role;
import com.mzh.library.exception.DaoException;
import com.mzh.library.service.BorrowingService;
import com.mzh.library.service.PermissionPolicy;
import com.mzh.library.service.ServiceResult;
import com.mzh.library.util.JdbcUtil;
import java.sql.SQLException;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
public class BorrowingServiceImpl implements BorrowingService {
public interface TransactionExecutor {
<T> T execute(JdbcUtil.TransactionCallback<T> callback);
}
private static final Logger LOGGER = Logger.getLogger(BorrowingServiceImpl.class.getName());
private static final String UNAVAILABLE_MESSAGE =
"Borrowing service is temporarily unavailable. Please try again later.";
private static final String VALIDATION_MESSAGE = "Please correct the highlighted borrowing fields.";
private static final String DENIED_MESSAGE = "You do not have permission to manage borrowing.";
private static final String HISTORY_DENIED_MESSAGE = "You do not have permission to view loan history.";
private static final int LOAN_DAYS = 14;
private static final int MAX_RENEWALS = 1;
private final BorrowRecordDao borrowRecordDao;
private final PermissionPolicy permissionPolicy;
private final Clock clock;
private final TransactionExecutor transactionExecutor;
public BorrowingServiceImpl(BorrowRecordDao borrowRecordDao) {
this(borrowRecordDao, new PermissionPolicy(), Clock.systemDefaultZone(), new JdbcTransactionExecutor());
}
public BorrowingServiceImpl(BorrowRecordDao borrowRecordDao, PermissionPolicy permissionPolicy, Clock clock,
TransactionExecutor transactionExecutor) {
this.borrowRecordDao = borrowRecordDao;
this.permissionPolicy = permissionPolicy;
this.clock = clock;
this.transactionExecutor = transactionExecutor;
}
@Override
public ServiceResult<List<BorrowRecord>> searchRecords(AuthenticatedUser actor, BorrowRecordSearchCriteria criteria) {
if (!canManageBorrowing(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
BorrowRecordSearchCriteria normalized = criteria == null ? new BorrowRecordSearchCriteria() : criteria;
Map<String, String> errors = validateSearch(normalized);
if (!errors.isEmpty()) {
return ServiceResult.validationFailure("Please correct the borrowing search filters.", errors);
}
try {
return ServiceResult.success(borrowRecordDao.search(normalized));
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to search borrow records actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<Long> borrowBook(AuthenticatedUser actor, String readerIdentifier, String bookIdentifier) {
if (!canManageBorrowing(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
String normalizedReaderIdentifier = trim(readerIdentifier);
String normalizedBookIdentifier = trim(bookIdentifier);
Map<String, String> errors = validateBorrowIdentifiers(normalizedReaderIdentifier, normalizedBookIdentifier);
if (!errors.isEmpty()) {
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
}
try {
return transactionExecutor.execute(connection -> {
Map<String, String> transactionErrors = new LinkedHashMap<>();
Optional<Reader> readerResult = borrowRecordDao.findReaderByIdentifierForUpdate(connection,
normalizedReaderIdentifier);
if (!readerResult.isPresent()) {
transactionErrors.put("readerIdentifier", "Reader was not found.");
}
Optional<Book> bookResult = borrowRecordDao.findBookByIdentifierForUpdate(connection,
normalizedBookIdentifier);
if (!bookResult.isPresent()) {
transactionErrors.put("bookIdentifier", "Book was not found.");
}
if (!transactionErrors.isEmpty()) {
return ServiceResult.validationFailure(VALIDATION_MESSAGE, transactionErrors);
}
Reader reader = readerResult.get();
Book book = bookResult.get();
validateBorrowEligibility(transactionErrors, reader, book, connection);
if (!transactionErrors.isEmpty()) {
return ServiceResult.validationFailure(VALIDATION_MESSAGE, transactionErrors);
}
LocalDateTime borrowedAt = now();
BorrowRecord record = new BorrowRecord();
record.setReaderId(reader.getId());
record.setBookId(book.getId());
record.setBorrowedAt(borrowedAt);
record.setDueAt(borrowedAt.plusDays(LOAN_DAYS));
record.setRenewalCount(0);
record.setStatus(BorrowRecordStatus.ACTIVE);
long id = borrowRecordDao.create(connection, record);
if (!borrowRecordDao.decrementAvailableCopies(connection, book.getId())) {
throw new DaoException("Book inventory was not decremented for borrow record " + id, null);
}
LOGGER.info("Borrowed book recordId=" + id + " readerId=" + reader.getId()
+ " bookId=" + book.getId() + " actorId=" + actor.getId());
return ServiceResult.success(id, "Book borrowed.");
});
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to borrow book actorId=" + actor.getId()
+ " readerIdentifier=" + normalizedReaderIdentifier
+ " bookIdentifier=" + normalizedBookIdentifier, ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<Void> returnBook(AuthenticatedUser actor, long recordId) {
if (!canManageBorrowing(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
if (recordId <= 0) {
return ServiceResult.failure("Select a valid borrowing record.");
}
try {
return transactionExecutor.execute(connection -> {
Optional<BorrowRecord> recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId);
if (!recordResult.isPresent()) {
return ServiceResult.failure("Borrowing record was not found.");
}
BorrowRecord record = recordResult.get();
Map<String, String> errors = validateActiveLoan(record);
if (!errors.isEmpty()) {
return ServiceResult.validationFailure("Borrowing record cannot be returned.", errors);
}
if (!borrowRecordDao.markReturned(connection, recordId, now())) {
throw new DaoException("Borrow record was not marked returned: " + recordId, null);
}
borrowRecordDao.incrementAvailableCopies(connection, record.getBookId());
LOGGER.info("Returned borrow recordId=" + recordId + " actorId=" + actor.getId());
return ServiceResult.success(null, "Book returned.");
});
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to return borrow record id=" + recordId + " actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<Void> renewLoan(AuthenticatedUser actor, long recordId) {
if (!canManageBorrowing(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
if (recordId <= 0) {
return ServiceResult.failure("Select a valid borrowing record.");
}
try {
return transactionExecutor.execute(connection -> {
Optional<BorrowRecord> recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId);
if (!recordResult.isPresent()) {
return ServiceResult.failure("Borrowing record was not found.");
}
BorrowRecord record = recordResult.get();
Map<String, String> errors = validateActiveLoan(record);
if (record.getRenewalCount() >= MAX_RENEWALS) {
errors.put("renewalCount", "This loan has already reached the renewal limit.");
}
if (!errors.isEmpty()) {
return ServiceResult.validationFailure("Borrowing record cannot be renewed.", errors);
}
LocalDateTime currentDueAt = record.getDueAt() == null ? now() : record.getDueAt();
LocalDateTime newDueAt = currentDueAt.plusDays(LOAN_DAYS);
if (!borrowRecordDao.renew(connection, recordId, newDueAt)) {
throw new DaoException("Borrow record was not renewed: " + recordId, null);
}
LOGGER.info("Renewed borrow recordId=" + recordId + " actorId=" + actor.getId());
return ServiceResult.success(null, "Loan renewed.");
});
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to renew borrow record id=" + recordId + " actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
@Override
public ServiceResult<List<BorrowRecord>> listCurrentReaderHistory(AuthenticatedUser actor) {
if (!canViewOwnHistory(actor)) {
return ServiceResult.failure(HISTORY_DENIED_MESSAGE);
}
try {
Optional<Reader> readerResult = borrowRecordDao.findReaderByUserId(actor.getId());
if (!readerResult.isPresent()) {
return ServiceResult.success(Collections.emptyList(), "No reader profile is linked to your account.");
}
return ServiceResult.success(borrowRecordDao.findByReaderId(readerResult.get().getId()));
} catch (DaoException ex) {
LOGGER.log(Level.SEVERE, "Unable to load reader loan history actorId=" + actor.getId(), ex);
return ServiceResult.failure(UNAVAILABLE_MESSAGE);
}
}
public int getMaxRenewals() {
return MAX_RENEWALS;
}
private void validateBorrowEligibility(Map<String, String> errors, Reader reader, Book book,
java.sql.Connection connection) {
if (reader.getStatus() != ReaderStatus.ACTIVE) {
errors.put("readerIdentifier", "Reader must be active to borrow books.");
}
int activeLoans = borrowRecordDao.countActiveByReaderId(connection, reader.getId());
if (activeLoans >= reader.getMaxBorrowCount()) {
errors.put("readerIdentifier", "Reader has reached the active borrowing limit.");
}
if (book.getStatus() != BookStatus.AVAILABLE) {
errors.put("bookIdentifier", "Book status does not allow borrowing.");
} else if (book.getAvailableCopies() <= 0) {
errors.put("bookIdentifier", "No available copies remain for this book.");
}
}
private Map<String, String> validateSearch(BorrowRecordSearchCriteria criteria) {
Map<String, String> errors = new LinkedHashMap<>();
String statusCode = trim(criteria.getStatusCode());
criteria.setReaderIdentifier(criteria.getReaderIdentifier());
criteria.setBookIdentifier(criteria.getBookIdentifier());
criteria.setStatusCode(statusCode);
if (!statusCode.isEmpty() && !BorrowRecordSearchCriteria.OVERDUE_STATUS.equals(statusCode)) {
try {
BorrowRecordStatus.fromCode(statusCode);
} catch (IllegalArgumentException ex) {
errors.put("status", "Select a valid borrowing status.");
}
}
return errors;
}
private Map<String, String> validateBorrowIdentifiers(String readerIdentifier, String bookIdentifier) {
Map<String, String> errors = new LinkedHashMap<>();
requireLength(errors, "readerIdentifier", readerIdentifier, "Reader ID", 64);
requireLength(errors, "bookIdentifier", bookIdentifier, "Book ID", 64);
return errors;
}
private Map<String, String> validateActiveLoan(BorrowRecord record) {
Map<String, String> errors = new LinkedHashMap<>();
if (record.getStatus() != BorrowRecordStatus.ACTIVE || record.getReturnedAt() != null) {
errors.put("status", "Only active loans can use this action.");
}
return errors;
}
private void requireLength(Map<String, String> errors, String field, String value, String label, int maxLength) {
if (value == null || value.isEmpty()) {
errors.put(field, label + " is required.");
return;
}
if (value.length() > maxLength) {
errors.put(field, label + " must be " + maxLength + " characters or fewer.");
}
}
private boolean canManageBorrowing(AuthenticatedUser actor) {
return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_BORROWING);
}
private boolean canViewOwnHistory(AuthenticatedUser actor) {
return actor != null
&& actor.getRole() == Role.READER
&& permissionPolicy.allows(actor.getRole(), Permission.BORROW_BOOKS);
}
private LocalDateTime now() {
return LocalDateTime.now(clock);
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
private static final class JdbcTransactionExecutor implements TransactionExecutor {
@Override
public <T> T execute(JdbcUtil.TransactionCallback<T> callback) {
return JdbcUtil.executeInTransaction(callback);
}
}
public static final class DirectTransactionExecutor implements TransactionExecutor {
@Override
public <T> T execute(JdbcUtil.TransactionCallback<T> callback) {
try {
return callback.execute(null);
} catch (SQLException ex) {
throw new DaoException("Unable to execute direct transaction", ex);
}
}
}
}