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

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,237 @@
package com.mzh.library.controller;
import com.mzh.library.dao.impl.JdbcBorrowRecordDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.BorrowRecord;
import com.mzh.library.entity.BorrowRecordSearchCriteria;
import com.mzh.library.entity.BorrowRecordStatus;
import com.mzh.library.service.ServiceResult;
import com.mzh.library.service.impl.BorrowingServiceImpl;
import com.mzh.library.util.SessionAttributes;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
public class BorrowingManagementServlet extends HttpServlet {
private static final String MANAGE_JSP = "/WEB-INF/jsp/borrowing/manage.jsp";
private static final String FORM_JSP = "/WEB-INF/jsp/borrowing/form.jsp";
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
private static final String FLASH_SUCCESS = "flashSuccess";
private static final String FLASH_ERROR = "flashError";
private BorrowingServiceImpl borrowingService;
@Override
public void init() {
this.borrowingService = new BorrowingServiceImpl(new JdbcBorrowRecordDao());
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String path = request.getServletPath();
if ("/borrowing/new".equals(path)) {
renderForm(request, response, Collections.emptyMap(), Collections.emptyMap(), null);
return;
}
if (!"/borrowing".equals(path)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
showManagementList(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String path = request.getServletPath();
if ("/borrowing/create".equals(path)) {
createBorrowRecord(request, response);
return;
}
if ("/borrowing/return".equals(path)) {
returnBook(request, response);
return;
}
if ("/borrowing/renew".equals(path)) {
renewLoan(request, response);
return;
}
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
private void showManagementList(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
BorrowRecordSearchCriteria criteria = searchCriteria(request);
request.setAttribute("criteria", criteria);
request.setAttribute("statuses", BorrowRecordStatus.values());
request.setAttribute("overdueStatus", BorrowRecordSearchCriteria.OVERDUE_STATUS);
request.setAttribute("maxRenewals", borrowingService.getMaxRenewals());
applyFlash(request);
ServiceResult<List<BorrowRecord>> result = borrowingService.searchRecords(currentUser(request), criteria);
if (isPermissionDenied(result)) {
forwardDenied(request, response, result.getMessage());
return;
}
request.setAttribute("borrowRecords", result.isSuccessful() ? result.getData() : Collections.emptyList());
if (!result.isSuccessful()) {
request.setAttribute("errorMessage", result.getMessage());
request.setAttribute("errors", result.getErrors());
}
request.getRequestDispatcher(MANAGE_JSP).forward(request, response);
}
private void createBorrowRecord(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
Map<String, String> values = formValues(request);
ServiceResult<Long> result = borrowingService.borrowBook(currentUser(request),
values.get("readerIdentifier"), values.get("bookIdentifier"));
if (isPermissionDenied(result)) {
forwardDenied(request, response, result.getMessage());
return;
}
if (!result.isSuccessful()) {
renderForm(request, response, values, result.getErrors(), result.getMessage());
return;
}
flashSuccess(request, result.getMessage());
response.sendRedirect(request.getContextPath() + "/borrowing");
}
private void returnBook(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
long id = requiredLong(request.getParameter("id"), -1L);
ServiceResult<Void> result = id <= 0
? ServiceResult.failure("Select a valid borrowing record.")
: borrowingService.returnBook(currentUser(request), id);
redirectWithResult(request, response, result);
}
private void renewLoan(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
long id = requiredLong(request.getParameter("id"), -1L);
ServiceResult<Void> result = id <= 0
? ServiceResult.failure("Select a valid borrowing record.")
: borrowingService.renewLoan(currentUser(request), id);
redirectWithResult(request, response, result);
}
private void redirectWithResult(HttpServletRequest request, HttpServletResponse response, ServiceResult<?> result)
throws IOException, ServletException {
if (isPermissionDenied(result)) {
forwardDenied(request, response, result.getMessage());
return;
}
if (result.isSuccessful()) {
flashSuccess(request, result.getMessage());
} else {
flashError(request, messageFor(result));
}
response.sendRedirect(request.getContextPath() + "/borrowing");
}
private void renderForm(HttpServletRequest request, HttpServletResponse response, Map<String, String> formValues,
Map<String, String> errors, String errorMessage)
throws ServletException, IOException {
request.setAttribute("formValues", formValues);
request.setAttribute("errors", errors);
if (errorMessage != null && !errorMessage.isEmpty()) {
request.setAttribute("errorMessage", errorMessage);
}
request.getRequestDispatcher(FORM_JSP).forward(request, response);
}
private BorrowRecordSearchCriteria searchCriteria(HttpServletRequest request) {
return new BorrowRecordSearchCriteria(
request.getParameter("readerIdentifier"),
request.getParameter("bookIdentifier"),
request.getParameter("status")
);
}
private Map<String, String> formValues(HttpServletRequest request) {
Map<String, String> values = new LinkedHashMap<>();
values.put("readerIdentifier", trim(request.getParameter("readerIdentifier")));
values.put("bookIdentifier", trim(request.getParameter("bookIdentifier")));
return values;
}
private long requiredLong(String value, long fallback) {
try {
long parsed = Long.parseLong(trim(value));
return parsed > 0 ? parsed : fallback;
} catch (NumberFormatException ex) {
return fallback;
}
}
private String messageFor(ServiceResult<?> result) {
if (result.getMessage() != null && !result.getMessage().isEmpty()) {
return result.getMessage();
}
if (result.hasErrors()) {
return result.getErrors().values().iterator().next();
}
return "Borrowing action failed.";
}
private boolean isPermissionDenied(ServiceResult<?> result) {
return !result.isSuccessful()
&& "You do not have permission to manage borrowing.".equals(result.getMessage());
}
private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message)
throws ServletException, IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
request.setAttribute("errorMessage", message);
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
}
private AuthenticatedUser currentUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null;
}
private void applyFlash(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return;
}
moveFlash(session, request, FLASH_SUCCESS, "successMessage");
moveFlash(session, request, FLASH_ERROR, "errorMessage");
}
private void moveFlash(HttpSession session, HttpServletRequest request, String sessionKey, String requestKey) {
Object value = session.getAttribute(sessionKey);
if (value != null) {
request.setAttribute(requestKey, value);
session.removeAttribute(sessionKey);
}
}
private void flashSuccess(HttpServletRequest request, String message) {
request.getSession().setAttribute(FLASH_SUCCESS, message);
}
private void flashError(HttpServletRequest request, String message) {
request.getSession().setAttribute(FLASH_ERROR, message);
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,67 @@
package com.mzh.library.controller;
import com.mzh.library.dao.impl.JdbcBorrowRecordDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.BorrowRecord;
import com.mzh.library.service.ServiceResult;
import com.mzh.library.service.impl.BorrowingServiceImpl;
import com.mzh.library.util.SessionAttributes;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
public class ReaderLoanHistoryServlet extends HttpServlet {
private static final String HISTORY_JSP = "/WEB-INF/jsp/reader/loans.jsp";
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
private static final String HISTORY_DENIED_MESSAGE = "You do not have permission to view loan history.";
private BorrowingServiceImpl borrowingService;
@Override
public void init() {
this.borrowingService = new BorrowingServiceImpl(new JdbcBorrowRecordDao());
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
ServiceResult<List<BorrowRecord>> result = borrowingService.listCurrentReaderHistory(currentUser(request));
if (isPermissionDenied(result)) {
forwardDenied(request, response, result.getMessage());
return;
}
request.setAttribute("borrowRecords", result.isSuccessful() ? result.getData() : Collections.emptyList());
if (result.isSuccessful() && result.getMessage() != null && !result.getMessage().isEmpty()) {
request.setAttribute("successMessage", result.getMessage());
}
if (!result.isSuccessful()) {
request.setAttribute("errorMessage", result.getMessage());
}
request.getRequestDispatcher(HISTORY_JSP).forward(request, response);
}
private boolean isPermissionDenied(ServiceResult<?> result) {
return !result.isSuccessful() && HISTORY_DENIED_MESSAGE.equals(result.getMessage());
}
private void forwardDenied(HttpServletRequest request, HttpServletResponse response, String message)
throws ServletException, IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
request.setAttribute("errorMessage", message);
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
}
private AuthenticatedUser currentUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
Object value = session == null ? null : session.getAttribute(SessionAttributes.AUTHENTICATED_USER);
return value instanceof AuthenticatedUser ? (AuthenticatedUser) value : null;
}
}
@@ -0,0 +1,37 @@
package com.mzh.library.dao;
import com.mzh.library.entity.Book;
import com.mzh.library.entity.BorrowRecord;
import com.mzh.library.entity.BorrowRecordSearchCriteria;
import com.mzh.library.entity.Reader;
import java.sql.Connection;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface BorrowRecordDao {
List<BorrowRecord> search(BorrowRecordSearchCriteria criteria);
List<BorrowRecord> findByReaderId(long readerId);
Optional<Reader> findReaderByUserId(long userId);
Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier);
Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier);
Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id);
int countActiveByReaderId(Connection connection, long readerId);
long create(Connection connection, BorrowRecord record);
boolean decrementAvailableCopies(Connection connection, long bookId);
boolean incrementAvailableCopies(Connection connection, long bookId);
boolean markReturned(Connection connection, long id, LocalDateTime returnedAt);
boolean renew(Connection connection, long id, LocalDateTime dueAt);
}
@@ -0,0 +1,368 @@
package com.mzh.library.dao.impl;
import com.mzh.library.dao.BorrowRecordDao;
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.Reader;
import com.mzh.library.entity.ReaderStatus;
import com.mzh.library.exception.DaoException;
import com.mzh.library.util.JdbcUtil;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcBorrowRecordDao implements BorrowRecordDao {
private static final String RECORD_COLUMNS = ""
+ "br.id, br.reader_id, r.reader_identifier, r.full_name AS reader_name, "
+ "br.book_id, b.book_identifier, b.title AS book_title, "
+ "br.borrowed_at, br.due_at, br.returned_at, br.renewal_count, br.status, "
+ "br.created_at, br.updated_at ";
private static final String RECORD_FROM = ""
+ "FROM borrow_records br "
+ "JOIN readers r ON r.id = br.reader_id "
+ "JOIN books b ON b.id = br.book_id ";
private static final String READER_COLUMNS = ""
+ "r.id, r.reader_identifier, r.user_id, u.username, r.full_name, r.phone, r.email, "
+ "r.status, r.max_borrow_count, r.created_at, r.updated_at ";
private static final String READER_FROM = ""
+ "FROM readers r "
+ "LEFT JOIN users u ON u.id = r.user_id ";
private static final String BOOK_COLUMNS = ""
+ "b.id, b.book_identifier, b.title, b.author, b.category_id, c.name AS category_name, "
+ "b.total_copies, b.available_copies, b.status, b.created_at, b.updated_at ";
private static final String BOOK_FROM = ""
+ "FROM books b "
+ "JOIN book_categories c ON c.id = b.category_id ";
private static final String FIND_READER_BY_USER_ID = "SELECT " + READER_COLUMNS + READER_FROM
+ "WHERE r.user_id = ?";
private static final String FIND_READER_BY_IDENTIFIER_FOR_UPDATE = "SELECT " + READER_COLUMNS + READER_FROM
+ "WHERE r.reader_identifier = ? FOR UPDATE";
private static final String FIND_BOOK_BY_IDENTIFIER_FOR_UPDATE = "SELECT " + BOOK_COLUMNS + BOOK_FROM
+ "WHERE b.book_identifier = ? FOR UPDATE";
private static final String FIND_RECORD_BY_ID_FOR_UPDATE = "SELECT " + RECORD_COLUMNS + RECORD_FROM
+ "WHERE br.id = ? FOR UPDATE";
private static final String COUNT_ACTIVE_BY_READER_ID = ""
+ "SELECT COUNT(*) "
+ "FROM borrow_records "
+ "WHERE reader_id = ? AND status = ? AND returned_at IS NULL";
private static final String CREATE = ""
+ "INSERT INTO borrow_records "
+ "(reader_id, book_id, borrowed_at, due_at, renewal_count, status) "
+ "VALUES (?, ?, ?, ?, ?, ?)";
private static final String DECREMENT_AVAILABLE = ""
+ "UPDATE books "
+ "SET available_copies = available_copies - 1 "
+ "WHERE id = ? AND available_copies > 0";
private static final String INCREMENT_AVAILABLE = ""
+ "UPDATE books "
+ "SET available_copies = LEAST(available_copies + 1, total_copies) "
+ "WHERE id = ?";
private static final String MARK_RETURNED = ""
+ "UPDATE borrow_records "
+ "SET status = ?, returned_at = ? "
+ "WHERE id = ? AND status = ? AND returned_at IS NULL";
private static final String RENEW = ""
+ "UPDATE borrow_records "
+ "SET due_at = ?, renewal_count = renewal_count + 1 "
+ "WHERE id = ? AND status = ? AND returned_at IS NULL";
@Override
public List<BorrowRecord> search(BorrowRecordSearchCriteria criteria) {
BorrowRecordSearchCriteria normalized = criteria == null ? new BorrowRecordSearchCriteria() : criteria;
List<Object> parameters = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT ")
.append(RECORD_COLUMNS)
.append(RECORD_FROM)
.append("WHERE 1 = 1 ");
appendLike(sql, parameters, "r.reader_identifier", normalized.getReaderIdentifier());
appendLike(sql, parameters, "b.book_identifier", normalized.getBookIdentifier());
appendStatus(sql, parameters, normalized);
appendOrder(sql);
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
bind(statement, parameters);
try (ResultSet resultSet = statement.executeQuery()) {
return mapRecords(resultSet);
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to search borrow records", ex);
}
}
@Override
public List<BorrowRecord> findByReaderId(long readerId) {
StringBuilder sql = new StringBuilder("SELECT ")
.append(RECORD_COLUMNS)
.append(RECORD_FROM)
.append("WHERE br.reader_id = ? ");
appendOrder(sql);
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(sql.toString())) {
statement.setLong(1, readerId);
try (ResultSet resultSet = statement.executeQuery()) {
return mapRecords(resultSet);
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to load reader borrow records", ex);
}
}
@Override
public Optional<Reader> findReaderByUserId(long userId) {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(FIND_READER_BY_USER_ID)) {
statement.setLong(1, userId);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? Optional.of(mapReader(resultSet)) : Optional.empty();
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to load reader by user id for borrowing", ex);
}
}
@Override
public Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier) {
try (PreparedStatement statement = connection.prepareStatement(FIND_READER_BY_IDENTIFIER_FOR_UPDATE)) {
statement.setString(1, identifier);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? Optional.of(mapReader(resultSet)) : Optional.empty();
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to lock reader for borrowing", ex);
}
}
@Override
public Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier) {
try (PreparedStatement statement = connection.prepareStatement(FIND_BOOK_BY_IDENTIFIER_FOR_UPDATE)) {
statement.setString(1, identifier);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? Optional.of(mapBook(resultSet)) : Optional.empty();
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to lock book for borrowing", ex);
}
}
@Override
public Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id) {
try (PreparedStatement statement = connection.prepareStatement(FIND_RECORD_BY_ID_FOR_UPDATE)) {
statement.setLong(1, id);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? Optional.of(mapRecord(resultSet)) : Optional.empty();
}
} catch (SQLException | IllegalArgumentException ex) {
throw new DaoException("Unable to lock borrow record", ex);
}
}
@Override
public int countActiveByReaderId(Connection connection, long readerId) {
try (PreparedStatement statement = connection.prepareStatement(COUNT_ACTIVE_BY_READER_ID)) {
statement.setLong(1, readerId);
statement.setString(2, BorrowRecordStatus.ACTIVE.getCode());
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? resultSet.getInt(1) : 0;
}
} catch (SQLException ex) {
throw new DaoException("Unable to count active borrow records", ex);
}
}
@Override
public long create(Connection connection, BorrowRecord record) {
try (PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) {
statement.setLong(1, record.getReaderId());
statement.setLong(2, record.getBookId());
statement.setTimestamp(3, Timestamp.valueOf(record.getBorrowedAt()));
statement.setTimestamp(4, Timestamp.valueOf(record.getDueAt()));
statement.setInt(5, record.getRenewalCount());
statement.setString(6, record.getStatus().getCode());
statement.executeUpdate();
try (ResultSet generatedKeys = statement.getGeneratedKeys()) {
if (generatedKeys.next()) {
return generatedKeys.getLong(1);
}
}
throw new DaoException("Unable to read generated borrow record id", null);
} catch (SQLException ex) {
throw new DaoException("Unable to create borrow record", ex);
}
}
@Override
public boolean decrementAvailableCopies(Connection connection, long bookId) {
try (PreparedStatement statement = connection.prepareStatement(DECREMENT_AVAILABLE)) {
statement.setLong(1, bookId);
return statement.executeUpdate() == 1;
} catch (SQLException ex) {
throw new DaoException("Unable to decrement available book copies", ex);
}
}
@Override
public boolean incrementAvailableCopies(Connection connection, long bookId) {
try (PreparedStatement statement = connection.prepareStatement(INCREMENT_AVAILABLE)) {
statement.setLong(1, bookId);
statement.executeUpdate();
return true;
} catch (SQLException ex) {
throw new DaoException("Unable to increment available book copies", ex);
}
}
@Override
public boolean markReturned(Connection connection, long id, LocalDateTime returnedAt) {
try (PreparedStatement statement = connection.prepareStatement(MARK_RETURNED)) {
statement.setString(1, BorrowRecordStatus.RETURNED.getCode());
statement.setTimestamp(2, Timestamp.valueOf(returnedAt));
statement.setLong(3, id);
statement.setString(4, BorrowRecordStatus.ACTIVE.getCode());
return statement.executeUpdate() == 1;
} catch (SQLException ex) {
throw new DaoException("Unable to mark borrow record returned", ex);
}
}
@Override
public boolean renew(Connection connection, long id, LocalDateTime dueAt) {
try (PreparedStatement statement = connection.prepareStatement(RENEW)) {
statement.setTimestamp(1, Timestamp.valueOf(dueAt));
statement.setLong(2, id);
statement.setString(3, BorrowRecordStatus.ACTIVE.getCode());
return statement.executeUpdate() == 1;
} catch (SQLException ex) {
throw new DaoException("Unable to renew borrow record", ex);
}
}
private void appendLike(StringBuilder sql, List<Object> parameters, String column, String value) {
if (value == null || value.trim().isEmpty()) {
return;
}
sql.append("AND ").append(column).append(" LIKE ? ");
parameters.add("%" + value.trim() + "%");
}
private void appendStatus(StringBuilder sql, List<Object> parameters, BorrowRecordSearchCriteria criteria) {
String statusCode = criteria.getStatusCode();
if (statusCode == null || statusCode.isEmpty()) {
return;
}
if (criteria.isOverdueOnly()) {
sql.append("AND br.status = ? AND br.returned_at IS NULL AND br.due_at < CURRENT_TIMESTAMP ");
parameters.add(BorrowRecordStatus.ACTIVE.getCode());
return;
}
sql.append("AND br.status = ? ");
parameters.add(statusCode);
}
private void appendOrder(StringBuilder sql) {
sql.append("ORDER BY ")
.append("CASE ")
.append("WHEN br.status = 'active' AND br.returned_at IS NULL AND br.due_at < CURRENT_TIMESTAMP THEN 0 ")
.append("WHEN br.status = 'active' THEN 1 ELSE 2 END, ")
.append("br.due_at, br.borrowed_at DESC");
}
private void bind(PreparedStatement statement, List<Object> parameters) throws SQLException {
for (int i = 0; i < parameters.size(); i++) {
statement.setString(i + 1, parameters.get(i).toString());
}
}
private List<BorrowRecord> mapRecords(ResultSet resultSet) throws SQLException {
List<BorrowRecord> records = new ArrayList<>();
while (resultSet.next()) {
records.add(mapRecord(resultSet));
}
return records;
}
private BorrowRecord mapRecord(ResultSet resultSet) throws SQLException {
BorrowRecord record = new BorrowRecord();
record.setId(resultSet.getLong("id"));
record.setReaderId(resultSet.getLong("reader_id"));
record.setReaderIdentifier(resultSet.getString("reader_identifier"));
record.setReaderName(resultSet.getString("reader_name"));
record.setBookId(resultSet.getLong("book_id"));
record.setBookIdentifier(resultSet.getString("book_identifier"));
record.setBookTitle(resultSet.getString("book_title"));
record.setBorrowedAt(toLocalDateTime(resultSet.getTimestamp("borrowed_at")));
record.setDueAt(toLocalDateTime(resultSet.getTimestamp("due_at")));
record.setReturnedAt(toLocalDateTime(resultSet.getTimestamp("returned_at")));
record.setRenewalCount(resultSet.getInt("renewal_count"));
record.setStatus(BorrowRecordStatus.fromCode(resultSet.getString("status")));
record.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
record.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
return record;
}
private Reader mapReader(ResultSet resultSet) throws SQLException {
Reader reader = new Reader();
reader.setId(resultSet.getLong("id"));
reader.setIdentifier(resultSet.getString("reader_identifier"));
long userId = resultSet.getLong("user_id");
reader.setUserId(resultSet.wasNull() ? null : userId);
reader.setUsername(resultSet.getString("username"));
reader.setFullName(resultSet.getString("full_name"));
reader.setPhone(resultSet.getString("phone"));
reader.setEmail(resultSet.getString("email"));
reader.setStatus(ReaderStatus.fromCode(resultSet.getString("status")));
reader.setMaxBorrowCount(resultSet.getInt("max_borrow_count"));
reader.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
reader.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
return reader;
}
private Book mapBook(ResultSet resultSet) throws SQLException {
Book book = new Book();
book.setId(resultSet.getLong("id"));
book.setIdentifier(resultSet.getString("book_identifier"));
book.setTitle(resultSet.getString("title"));
book.setAuthor(resultSet.getString("author"));
book.setCategoryId(resultSet.getLong("category_id"));
book.setCategoryName(resultSet.getString("category_name"));
book.setTotalCopies(resultSet.getInt("total_copies"));
book.setAvailableCopies(resultSet.getInt("available_copies"));
book.setStatus(BookStatus.fromCode(resultSet.getString("status")));
book.setCreatedAt(toLocalDateTime(resultSet.getTimestamp("created_at")));
book.setUpdatedAt(toLocalDateTime(resultSet.getTimestamp("updated_at")));
return book;
}
private LocalDateTime toLocalDateTime(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toLocalDateTime();
}
}
@@ -0,0 +1,166 @@
package com.mzh.library.entity;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class BorrowRecord {
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private long id;
private long readerId;
private String readerIdentifier;
private String readerName;
private long bookId;
private String bookIdentifier;
private String bookTitle;
private LocalDateTime borrowedAt;
private LocalDateTime dueAt;
private LocalDateTime returnedAt;
private int renewalCount;
private BorrowRecordStatus status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public long getReaderId() {
return readerId;
}
public void setReaderId(long readerId) {
this.readerId = readerId;
}
public String getReaderIdentifier() {
return readerIdentifier;
}
public void setReaderIdentifier(String readerIdentifier) {
this.readerIdentifier = readerIdentifier;
}
public String getReaderName() {
return readerName;
}
public void setReaderName(String readerName) {
this.readerName = readerName;
}
public long getBookId() {
return bookId;
}
public void setBookId(long bookId) {
this.bookId = bookId;
}
public String getBookIdentifier() {
return bookIdentifier;
}
public void setBookIdentifier(String bookIdentifier) {
this.bookIdentifier = bookIdentifier;
}
public String getBookTitle() {
return bookTitle;
}
public void setBookTitle(String bookTitle) {
this.bookTitle = bookTitle;
}
public LocalDateTime getBorrowedAt() {
return borrowedAt;
}
public void setBorrowedAt(LocalDateTime borrowedAt) {
this.borrowedAt = borrowedAt;
}
public LocalDateTime getDueAt() {
return dueAt;
}
public void setDueAt(LocalDateTime dueAt) {
this.dueAt = dueAt;
}
public LocalDateTime getReturnedAt() {
return returnedAt;
}
public void setReturnedAt(LocalDateTime returnedAt) {
this.returnedAt = returnedAt;
}
public int getRenewalCount() {
return renewalCount;
}
public void setRenewalCount(int renewalCount) {
this.renewalCount = renewalCount;
}
public BorrowRecordStatus getStatus() {
return status;
}
public void setStatus(BorrowRecordStatus status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public boolean isOverdue() {
return BorrowRecordStatus.ACTIVE == status
&& returnedAt == null
&& dueAt != null
&& dueAt.isBefore(LocalDateTime.now());
}
public String getDisplayStatusCode() {
return isOverdue() ? BorrowRecordSearchCriteria.OVERDUE_STATUS : status.getCode();
}
public String getDisplayStatusName() {
return isOverdue() ? "Overdue" : status.getDisplayName();
}
public String getBorrowedAtText() {
return format(borrowedAt);
}
public String getDueAtText() {
return format(dueAt);
}
public String getReturnedAtText() {
return format(returnedAt);
}
private String format(LocalDateTime value) {
return value == null ? "" : DISPLAY_FORMAT.format(value);
}
}
@@ -0,0 +1,50 @@
package com.mzh.library.entity;
public class BorrowRecordSearchCriteria {
public static final String OVERDUE_STATUS = "overdue";
private String readerIdentifier;
private String bookIdentifier;
private String statusCode;
public BorrowRecordSearchCriteria() {
}
public BorrowRecordSearchCriteria(String readerIdentifier, String bookIdentifier, String statusCode) {
this.readerIdentifier = trim(readerIdentifier);
this.bookIdentifier = trim(bookIdentifier);
this.statusCode = trim(statusCode);
}
public String getReaderIdentifier() {
return readerIdentifier;
}
public void setReaderIdentifier(String readerIdentifier) {
this.readerIdentifier = trim(readerIdentifier);
}
public String getBookIdentifier() {
return bookIdentifier;
}
public void setBookIdentifier(String bookIdentifier) {
this.bookIdentifier = trim(bookIdentifier);
}
public String getStatusCode() {
return statusCode;
}
public void setStatusCode(String statusCode) {
this.statusCode = trim(statusCode);
}
public boolean isOverdueOnly() {
return OVERDUE_STATUS.equals(statusCode);
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,39 @@
package com.mzh.library.entity;
import java.util.Locale;
public enum BorrowRecordStatus {
ACTIVE("active", "Active"),
RETURNED("returned", "Returned");
private final String code;
private final String displayName;
BorrowRecordStatus(String code, String displayName) {
this.code = code;
this.displayName = displayName;
}
public String getCode() {
return code;
}
public String getDisplayName() {
return displayName;
}
public static BorrowRecordStatus fromCode(String code) {
if (code == null || code.trim().isEmpty()) {
throw new IllegalArgumentException("Borrow record status is required");
}
String normalized = code.trim().toLowerCase(Locale.ROOT);
for (BorrowRecordStatus status : values()) {
if (status.code.equals(normalized)) {
return status;
}
}
throw new IllegalArgumentException("Unsupported borrow record status: " + code);
}
}
@@ -3,6 +3,7 @@ package com.mzh.library.filter;
import com.mzh.library.dao.impl.JdbcUserDao;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Permission;
import com.mzh.library.entity.Role;
import com.mzh.library.service.AuthService;
import com.mzh.library.service.impl.AuthServiceImpl;
import com.mzh.library.util.SessionAttributes;
@@ -25,11 +26,13 @@ public class AuthorizationFilter implements Filter {
private static final Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName());
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
private static final List<PathRule> RULES = Arrays.asList(
new PathRule("/borrowing", Permission.MANAGE_BORROWING),
new PathRule("/books", Permission.MANAGE_BOOKS),
new PathRule("/readers", Permission.MANAGE_READERS),
new PathRule("/catalog", Permission.VIEW_CATALOG),
new PathRule("/admin", Permission.MANAGE_USERS),
new PathRule("/librarian", Permission.MANAGE_BORROWING),
new PathRule("/reader/loans", Permission.BORROW_BOOKS, Role.READER),
new PathRule("/reader", Permission.VIEW_CATALOG)
);
@@ -41,38 +44,39 @@ public class AuthorizationFilter implements Filter {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String path = relativePath(httpRequest);
Permission requiredPermission = requiredPermission(path);
PathRule requiredRule = requiredRule(path);
if (requiredPermission == null) {
if (requiredRule == null) {
chain.doFilter(request, response);
return;
}
AuthenticatedUser user = currentUser(httpRequest.getSession(false));
if (authService.hasPermission(user, requiredPermission)) {
if (requiredRule.allows(authService, user)) {
chain.doFilter(request, response);
return;
}
logDeniedAccess(user, requiredPermission, path);
logDeniedAccess(user, requiredRule, path);
httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
request.setAttribute("errorMessage", "You do not have permission to access this page.");
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response);
}
private void logDeniedAccess(AuthenticatedUser user, Permission requiredPermission, String path) {
private void logDeniedAccess(AuthenticatedUser user, PathRule requiredRule, String path) {
String actor = user == null
? "anonymous"
: "userId=" + user.getId() + " role=" + user.getRole().getCode();
LOGGER.warning("Permission denied path=" + path
+ " requiredPermission=" + requiredPermission.getCode()
+ " requiredPermission=" + requiredRule.permission.getCode()
+ " requiredRole=" + requiredRule.requiredRoleCode()
+ " actor=" + actor);
}
private Permission requiredPermission(String path) {
private PathRule requiredRule(String path) {
for (PathRule rule : RULES) {
if (path.equals(rule.prefix) || path.startsWith(rule.prefix + "/")) {
return rule.permission;
return rule;
}
}
return null;
@@ -95,10 +99,25 @@ public class AuthorizationFilter implements Filter {
private static final class PathRule {
private final String prefix;
private final Permission permission;
private final Role requiredRole;
private PathRule(String prefix, Permission permission) {
this(prefix, permission, null);
}
private PathRule(String prefix, Permission permission, Role requiredRole) {
this.prefix = prefix;
this.permission = permission;
this.requiredRole = requiredRole;
}
private boolean allows(AuthService authService, AuthenticatedUser user) {
boolean roleAllowed = requiredRole == null || (user != null && user.getRole() == requiredRole);
return roleAllowed && authService.hasPermission(user, permission);
}
private String requiredRoleCode() {
return requiredRole == null ? "any" : requiredRole.getCode();
}
}
}
@@ -0,0 +1,19 @@
package com.mzh.library.service;
import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.BorrowRecord;
import com.mzh.library.entity.BorrowRecordSearchCriteria;
import java.util.List;
public interface BorrowingService {
ServiceResult<List<BorrowRecord>> searchRecords(AuthenticatedUser actor, BorrowRecordSearchCriteria criteria);
ServiceResult<Long> borrowBook(AuthenticatedUser actor, String readerIdentifier, String bookIdentifier);
ServiceResult<Void> returnBook(AuthenticatedUser actor, long recordId);
ServiceResult<Void> renewLoan(AuthenticatedUser actor, long recordId);
ServiceResult<List<BorrowRecord>> listCurrentReaderHistory(AuthenticatedUser actor);
}
@@ -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);
}
}
}
}
@@ -13,6 +13,11 @@ public final class JdbcUtil {
private static final String CONFIG_FILE = "db.properties";
private static final String DEFAULT_DRIVER = "com.mysql.cj.jdbc.Driver";
@FunctionalInterface
public interface TransactionCallback<T> {
T execute(Connection connection) throws SQLException;
}
private JdbcUtil() {
}
@@ -31,6 +36,33 @@ public final class JdbcUtil {
}
}
public static <T> T executeInTransaction(TransactionCallback<T> callback) {
try (Connection connection = getConnection()) {
connection.setAutoCommit(false);
try {
T result = callback.execute(connection);
connection.commit();
return result;
} catch (SQLException ex) {
rollbackQuietly(connection);
throw new DaoException("Unable to complete database transaction", ex);
} catch (RuntimeException ex) {
rollbackQuietly(connection);
throw ex;
}
} catch (SQLException ex) {
throw new DaoException("Unable to complete database transaction", ex);
}
}
private static void rollbackQuietly(Connection connection) {
try {
connection.rollback();
} catch (SQLException ignored) {
// Preserve the original transaction failure for callers and logs.
}
}
private static Properties loadProperties() {
try (InputStream inputStream = Thread.currentThread()
.getContextClassLoader()
+29 -19
View File
@@ -117,6 +117,31 @@ CREATE TABLE IF NOT EXISTS books (
CHECK (status IN ('available', 'unavailable', 'archived'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS borrow_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
reader_id BIGINT NOT NULL,
book_id BIGINT NOT NULL,
borrowed_at DATETIME NOT NULL,
due_at DATETIME NOT NULL,
returned_at DATETIME NULL,
renewal_count INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_borrow_records_reader_id (reader_id),
KEY idx_borrow_records_book_id (book_id),
KEY idx_borrow_records_status (status),
KEY idx_borrow_records_due_at (due_at),
CONSTRAINT fk_borrow_records_reader
FOREIGN KEY (reader_id) REFERENCES readers (id),
CONSTRAINT fk_borrow_records_book
FOREIGN KEY (book_id) REFERENCES books (id),
CONSTRAINT chk_borrow_records_renewal_count
CHECK (renewal_count >= 0),
CONSTRAINT chk_borrow_records_status
CHECK (status IN ('active', 'returned'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO roles (code, name, description) VALUES
('administrator', 'Administrator', 'Full system administration role'),
('librarian', 'Librarian', 'Library operation and borrowing management role'),
@@ -146,7 +171,6 @@ INSERT IGNORE INTO role_permissions (role_code, permission_code) VALUES
('administrator', 'view_reports'),
('administrator', 'view_system_logs'),
('administrator', 'view_catalog'),
('administrator', 'borrow_books'),
('librarian', 'manage_books'),
('librarian', 'manage_readers'),
('librarian', 'manage_borrowing'),
@@ -162,17 +186,10 @@ INSERT IGNORE INTO users (username, password_hash, display_name, role_code, acti
('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1),
('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 1);
INSERT INTO readers (reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) VALUES
INSERT IGNORE INTO readers (reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) VALUES
('RD-0001', (SELECT id FROM users WHERE username = 'reader'), 'Demo Reader', '13800000000',
'reader@example.com', 'active', 5),
('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3)
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
full_name = VALUES(full_name),
phone = VALUES(phone),
email = VALUES(email),
status = VALUES(status),
max_borrow_count = VALUES(max_borrow_count);
('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3);
INSERT INTO book_categories (name, description) VALUES
('Computer Science', 'Programming, software engineering, and systems books'),
@@ -182,7 +199,7 @@ INSERT INTO book_categories (name, description) VALUES
ON DUPLICATE KEY UPDATE
description = VALUES(description);
INSERT INTO books (book_identifier, title, author, category_id, total_copies, available_copies, status) VALUES
INSERT IGNORE INTO books (book_identifier, title, author, category_id, total_copies, available_copies, status) VALUES
('BK-0001', 'Effective Java', 'Joshua Bloch',
(SELECT id FROM book_categories WHERE name = 'Computer Science'), 5, 4, 'available'),
('BK-0002', 'Clean Code', 'Robert C. Martin',
@@ -190,11 +207,4 @@ INSERT INTO books (book_identifier, title, author, category_id, total_copies, av
('BK-0003', 'Pride and Prejudice', 'Jane Austen',
(SELECT id FROM book_categories WHERE name = 'Literature'), 3, 3, 'available'),
('BK-0004', 'A Brief History of Time', 'Stephen Hawking',
(SELECT id FROM book_categories WHERE name = 'Science'), 2, 1, 'available')
ON DUPLICATE KEY UPDATE
title = VALUES(title),
author = VALUES(author),
category_id = VALUES(category_id),
total_copies = VALUES(total_copies),
available_copies = VALUES(available_copies),
status = VALUES(status);
(SELECT id FROM book_categories WHERE name = 'Science'), 2, 1, 'available');
@@ -0,0 +1,57 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New Borrow - MZH Library</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
</head>
<body>
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
<main class="page-shell">
<section class="form-panel" aria-labelledby="borrow-form-title">
<p class="eyebrow">Borrowing Management</p>
<h1 id="borrow-form-title">New borrow</h1>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<c:set var="readerIdentifierValue" value="${formValues.readerIdentifier}" />
<c:set var="bookIdentifierValue" value="${formValues.bookIdentifier}" />
<form class="borrow-form" action="${pageContext.request.contextPath}/borrowing/create" method="post" novalidate>
<div class="form-grid">
<div class="form-field">
<label for="readerIdentifier">Reader ID</label>
<input id="readerIdentifier" name="readerIdentifier" type="text"
value="${fn:escapeXml(readerIdentifierValue)}" required>
<c:if test="${not empty errors.readerIdentifier}">
<span class="field-error"><c:out value="${errors.readerIdentifier}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="bookIdentifier">Book ID</label>
<input id="bookIdentifier" name="bookIdentifier" type="text"
value="${fn:escapeXml(bookIdentifierValue)}" required>
<c:if test="${not empty errors.bookIdentifier}">
<span class="field-error"><c:out value="${errors.bookIdentifier}" /></span>
</c:if>
</div>
</div>
<div class="form-actions">
<button class="button button-primary" type="submit">Borrow</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Cancel</a>
</div>
</form>
</section>
</main>
</body>
</html>
@@ -0,0 +1,155 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Borrowing Management - MZH Library</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
</head>
<body>
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
<main class="page-shell">
<section class="dashboard-hero catalog-hero" aria-labelledby="borrowing-title">
<div>
<p class="eyebrow">Borrowing Management</p>
<h1 id="borrowing-title">Manage borrowing</h1>
<p>Create borrow records, process returns, renew active loans, and review overdue items.</p>
</div>
<a class="button button-primary" href="${pageContext.request.contextPath}/borrowing/new">New borrow</a>
</section>
<c:if test="${not empty successMessage}">
<div class="message message-success" role="status">
<c:out value="${successMessage}" />
</div>
</c:if>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<section class="toolbar-panel" aria-label="Borrowing search">
<form class="search-form borrowing-search-form" action="${pageContext.request.contextPath}/borrowing" method="get">
<div class="search-field">
<label for="readerIdentifier">Reader ID</label>
<input id="readerIdentifier" name="readerIdentifier" type="text"
value="${fn:escapeXml(criteria.readerIdentifier)}">
</div>
<div class="search-field">
<label for="bookIdentifier">Book ID</label>
<input id="bookIdentifier" name="bookIdentifier" type="text"
value="${fn:escapeXml(criteria.bookIdentifier)}">
</div>
<div class="search-field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">All statuses</option>
<c:forEach var="status" items="${statuses}">
<option value="${status.code}" <c:if test="${criteria.statusCode == status.code}">selected</c:if>>
<c:out value="${status.displayName}" />
</option>
</c:forEach>
<option value="${overdueStatus}" <c:if test="${criteria.statusCode == overdueStatus}">selected</c:if>>
Overdue
</option>
</select>
<c:if test="${not empty errors.status}">
<span class="field-error"><c:out value="${errors.status}" /></span>
</c:if>
</div>
<button class="button button-primary" type="submit">Search</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Clear</a>
</form>
</section>
<section class="table-panel" aria-labelledby="borrowing-results-title">
<h2 id="borrowing-results-title">Borrowing records</h2>
<c:choose>
<c:when test="${empty borrowRecords}">
<p class="empty-state">No borrowing records match the current filters.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table borrowing-table">
<thead>
<tr>
<th scope="col">Reader</th>
<th scope="col">Book</th>
<th scope="col">Borrowed</th>
<th scope="col">Due</th>
<th scope="col">Returned</th>
<th scope="col">Renewals</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<c:forEach var="record" items="${borrowRecords}">
<tr>
<td>
<strong><c:out value="${record.readerIdentifier}" /></strong>
<div class="muted-text"><c:out value="${record.readerName}" /></div>
</td>
<td>
<strong><c:out value="${record.bookIdentifier}" /></strong>
<div class="muted-text"><c:out value="${record.bookTitle}" /></div>
</td>
<td><c:out value="${record.borrowedAtText}" /></td>
<td><c:out value="${record.dueAtText}" /></td>
<td>
<c:choose>
<c:when test="${not empty record.returnedAtText}">
<c:out value="${record.returnedAtText}" />
</c:when>
<c:otherwise>Not returned</c:otherwise>
</c:choose>
</td>
<td><c:out value="${record.renewalCount}" /> / <c:out value="${maxRenewals}" /></td>
<td>
<span class="status-pill status-${record.displayStatusCode}">
<c:out value="${record.displayStatusName}" />
</span>
</td>
<td>
<c:choose>
<c:when test="${record.status.code == 'active'}">
<div class="table-actions">
<form action="${pageContext.request.contextPath}/borrowing/return"
method="post"
onsubmit="return confirm('Return this book?');">
<input type="hidden" name="id" value="${record.id}">
<button class="button button-secondary" type="submit">Return</button>
</form>
<c:if test="${record.renewalCount < maxRenewals}">
<form action="${pageContext.request.contextPath}/borrowing/renew"
method="post"
onsubmit="return confirm('Renew this loan?');">
<input type="hidden" name="id" value="${record.id}">
<button class="button button-secondary" type="submit">Renew</button>
</form>
</c:if>
</div>
</c:when>
<c:otherwise>
<span class="muted-text">Complete</span>
</c:otherwise>
</c:choose>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
</main>
</body>
</html>
@@ -12,8 +12,12 @@
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
<a href="${pageContext.request.contextPath}/books">Books</a>
<a href="${pageContext.request.contextPath}/readers">Readers</a>
<a href="${pageContext.request.contextPath}/borrowing">Borrowing</a>
</c:if>
<a href="${pageContext.request.contextPath}/reader/home">Reader</a>
<c:if test="${sessionScope.userRole == 'reader'}">
<a href="${pageContext.request.contextPath}/reader/loans">My Loans</a>
</c:if>
<span class="user-pill">
<c:out value="${sessionScope.authenticatedUser.displayName}" />
</span>
+15 -1
View File
@@ -46,6 +46,12 @@
<p>Create, update, deactivate, and review reader eligibility records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Open</a>
</article>
<article class="workspace-card">
<h2>Borrowing Management</h2>
<p>Create loans, process returns, renew active records, and review overdue items.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Open</a>
</article>
</c:if>
<article class="workspace-card">
@@ -56,9 +62,17 @@
<article class="workspace-card">
<h2>Reader Center</h2>
<p>Reader self-service entry point.</p>
<p>Reader self-service entry point for catalog access and loan history.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/home">Open</a>
</article>
<c:if test="${sessionScope.userRole == 'reader'}">
<article class="workspace-card">
<h2>My Loan History</h2>
<p>Review your active, returned, and overdue borrowing records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/loans">Open</a>
</article>
</c:if>
</section>
</main>
</body>
@@ -0,0 +1,85 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loan History - MZH Library</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
</head>
<body>
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
<main class="page-shell">
<section class="dashboard-hero catalog-hero" aria-labelledby="loan-history-title">
<div>
<p class="eyebrow">Reader Center</p>
<h1 id="loan-history-title">Loan history</h1>
<p>Review your active, returned, and overdue borrowing records.</p>
</div>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Search catalog</a>
</section>
<c:if test="${not empty successMessage}">
<div class="message message-success" role="status">
<c:out value="${successMessage}" />
</div>
</c:if>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<section class="table-panel" aria-labelledby="loan-results-title">
<h2 id="loan-results-title">Borrowing records</h2>
<c:choose>
<c:when test="${empty borrowRecords}">
<p class="empty-state">No borrowing records are available for this account.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table borrowing-table">
<thead>
<tr>
<th scope="col">Book ID</th>
<th scope="col">Title</th>
<th scope="col">Borrowed</th>
<th scope="col">Due</th>
<th scope="col">Returned</th>
<th scope="col">Renewals</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<c:forEach var="record" items="${borrowRecords}">
<tr>
<td><c:out value="${record.bookIdentifier}" /></td>
<td><c:out value="${record.bookTitle}" /></td>
<td><c:out value="${record.borrowedAtText}" /></td>
<td><c:out value="${record.dueAtText}" /></td>
<td>
<c:choose>
<c:when test="${not empty record.returnedAtText}">
<c:out value="${record.returnedAtText}" />
</c:when>
<c:otherwise>Not returned</c:otherwise>
</c:choose>
</td>
<td><c:out value="${record.renewalCount}" /></td>
<td>
<span class="status-pill status-${record.displayStatusCode}">
<c:out value="${record.displayStatusName}" />
</span>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
</main>
</body>
</html>
+14
View File
@@ -39,6 +39,20 @@
<p>Create, update, deactivate, and review eligibility fields for reader records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Manage readers</a>
</article>
<article class="workspace-card">
<h2>Borrowing Management</h2>
<p>Create loans, process returns, renew records, and review overdue items.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/borrowing">Manage borrowing</a>
</article>
</c:if>
<c:if test="${sessionScope.userRole == 'reader'}">
<article class="workspace-card">
<h2>My Loan History</h2>
<p>Review active loans, returned records, renewal counts, and overdue status.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/loans">View history</a>
</article>
</c:if>
</section>
</main>
+22
View File
@@ -110,6 +110,28 @@
<url-pattern>/readers/delete</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>BorrowingManagementServlet</servlet-name>
<servlet-class>com.mzh.library.controller.BorrowingManagementServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>BorrowingManagementServlet</servlet-name>
<url-pattern>/borrowing</url-pattern>
<url-pattern>/borrowing/new</url-pattern>
<url-pattern>/borrowing/create</url-pattern>
<url-pattern>/borrowing/return</url-pattern>
<url-pattern>/borrowing/renew</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>ReaderLoanHistoryServlet</servlet-name>
<servlet-class>com.mzh.library.controller.ReaderLoanHistoryServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>ReaderLoanHistoryServlet</servlet-name>
<url-pattern>/reader/loans</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>UnauthorizedServlet</servlet-name>
<servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class>
+29 -3
View File
@@ -289,6 +289,10 @@ h2 {
align-items: end;
}
.borrowing-search-form {
grid-template-columns: repeat(3, minmax(120px, 1fr)) auto auto;
}
.search-field {
display: grid;
gap: 6px;
@@ -305,7 +309,8 @@ h2 {
.book-form input,
.book-form select,
.reader-form input,
.reader-form select {
.reader-form select,
.borrow-form input {
width: 100%;
min-height: 42px;
padding: 9px 11px;
@@ -320,7 +325,8 @@ h2 {
.book-form input:focus,
.book-form select:focus,
.reader-form input:focus,
.reader-form select:focus {
.reader-form select:focus,
.borrow-form input:focus {
outline: 3px solid rgba(37, 111, 108, 0.18);
border-color: var(--color-primary);
}
@@ -337,6 +343,10 @@ h2 {
font-size: 14px;
}
.borrowing-table {
min-width: 980px;
}
.data-table th,
.data-table td {
padding: 12px 10px;
@@ -397,6 +407,21 @@ h2 {
background: #eef1f5;
}
.status-returned {
color: var(--color-muted);
background: #eef1f5;
}
.status-overdue {
color: #7a211a;
background: #fff0ee;
}
.muted-text {
color: var(--color-muted);
font-size: 13px;
}
.table-actions {
display: flex;
gap: 8px;
@@ -413,7 +438,8 @@ h2 {
}
.book-form,
.reader-form {
.reader-form,
.borrow-form {
display: grid;
gap: 20px;
}
@@ -0,0 +1,455 @@
package com.mzh.library.service;
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.impl.BorrowingServiceImpl;
import java.sql.Connection;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.EnumSet;
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 final class BorrowingServiceCheck {
private static final String UNAVAILABLE_MESSAGE =
"Borrowing service is temporarily unavailable. Please try again later.";
private static final Clock FIXED_CLOCK = Clock.fixed(
Instant.parse("2026-04-27T00:00:00Z"),
ZoneId.of("UTC")
);
private BorrowingServiceCheck() {
}
public static void main(String[] args) {
Logger.getLogger(BorrowingServiceImpl.class.getName()).setLevel(Level.OFF);
InMemoryBorrowRecordDao dao = new InMemoryBorrowRecordDao();
BorrowingService service = service(dao);
AuthenticatedUser librarian = user(10L, Role.LIBRARIAN);
AuthenticatedUser readerUser = user(20L, Role.READER);
AuthenticatedUser administrator = user(30L, Role.ADMINISTRATOR);
Reader activeReader = reader(1L, "RD-1000", 20L, "Active Reader", ReaderStatus.ACTIVE, 1);
Reader suspendedReader = reader(2L, "RD-1001", null, "Suspended Reader", ReaderStatus.SUSPENDED, 3);
Reader renewingReader = reader(3L, "RD-1002", null, "Renewing Reader", ReaderStatus.ACTIVE, 2);
dao.putReader(activeReader);
dao.putReader(suspendedReader);
dao.putReader(renewingReader);
dao.putBook(book(1L, "BK-1000", "Available Book", BookStatus.AVAILABLE, 2, 1));
dao.putBook(book(2L, "BK-1001", "Unavailable Book", BookStatus.UNAVAILABLE, 1, 1));
dao.putBook(book(3L, "BK-1002", "No Copies", BookStatus.AVAILABLE, 1, 0));
dao.putBook(book(4L, "BK-1003", "Renewable Book", BookStatus.AVAILABLE, 1, 1));
dao.putBook(book(5L, "BK-1004", "Limit Book", BookStatus.AVAILABLE, 1, 1));
ServiceResult<Long> denied = service.borrowBook(readerUser, "RD-1000", "BK-1000");
require(!denied.isSuccessful(), "reader should not manage borrow creation");
require("You do not have permission to manage borrowing.".equals(denied.getMessage()),
"reader borrow creation should return permission message");
ServiceResult<Long> suspended = service.borrowBook(librarian, "RD-1001", "BK-1000");
require(!suspended.isSuccessful(), "suspended reader should not borrow");
require(suspended.getErrors().containsKey("readerIdentifier"),
"suspended reader failure should target readerIdentifier");
ServiceResult<Long> unavailable = service.borrowBook(librarian, "RD-1000", "BK-1001");
require(!unavailable.isSuccessful(), "unavailable book should not be borrowed");
require(unavailable.getErrors().containsKey("bookIdentifier"),
"unavailable book failure should target bookIdentifier");
ServiceResult<Long> noCopies = service.borrowBook(librarian, "RD-1000", "BK-1002");
require(!noCopies.isSuccessful(), "book with no copies should not be borrowed");
require(noCopies.getErrors().containsKey("bookIdentifier"),
"no-copy book failure should target bookIdentifier");
ServiceResult<Long> borrowed = service.borrowBook(librarian, "RD-1000", "BK-1000");
require(borrowed.isSuccessful(), "librarian should create a valid borrow record");
long borrowedId = borrowed.getData();
require(dao.books.get(1L).getAvailableCopies() == 0, "borrow should decrement available copies");
ServiceResult<Long> overLimit = service.borrowBook(librarian, "RD-1000", "BK-1004");
require(!overLimit.isSuccessful(), "reader at max active loans should be blocked");
require(overLimit.getErrors().containsKey("readerIdentifier"),
"borrow limit failure should target readerIdentifier");
ServiceResult<List<BorrowRecord>> history = service.listCurrentReaderHistory(readerUser);
require(history.isSuccessful(), "linked reader should view own loan history");
require(history.getData().size() == 1, "reader history should include own record");
ServiceResult<List<BorrowRecord>> staffHistory = service.listCurrentReaderHistory(administrator);
require(!staffHistory.isSuccessful(), "staff should use management history, not reader loan history");
require("You do not have permission to view loan history.".equals(staffHistory.getMessage()),
"staff reader-history access should return permission message");
ServiceResult<Void> returned = service.returnBook(librarian, borrowedId);
require(returned.isSuccessful(), "librarian should return an active loan");
require(dao.records.get(borrowedId).getStatus() == BorrowRecordStatus.RETURNED,
"return should mark record returned");
require(dao.books.get(1L).getAvailableCopies() == 1, "return should restore available copies");
ServiceResult<Long> renewable = service.borrowBook(librarian, "RD-1002", "BK-1003");
require(renewable.isSuccessful(), "renewal test borrow should succeed");
long renewableId = renewable.getData();
LocalDateTime firstDueAt = dao.records.get(renewableId).getDueAt();
ServiceResult<Void> renewed = service.renewLoan(librarian, renewableId);
require(renewed.isSuccessful(), "first renewal should succeed");
BorrowRecord renewedRecord = dao.records.get(renewableId);
require(renewedRecord.getRenewalCount() == 1, "renew should increment renewal count");
require(renewedRecord.getDueAt().equals(firstDueAt.plusDays(14)), "renew should extend due date");
ServiceResult<Void> secondRenewal = service.renewLoan(librarian, renewableId);
require(!secondRenewal.isSuccessful(), "second renewal should hit the explicit renewal limit");
require(secondRenewal.getErrors().containsKey("renewalCount"),
"renewal limit should target renewalCount");
BorrowRecord overdue = record(90L, 3L, 5L, LocalDateTime.of(2000, 1, 1, 12, 0),
LocalDateTime.of(2000, 1, 15, 12, 0), null, 0, BorrowRecordStatus.ACTIVE);
dao.records.put(overdue.getId(), overdue);
ServiceResult<List<BorrowRecord>> overdueSearch = service.searchRecords(librarian,
new BorrowRecordSearchCriteria("", "", BorrowRecordSearchCriteria.OVERDUE_STATUS));
require(overdueSearch.isSuccessful(), "overdue search should succeed");
require(overdueSearch.getData().size() == 1, "overdue search should only return overdue active loans");
AuthenticatedUser unlinkedReader = user(21L, Role.READER);
ServiceResult<List<BorrowRecord>> unlinkedHistory = service.listCurrentReaderHistory(unlinkedReader);
require(unlinkedHistory.isSuccessful(), "unlinked reader history should return a safe empty result");
require(unlinkedHistory.getData().isEmpty(), "unlinked reader history should be empty");
BorrowingService failingService = service(new FailingBorrowRecordDao());
ServiceResult<List<BorrowRecord>> failingSearch = failingService.searchRecords(librarian,
new BorrowRecordSearchCriteria());
require(!failingSearch.isSuccessful(), "DAO failure should not escape search");
require(UNAVAILABLE_MESSAGE.equals(failingSearch.getMessage()),
"DAO failure should map to safe borrowing message");
}
private static BorrowingService service(BorrowRecordDao dao) {
return new BorrowingServiceImpl(dao, new PermissionPolicy(), FIXED_CLOCK,
new BorrowingServiceImpl.DirectTransactionExecutor());
}
private static AuthenticatedUser user(long id, Role role) {
return new AuthenticatedUser(id, role.getCode(), role.getDisplayName(), role,
role == Role.READER
? EnumSet.of(Permission.VIEW_CATALOG, Permission.BORROW_BOOKS)
: EnumSet.of(Permission.MANAGE_BORROWING, Permission.VIEW_CATALOG));
}
private static Reader reader(long id, String identifier, Long userId, String fullName,
ReaderStatus status, int maxBorrowCount) {
Reader reader = new Reader();
reader.setId(id);
reader.setIdentifier(identifier);
reader.setUserId(userId);
reader.setFullName(fullName);
reader.setStatus(status);
reader.setMaxBorrowCount(maxBorrowCount);
return reader;
}
private static Book book(long id, String identifier, String title, BookStatus status,
int totalCopies, int availableCopies) {
Book book = new Book();
book.setId(id);
book.setIdentifier(identifier);
book.setTitle(title);
book.setAuthor("Test Author");
book.setCategoryId(1L);
book.setCategoryName("Test Category");
book.setStatus(status);
book.setTotalCopies(totalCopies);
book.setAvailableCopies(availableCopies);
return book;
}
private static BorrowRecord record(long id, long readerId, long bookId, LocalDateTime borrowedAt,
LocalDateTime dueAt, LocalDateTime returnedAt, int renewalCount,
BorrowRecordStatus status) {
BorrowRecord record = new BorrowRecord();
record.setId(id);
record.setReaderId(readerId);
record.setBookId(bookId);
record.setBorrowedAt(borrowedAt);
record.setDueAt(dueAt);
record.setReturnedAt(returnedAt);
record.setRenewalCount(renewalCount);
record.setStatus(status);
return record;
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
private static final class InMemoryBorrowRecordDao implements BorrowRecordDao {
private final Map<Long, Reader> readers = new LinkedHashMap<>();
private final Map<Long, Book> books = new LinkedHashMap<>();
private final Map<Long, BorrowRecord> records = new LinkedHashMap<>();
private long nextId = 1L;
private void putReader(Reader reader) {
readers.put(reader.getId(), reader);
}
private void putBook(Book book) {
books.put(book.getId(), book);
}
@Override
public List<BorrowRecord> search(BorrowRecordSearchCriteria criteria) {
List<BorrowRecord> matches = new ArrayList<>();
for (BorrowRecord record : records.values()) {
BorrowRecord enriched = enrich(copy(record));
if (matches(criteria.getReaderIdentifier(), enriched.getReaderIdentifier())
&& matches(criteria.getBookIdentifier(), enriched.getBookIdentifier())
&& statusMatches(criteria, enriched)) {
matches.add(enriched);
}
}
return matches;
}
@Override
public List<BorrowRecord> findByReaderId(long readerId) {
List<BorrowRecord> matches = new ArrayList<>();
for (BorrowRecord record : records.values()) {
if (record.getReaderId() == readerId) {
matches.add(enrich(copy(record)));
}
}
return matches;
}
@Override
public Optional<Reader> findReaderByUserId(long userId) {
for (Reader reader : readers.values()) {
if (reader.getUserId() != null && reader.getUserId() == userId) {
return Optional.of(copy(reader));
}
}
return Optional.empty();
}
@Override
public Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier) {
for (Reader reader : readers.values()) {
if (reader.getIdentifier().equals(identifier)) {
return Optional.of(copy(reader));
}
}
return Optional.empty();
}
@Override
public Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier) {
for (Book book : books.values()) {
if (book.getIdentifier().equals(identifier)) {
return Optional.of(copy(book));
}
}
return Optional.empty();
}
@Override
public Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id) {
return Optional.ofNullable(records.get(id)).map(this::copy).map(this::enrich);
}
@Override
public int countActiveByReaderId(Connection connection, long readerId) {
int count = 0;
for (BorrowRecord record : records.values()) {
if (record.getReaderId() == readerId
&& record.getStatus() == BorrowRecordStatus.ACTIVE
&& record.getReturnedAt() == null) {
count++;
}
}
return count;
}
@Override
public long create(Connection connection, BorrowRecord record) {
long id = nextId++;
BorrowRecord stored = copy(record);
stored.setId(id);
records.put(id, stored);
return id;
}
@Override
public boolean decrementAvailableCopies(Connection connection, long bookId) {
Book book = books.get(bookId);
if (book == null || book.getAvailableCopies() <= 0) {
return false;
}
book.setAvailableCopies(book.getAvailableCopies() - 1);
return true;
}
@Override
public boolean incrementAvailableCopies(Connection connection, long bookId) {
Book book = books.get(bookId);
if (book == null) {
return false;
}
book.setAvailableCopies(Math.min(book.getAvailableCopies() + 1, book.getTotalCopies()));
return true;
}
@Override
public boolean markReturned(Connection connection, long id, LocalDateTime returnedAt) {
BorrowRecord record = records.get(id);
if (record == null || record.getStatus() != BorrowRecordStatus.ACTIVE || record.getReturnedAt() != null) {
return false;
}
record.setStatus(BorrowRecordStatus.RETURNED);
record.setReturnedAt(returnedAt);
return true;
}
@Override
public boolean renew(Connection connection, long id, LocalDateTime dueAt) {
BorrowRecord record = records.get(id);
if (record == null || record.getStatus() != BorrowRecordStatus.ACTIVE || record.getReturnedAt() != null) {
return false;
}
record.setDueAt(dueAt);
record.setRenewalCount(record.getRenewalCount() + 1);
return true;
}
private boolean matches(String filter, String value) {
return filter == null || filter.isEmpty() || value.contains(filter);
}
private boolean statusMatches(BorrowRecordSearchCriteria criteria, BorrowRecord record) {
String statusCode = criteria.getStatusCode();
if (statusCode == null || statusCode.isEmpty()) {
return true;
}
if (criteria.isOverdueOnly()) {
return record.isOverdue();
}
return record.getStatus().getCode().equals(statusCode);
}
private BorrowRecord enrich(BorrowRecord record) {
Reader reader = readers.get(record.getReaderId());
Book book = books.get(record.getBookId());
if (reader != null) {
record.setReaderIdentifier(reader.getIdentifier());
record.setReaderName(reader.getFullName());
}
if (book != null) {
record.setBookIdentifier(book.getIdentifier());
record.setBookTitle(book.getTitle());
}
return record;
}
private Reader copy(Reader source) {
return reader(source.getId(), source.getIdentifier(), source.getUserId(), source.getFullName(),
source.getStatus(), source.getMaxBorrowCount());
}
private Book copy(Book source) {
return book(source.getId(), source.getIdentifier(), source.getTitle(), source.getStatus(),
source.getTotalCopies(), source.getAvailableCopies());
}
private BorrowRecord copy(BorrowRecord source) {
BorrowRecord copy = record(source.getId(), source.getReaderId(), source.getBookId(),
source.getBorrowedAt(), source.getDueAt(), source.getReturnedAt(), source.getRenewalCount(),
source.getStatus());
copy.setReaderIdentifier(source.getReaderIdentifier());
copy.setReaderName(source.getReaderName());
copy.setBookIdentifier(source.getBookIdentifier());
copy.setBookTitle(source.getBookTitle());
return copy;
}
}
private static final class FailingBorrowRecordDao implements BorrowRecordDao {
@Override
public List<BorrowRecord> search(BorrowRecordSearchCriteria criteria) {
throw new DaoException("Simulated search failure", null);
}
@Override
public List<BorrowRecord> findByReaderId(long readerId) {
throw new DaoException("Simulated history failure", null);
}
@Override
public Optional<Reader> findReaderByUserId(long userId) {
throw new DaoException("Simulated reader failure", null);
}
@Override
public Optional<Reader> findReaderByIdentifierForUpdate(Connection connection, String identifier) {
throw new DaoException("Simulated reader lock failure", null);
}
@Override
public Optional<Book> findBookByIdentifierForUpdate(Connection connection, String identifier) {
throw new DaoException("Simulated book lock failure", null);
}
@Override
public Optional<BorrowRecord> findByIdForUpdate(Connection connection, long id) {
throw new DaoException("Simulated record lock failure", null);
}
@Override
public int countActiveByReaderId(Connection connection, long readerId) {
throw new DaoException("Simulated count failure", null);
}
@Override
public long create(Connection connection, BorrowRecord record) {
throw new DaoException("Simulated create failure", null);
}
@Override
public boolean decrementAvailableCopies(Connection connection, long bookId) {
throw new DaoException("Simulated decrement failure", null);
}
@Override
public boolean incrementAvailableCopies(Connection connection, long bookId) {
throw new DaoException("Simulated increment failure", null);
}
@Override
public boolean markReturned(Connection connection, long id, LocalDateTime returnedAt) {
throw new DaoException("Simulated return failure", null);
}
@Override
public boolean renew(Connection connection, long id, LocalDateTime dueAt) {
throw new DaoException("Simulated renew failure", null);
}
}
}
@@ -13,8 +13,11 @@ public final class PermissionPolicyCheck {
require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users");
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing");
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers");
require(!policy.allows(Role.LIBRARIAN, Permission.BORROW_BOOKS), "librarian should not borrow as a reader");
require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users");
require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog");
require(policy.allows(Role.READER, Permission.BORROW_BOOKS), "reader should view borrowing capabilities");
require(!policy.allows(Role.READER, Permission.MANAGE_BORROWING), "reader should not manage borrowing");
require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books");
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
}