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

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
@@ -302,6 +302,131 @@ readers/form.jsp -> JDBC -> DELETE FROM readers using request parameters
readers/form.jsp -> ReaderManagementServlet -> ReaderService -> ReaderDao -> readers.status = inactive readers/form.jsp -> ReaderManagementServlet -> ReaderService -> ReaderDao -> readers.status = inactive
``` ```
## Scenario: Borrowing Circulation Management Slice
### 1. Scope / Trigger
- Trigger: borrowing circulation now spans `borrow_records`, book inventory,
reader eligibility, Servlet routes, service transactions, DAO locks, and JSP
management/history screens.
- Schema path: `src/main/resources/db/schema.sql`.
- JSP paths: `WEB-INF/jsp/borrowing/manage.jsp`,
`borrowing/form.jsp`, and `reader/loans.jsp`.
### 2. Signatures
- Entity signatures: `BorrowRecord` fields are `id`, `readerId`,
`readerIdentifier`, `readerName`, `bookId`, `bookIdentifier`, `bookTitle`,
`borrowedAt`, `dueAt`, nullable `returnedAt`, `renewalCount`, `status`,
`createdAt`, and `updatedAt`.
- Status signature: `BorrowRecordStatus` enum codes are `active` and
`returned`; overdue is derived from active, non-returned rows where
`due_at < CURRENT_TIMESTAMP`.
- Search signature: `BorrowRecordSearchCriteria(readerIdentifier,
bookIdentifier, statusCode)` where `statusCode` may be empty, `active`,
`returned`, or the derived filter `overdue`.
- DAO signatures: `BorrowRecordDao.search(criteria)`,
`findByReaderId(readerId)`, `findReaderByUserId(userId)`,
`findReaderByIdentifierForUpdate(connection, identifier)`,
`findBookByIdentifierForUpdate(connection, identifier)`,
`findByIdForUpdate(connection, id)`, `countActiveByReaderId(connection,
readerId)`, `create(connection, record)`, `decrementAvailableCopies(...)`,
`incrementAvailableCopies(...)`, `markReturned(...)`, and `renew(...)`.
- Service signatures: `BorrowingService.searchRecords(actor, criteria)`,
`borrowBook(actor, readerIdentifier, bookIdentifier)`,
`returnBook(actor, recordId)`, `renewLoan(actor, recordId)`, and
`listCurrentReaderHistory(actor)`, all returning `ServiceResult<T>`.
- Management routes: `GET /borrowing`, `GET /borrowing/new`,
`POST /borrowing/create`, `POST /borrowing/return`, and
`POST /borrowing/renew`.
- Reader route: `GET /reader/loans`.
- Protected permissions: `/borrowing*` requires `MANAGE_BORROWING`;
`/reader/loans` requires `BORROW_BOOKS` and the `reader` role because it
displays only the signed-in reader's own history.
- DB signature: `borrow_records(id, reader_id, book_id, borrowed_at, due_at,
returned_at, renewal_count, status, created_at, updated_at)`, with foreign
keys to `readers(id)` and `books(id)`, indexes on reader, book, status, and
due date, and checks for non-negative renewal count and supported statuses.
### 3. Contracts
- Borrow operations require an active reader and a borrowable book with
`BookStatus.AVAILABLE` and `available_copies > 0`.
- Reader active-loan count is rows with `status = active` and `returned_at IS
NULL`; overdue rows still count toward `max_borrow_count`.
- Borrowing creates one `borrow_records` row and decrements
`books.available_copies` in the same transaction.
- Returning an active loan sets `status = returned`, stores `returned_at`, and
increments `books.available_copies` with a cap at `total_copies` in the same
transaction.
- Renewing an active loan extends `due_at`, increments `renewal_count`, and is
limited to one renewal per MVP loan.
- `JdbcUtil.executeInTransaction` is the local transaction helper for
multi-table borrowing workflows. Services decide the workflow boundary; DAOs
own SQL and row-lock statements.
- Demo `readers` and `books` seed rows must not overwrite existing rows during
schema replay, because resetting reader eligibility or `available_copies` can
corrupt live borrowing state.
- Servlet controllers set JSP attributes such as `criteria`, `statuses`,
`overdueStatus`, `maxRenewals`, `borrowRecords`, `formValues`, `errors`,
`errorMessage`, and `successMessage`.
- JSP pages render JavaBean properties only; they must not call DAOs or embed
SQL.
### 4. Validation & Error Matrix
- Missing reader ID or book ID -> return to `borrowing/form.jsp` with field
errors.
- Unknown reader -> field error on `readerIdentifier`.
- Inactive or suspended reader -> field error on `readerIdentifier`.
- Reader at or above `max_borrow_count` active loans -> field error on
`readerIdentifier`.
- Unknown book -> field error on `bookIdentifier`.
- Unavailable, archived, or zero-copy book -> field error on `bookIdentifier`.
- Missing or non-positive record ID for return/renew -> flash error.
- Returning or renewing a returned loan -> validation failure on `status`.
- Renewing after the renewal limit -> validation failure on `renewalCount`.
- DAO/transaction failure -> log server-side details and return
`Borrowing service is temporarily unavailable. Please try again later.`
### 5. Good/Base/Bad Cases
- Good: a librarian creates a loan for active reader `RD-1000` and available
book `BK-1000`; the loan appears in `/borrowing` and book availability is
decremented.
- Base: `/borrowing?status=overdue` lists active, non-returned loans past due
without requiring a scheduler or stored overdue status.
- Bad: a Servlet updates `books.available_copies` outside the borrowing
transaction or a JSP issues SQL to filter loan records.
### 6. Tests Required
- Run `BorrowingServiceCheck` assertions for permission denial, inactive
readers, unavailable/no-copy books, max active loan count, successful borrow,
return inventory restoration, one-renewal limit, overdue search, reader loan
history, and DAO failure fallback.
- Run `PermissionPolicyCheck` to confirm readers have `BORROW_BOOKS`, readers
lack `MANAGE_BORROWING`, and librarians lack reader self-borrow permission.
Service or filter checks should also confirm staff use `/borrowing`, not the
reader-only `/reader/loans` history route.
- Scan JSPs for scriptlets and SQL/JDBC references.
- When Maven/Tomcat dependencies are installed, run `mvn clean package` to
compile Servlets and package JSP resources.
### 7. Wrong vs Correct
#### Wrong
```text
borrowing/manage.jsp -> JDBC -> UPDATE books SET available_copies = ...
```
#### Correct
```text
borrowing/form.jsp -> BorrowingManagementServlet -> BorrowingService -> BorrowRecordDao -> borrow_records + books in one transaction
```
## Scenario: Login And Permission Scaffold Schema ## Scenario: Login And Permission Scaffold Schema
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -0,0 +1,12 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview for final spec compliance review."}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify schema, DAO, transaction, and integrity handling for circulation operations."}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify user-facing validation failures and exception boundaries."}
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify logging expectations for key borrowing operations and exceptions."}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify backend layer boundaries and code quality."}
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend JSP/CSS conventions for final review."}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify forms, tables, reusable fragments, and UI consistency."}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Verify request/session/form state and validation message handling."}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Verify JavaBean display contracts and JSP-safe data access."}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Verify UI quality and responsive JSP/CSS behavior."}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Verify cross-layer data flow from schema through JSP."}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Verify reuse of existing patterns and avoid duplicate helper logic."}
@@ -0,0 +1,15 @@
{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview and pre-development checklist for Servlet -> Service -> DAO -> MySQL work."}
{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Required backend package and class placement conventions for new entity, DAO, service, and servlet files."}
{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Required MySQL schema, DAO CRUD, transaction, and data-integrity conventions for borrowing records."}
{"file": ".trellis/spec/backend/error-handling.md", "reason": "Required Servlet validation and ServiceResult-style failure handling for borrow/return/renew workflows."}
{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Guidance for operation logging and exception tracing for circulation actions."}
{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Layer boundary and review constraints for backend implementation."}
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend JSP/CSS conventions and pre-development checklist for new circulation screens."}
{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Required JSP and static asset placement conventions."}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Existing JSP fragments, forms, tables, and reusable UI conventions."}
{"file": ".trellis/spec/frontend/state-management.md", "reason": "Server-rendered request/session/form state conventions for validation messages and filters."}
{"file": ".trellis/spec/frontend/type-safety.md", "reason": "JSP/Servlet validation and JavaBean display contracts."}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "UI quality checks for JSP/CSS changes."}
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Borrowing spans schema, DAO, service, servlet, JSP, and permissions."}
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Feature should reuse existing patterns for search criteria, ServiceResult, DAOs, forms, and JSP tables."}
{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements include borrowing/return management in the overall library system scope."}
@@ -0,0 +1,108 @@
# Borrowing circulation management
## Goal
Implement the next core library-management slice: borrowing circulation for borrow, return, renew, overdue visibility, and reader loan history.
## What I already know
* The project is a Java 11 Maven WAR application using JSP + Servlet on Tomcat and MySQL through JDBC DAO classes.
* Existing implemented slices cover login, role/permission checks, dashboard navigation, book catalog/search, book management, and reader profile/eligibility management.
* Current routes include `/login`, `/logout`, `/dashboard`, role homes, `/catalog`, `/books`, and `/readers`.
* The schema already defines roles, permissions, role permissions, users, system logs, readers, book categories, and books.
* Permissions already include `manage_borrowing` and `borrow_books`.
* Dashboard copy mentions borrowing, return, renewal, and overdue workflows, but there are no borrowing tables, Servlet routes, JSP pages, DAO classes, services, or tests for that workflow yet.
* The user selected borrowing circulation as the next feature slice.
* `AuthorizationFilter` currently protects `/librarian/**` with `MANAGE_BORROWING` and `/reader/**` with `VIEW_CATALOG`.
* `PermissionPolicy` grants `MANAGE_BORROWING` to administrators and librarians, and `BORROW_BOOKS` to readers.
## Scope
* Administrators and librarians can manage circulation records from a borrowing management screen.
* Readers can view their own borrowing records from the reader area when their account is linked to a reader profile.
* Borrowing workflow covers borrow, return, renew, active loans, returned loans, and overdue loans.
* Overdue handling should be visible in lists and service results; no background scheduler is required for this task.
* The implementation should preserve the existing Servlet -> Service -> DAO boundary and JSP view style.
## Requirements
### Persistence
* Add a borrowing/loan table to `src/main/resources/db/schema.sql`.
* Store the borrowing record id, reader id, book id, borrow date/time, due date/time, optional return date/time, renewal count, status, and timestamps.
* Relate borrowing rows to `readers` and `books` with foreign keys.
* Add useful indexes for reader, book, status, and due date lookups.
* Keep seed data safe and optional; do not require destructive schema resets.
### Business Rules
* Borrowing a book requires an active reader.
* Borrowing a book requires the book status to allow borrowing and `available_copies > 0`.
* Borrowing must reject readers at or above `max_borrow_count` for active, non-returned loans.
* Successful borrowing creates a borrowing record and decrements `books.available_copies`.
* Returning an active loan marks it returned, records the return time, and increments `books.available_copies` without exceeding `total_copies`.
* Renewing an active loan extends the due date and increments the renewal count.
* Renewal should be limited to a small, explicit maximum, preferably one renewal per loan for this MVP.
* Overdue records are records not returned by their due date. They should be searchable or filterable.
* Service methods should return `ServiceResult` style validation errors instead of throwing user-facing exceptions for normal validation failures.
### Backend
* Add entity, DAO, JDBC DAO, service, and service implementation classes for borrowing circulation.
* Add Servlet routes for borrowing management under an administrator/librarian-accessible path such as `/borrowing`, `/borrowing/new`, `/borrowing/create`, `/borrowing/return`, and `/borrowing/renew`.
* Add a reader-facing route for own borrowing records under the existing reader area or another path protected appropriately.
* Update `web.xml` and authorization rules as needed.
* Keep transaction-sensitive borrow/return/renew operations consistent. If the existing JDBC helper does not provide transactions, implement the smallest safe local transaction pattern needed for multi-table updates.
### Frontend
* Add JSP pages for borrowing list/history and the borrow form.
* Add navigation entries from dashboard, role home, and header where appropriate.
* Keep the UI consistent with existing card, table, form, alert, and button patterns.
* Show clear success and validation messages for borrow, return, and renew actions.
* Lists should show reader identifier/name, book identifier/title, borrow date, due date, return date when present, renewal count, and status.
### Tests
* Add or update service-level check classes for borrowing rules.
* Update permission tests if new path/permission logic changes.
* Build with Maven after implementation if Maven is available.
## Acceptance Criteria
* [ ] Administrator/librarian can open a borrowing management page.
* [ ] Administrator/librarian can create a valid borrowing record by selecting or entering an existing active reader and borrowable book.
* [ ] Borrowing decrements available copies and blocks unavailable books.
* [ ] Borrowing blocks inactive/suspended readers and readers over their max active borrowing count.
* [ ] Administrator/librarian can return an active loan; the book copy count is restored.
* [ ] Administrator/librarian can renew an active loan within the configured renewal limit.
* [ ] Borrowing lists can distinguish active, returned, and overdue records.
* [ ] Reader can view their own borrowing records when logged in with a linked reader profile.
* [ ] Relevant service checks pass.
* [ ] Maven build/check commands pass where available.
## Definition of Done
* Tests added/updated where appropriate.
* Lint/typecheck/build checks are green.
* Docs/notes updated if behavior changes.
* Rollback considered if risky.
## Out of Scope (explicit)
* Reader self-service borrow requests/reservations are not part of this MVP.
* Email/SMS notifications are out of scope.
* Fine calculation/payment is out of scope.
* Background jobs/schedulers are out of scope.
* Full reports/rankings remain out of scope.
* No unrelated refactors or visual redesigns.
## Technical Notes
* `README.md` confirms implemented scaffold slices and project stack.
* `src/main/webapp/WEB-INF/web.xml` defines current Servlet mappings.
* `src/main/resources/db/schema.sql` has no borrowing/loan table yet.
* `src/main/webapp/WEB-INF/jsp/dashboard.jsp` and `role-home.jsp` already refer to borrowing workflows in UI copy.
* `src/main/java/com/mzh/library/entity/Permission.java` already defines `MANAGE_BORROWING` and `BORROW_BOOKS`.
* `src/main/java/com/mzh/library/filter/AuthorizationFilter.java` may need a `/borrowing` rule mapped to `MANAGE_BORROWING` and reader borrowing history should not expose other readers' records.
* The local `codebase-retrieval` MCP call was rejected by the approval layer, so context was gathered from targeted repo file reads instead.
@@ -0,0 +1,26 @@
{
"id": "continue-development",
"name": "continue-development",
"title": "brainstorm: 继续开发程序",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "Zzzz",
"assignee": "Zzzz",
"createdAt": "2026-04-27",
"completedAt": null,
"branch": null,
"base_branch": "master",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -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.dao.impl.JdbcUserDao;
import com.mzh.library.entity.AuthenticatedUser; import com.mzh.library.entity.AuthenticatedUser;
import com.mzh.library.entity.Permission; import com.mzh.library.entity.Permission;
import com.mzh.library.entity.Role;
import com.mzh.library.service.AuthService; import com.mzh.library.service.AuthService;
import com.mzh.library.service.impl.AuthServiceImpl; import com.mzh.library.service.impl.AuthServiceImpl;
import com.mzh.library.util.SessionAttributes; 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 Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName());
private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp"; private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp";
private static final List<PathRule> RULES = Arrays.asList( private static final List<PathRule> RULES = Arrays.asList(
new PathRule("/borrowing", Permission.MANAGE_BORROWING),
new PathRule("/books", Permission.MANAGE_BOOKS), new PathRule("/books", Permission.MANAGE_BOOKS),
new PathRule("/readers", Permission.MANAGE_READERS), new PathRule("/readers", Permission.MANAGE_READERS),
new PathRule("/catalog", Permission.VIEW_CATALOG), new PathRule("/catalog", Permission.VIEW_CATALOG),
new PathRule("/admin", Permission.MANAGE_USERS), new PathRule("/admin", Permission.MANAGE_USERS),
new PathRule("/librarian", Permission.MANAGE_BORROWING), new PathRule("/librarian", Permission.MANAGE_BORROWING),
new PathRule("/reader/loans", Permission.BORROW_BOOKS, Role.READER),
new PathRule("/reader", Permission.VIEW_CATALOG) new PathRule("/reader", Permission.VIEW_CATALOG)
); );
@@ -41,38 +44,39 @@ public class AuthorizationFilter implements Filter {
HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response; HttpServletResponse httpResponse = (HttpServletResponse) response;
String path = relativePath(httpRequest); String path = relativePath(httpRequest);
Permission requiredPermission = requiredPermission(path); PathRule requiredRule = requiredRule(path);
if (requiredPermission == null) { if (requiredRule == null) {
chain.doFilter(request, response); chain.doFilter(request, response);
return; return;
} }
AuthenticatedUser user = currentUser(httpRequest.getSession(false)); AuthenticatedUser user = currentUser(httpRequest.getSession(false));
if (authService.hasPermission(user, requiredPermission)) { if (requiredRule.allows(authService, user)) {
chain.doFilter(request, response); chain.doFilter(request, response);
return; return;
} }
logDeniedAccess(user, requiredPermission, path); logDeniedAccess(user, requiredRule, path);
httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
request.setAttribute("errorMessage", "You do not have permission to access this page."); request.setAttribute("errorMessage", "You do not have permission to access this page.");
request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response); 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 String actor = user == null
? "anonymous" ? "anonymous"
: "userId=" + user.getId() + " role=" + user.getRole().getCode(); : "userId=" + user.getId() + " role=" + user.getRole().getCode();
LOGGER.warning("Permission denied path=" + path LOGGER.warning("Permission denied path=" + path
+ " requiredPermission=" + requiredPermission.getCode() + " requiredPermission=" + requiredRule.permission.getCode()
+ " requiredRole=" + requiredRule.requiredRoleCode()
+ " actor=" + actor); + " actor=" + actor);
} }
private Permission requiredPermission(String path) { private PathRule requiredRule(String path) {
for (PathRule rule : RULES) { for (PathRule rule : RULES) {
if (path.equals(rule.prefix) || path.startsWith(rule.prefix + "/")) { if (path.equals(rule.prefix) || path.startsWith(rule.prefix + "/")) {
return rule.permission; return rule;
} }
} }
return null; return null;
@@ -95,10 +99,25 @@ public class AuthorizationFilter implements Filter {
private static final class PathRule { private static final class PathRule {
private final String prefix; private final String prefix;
private final Permission permission; private final Permission permission;
private final Role requiredRole;
private PathRule(String prefix, Permission permission) { private PathRule(String prefix, Permission permission) {
this(prefix, permission, null);
}
private PathRule(String prefix, Permission permission, Role requiredRole) {
this.prefix = prefix; this.prefix = prefix;
this.permission = permission; 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 CONFIG_FILE = "db.properties";
private static final String DEFAULT_DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String DEFAULT_DRIVER = "com.mysql.cj.jdbc.Driver";
@FunctionalInterface
public interface TransactionCallback<T> {
T execute(Connection connection) throws SQLException;
}
private JdbcUtil() { 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() { private static Properties loadProperties() {
try (InputStream inputStream = Thread.currentThread() try (InputStream inputStream = Thread.currentThread()
.getContextClassLoader() .getContextClassLoader()
+29 -19
View File
@@ -117,6 +117,31 @@ CREATE TABLE IF NOT EXISTS books (
CHECK (status IN ('available', 'unavailable', 'archived')) CHECK (status IN ('available', 'unavailable', 'archived'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 INSERT INTO roles (code, name, description) VALUES
('administrator', 'Administrator', 'Full system administration role'), ('administrator', 'Administrator', 'Full system administration role'),
('librarian', 'Librarian', 'Library operation and borrowing management 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_reports'),
('administrator', 'view_system_logs'), ('administrator', 'view_system_logs'),
('administrator', 'view_catalog'), ('administrator', 'view_catalog'),
('administrator', 'borrow_books'),
('librarian', 'manage_books'), ('librarian', 'manage_books'),
('librarian', 'manage_readers'), ('librarian', 'manage_readers'),
('librarian', 'manage_borrowing'), ('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), ('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1),
('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 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', ('RD-0001', (SELECT id FROM users WHERE username = 'reader'), 'Demo Reader', '13800000000',
'reader@example.com', 'active', 5), 'reader@example.com', 'active', 5),
('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3) ('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);
INSERT INTO book_categories (name, description) VALUES INSERT INTO book_categories (name, description) VALUES
('Computer Science', 'Programming, software engineering, and systems books'), ('Computer Science', 'Programming, software engineering, and systems books'),
@@ -182,7 +199,7 @@ INSERT INTO book_categories (name, description) VALUES
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
description = VALUES(description); 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', ('BK-0001', 'Effective Java', 'Joshua Bloch',
(SELECT id FROM book_categories WHERE name = 'Computer Science'), 5, 4, 'available'), (SELECT id FROM book_categories WHERE name = 'Computer Science'), 5, 4, 'available'),
('BK-0002', 'Clean Code', 'Robert C. Martin', ('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', ('BK-0003', 'Pride and Prejudice', 'Jane Austen',
(SELECT id FROM book_categories WHERE name = 'Literature'), 3, 3, 'available'), (SELECT id FROM book_categories WHERE name = 'Literature'), 3, 3, 'available'),
('BK-0004', 'A Brief History of Time', 'Stephen Hawking', ('BK-0004', 'A Brief History of Time', 'Stephen Hawking',
(SELECT id FROM book_categories WHERE name = 'Science'), 2, 1, 'available') (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);
@@ -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}/librarian/home">Librarian</a>
<a href="${pageContext.request.contextPath}/books">Books</a> <a href="${pageContext.request.contextPath}/books">Books</a>
<a href="${pageContext.request.contextPath}/readers">Readers</a> <a href="${pageContext.request.contextPath}/readers">Readers</a>
<a href="${pageContext.request.contextPath}/borrowing">Borrowing</a>
</c:if> </c:if>
<a href="${pageContext.request.contextPath}/reader/home">Reader</a> <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"> <span class="user-pill">
<c:out value="${sessionScope.authenticatedUser.displayName}" /> <c:out value="${sessionScope.authenticatedUser.displayName}" />
</span> </span>
+15 -1
View File
@@ -46,6 +46,12 @@
<p>Create, update, deactivate, and review reader eligibility records.</p> <p>Create, update, deactivate, and review reader eligibility records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Open</a> <a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Open</a>
</article> </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> </c:if>
<article class="workspace-card"> <article class="workspace-card">
@@ -56,9 +62,17 @@
<article class="workspace-card"> <article class="workspace-card">
<h2>Reader Center</h2> <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> <a class="button button-secondary" href="${pageContext.request.contextPath}/reader/home">Open</a>
</article> </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> </section>
</main> </main>
</body> </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> <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> <a class="button button-secondary" href="${pageContext.request.contextPath}/readers">Manage readers</a>
</article> </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> </c:if>
</section> </section>
</main> </main>
+22
View File
@@ -110,6 +110,28 @@
<url-pattern>/readers/delete</url-pattern> <url-pattern>/readers/delete</url-pattern>
</servlet-mapping> </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>
<servlet-name>UnauthorizedServlet</servlet-name> <servlet-name>UnauthorizedServlet</servlet-name>
<servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class> <servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class>
+29 -3
View File
@@ -289,6 +289,10 @@ h2 {
align-items: end; align-items: end;
} }
.borrowing-search-form {
grid-template-columns: repeat(3, minmax(120px, 1fr)) auto auto;
}
.search-field { .search-field {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -305,7 +309,8 @@ h2 {
.book-form input, .book-form input,
.book-form select, .book-form select,
.reader-form input, .reader-form input,
.reader-form select { .reader-form select,
.borrow-form input {
width: 100%; width: 100%;
min-height: 42px; min-height: 42px;
padding: 9px 11px; padding: 9px 11px;
@@ -320,7 +325,8 @@ h2 {
.book-form input:focus, .book-form input:focus,
.book-form select:focus, .book-form select:focus,
.reader-form input: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); outline: 3px solid rgba(37, 111, 108, 0.18);
border-color: var(--color-primary); border-color: var(--color-primary);
} }
@@ -337,6 +343,10 @@ h2 {
font-size: 14px; font-size: 14px;
} }
.borrowing-table {
min-width: 980px;
}
.data-table th, .data-table th,
.data-table td { .data-table td {
padding: 12px 10px; padding: 12px 10px;
@@ -397,6 +407,21 @@ h2 {
background: #eef1f5; 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 { .table-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -413,7 +438,8 @@ h2 {
} }
.book-form, .book-form,
.reader-form { .reader-form,
.borrow-form {
display: grid; display: grid;
gap: 20px; 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.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_BORROWING), "librarian should manage borrowing");
require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers"); 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.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.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_BOOKS), "reader should not manage books");
require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers"); require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers");
} }