From 7502890a77c8e09e562685b44eb6feac3a2d18a5 Mon Sep 17 00:00:00 2001 From: Zzzz Date: Mon, 27 Apr 2026 21:19:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=80=9F=E4=B9=A6/=E8=BF=98=E4=B9=A6/=E7=BB=AD?= =?UTF-8?q?=E5=80=9F/=E9=80=BE=E6=9C=9F=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/backend/database-guidelines.md | 125 +++++ .../04-27-continue-development/check.jsonl | 12 + .../implement.jsonl | 15 + .../tasks/04-27-continue-development/prd.md | 108 +++++ .../04-27-continue-development/task.json | 26 + .../BorrowingManagementServlet.java | 237 +++++++++ .../controller/ReaderLoanHistoryServlet.java | 67 +++ .../com/mzh/library/dao/BorrowRecordDao.java | 37 ++ .../library/dao/impl/JdbcBorrowRecordDao.java | 368 ++++++++++++++ .../com/mzh/library/entity/BorrowRecord.java | 166 +++++++ .../entity/BorrowRecordSearchCriteria.java | 50 ++ .../library/entity/BorrowRecordStatus.java | 39 ++ .../library/filter/AuthorizationFilter.java | 35 +- .../mzh/library/service/BorrowingService.java | 19 + .../service/impl/BorrowingServiceImpl.java | 339 +++++++++++++ .../java/com/mzh/library/util/JdbcUtil.java | 32 ++ src/main/resources/db/schema.sql | 48 +- .../webapp/WEB-INF/jsp/borrowing/form.jsp | 57 +++ .../webapp/WEB-INF/jsp/borrowing/manage.jsp | 155 ++++++ .../webapp/WEB-INF/jsp/common/header.jspf | 4 + src/main/webapp/WEB-INF/jsp/dashboard.jsp | 16 +- src/main/webapp/WEB-INF/jsp/reader/loans.jsp | 85 ++++ src/main/webapp/WEB-INF/jsp/role-home.jsp | 14 + src/main/webapp/WEB-INF/web.xml | 22 + src/main/webapp/static/css/app.css | 32 +- .../service/BorrowingServiceCheck.java | 455 ++++++++++++++++++ .../service/PermissionPolicyCheck.java | 3 + 27 files changed, 2535 insertions(+), 31 deletions(-) create mode 100644 .trellis/tasks/04-27-continue-development/check.jsonl create mode 100644 .trellis/tasks/04-27-continue-development/implement.jsonl create mode 100644 .trellis/tasks/04-27-continue-development/prd.md create mode 100644 .trellis/tasks/04-27-continue-development/task.json create mode 100644 src/main/java/com/mzh/library/controller/BorrowingManagementServlet.java create mode 100644 src/main/java/com/mzh/library/controller/ReaderLoanHistoryServlet.java create mode 100644 src/main/java/com/mzh/library/dao/BorrowRecordDao.java create mode 100644 src/main/java/com/mzh/library/dao/impl/JdbcBorrowRecordDao.java create mode 100644 src/main/java/com/mzh/library/entity/BorrowRecord.java create mode 100644 src/main/java/com/mzh/library/entity/BorrowRecordSearchCriteria.java create mode 100644 src/main/java/com/mzh/library/entity/BorrowRecordStatus.java create mode 100644 src/main/java/com/mzh/library/service/BorrowingService.java create mode 100644 src/main/java/com/mzh/library/service/impl/BorrowingServiceImpl.java create mode 100644 src/main/webapp/WEB-INF/jsp/borrowing/form.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/reader/loans.jsp create mode 100644 src/test/java/com/mzh/library/service/BorrowingServiceCheck.java diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index ad28282..c2e1885 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -302,6 +302,131 @@ readers/form.jsp -> JDBC -> DELETE FROM readers using request parameters 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`. +- 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 ### 1. Scope / Trigger diff --git a/.trellis/tasks/04-27-continue-development/check.jsonl b/.trellis/tasks/04-27-continue-development/check.jsonl new file mode 100644 index 0000000..72d4b2a --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/check.jsonl @@ -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."} diff --git a/.trellis/tasks/04-27-continue-development/implement.jsonl b/.trellis/tasks/04-27-continue-development/implement.jsonl new file mode 100644 index 0000000..7c9e002 --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/04-27-continue-development/prd.md b/.trellis/tasks/04-27-continue-development/prd.md new file mode 100644 index 0000000..6877cca --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/prd.md @@ -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. diff --git a/.trellis/tasks/04-27-continue-development/task.json b/.trellis/tasks/04-27-continue-development/task.json new file mode 100644 index 0000000..22fcd74 --- /dev/null +++ b/.trellis/tasks/04-27-continue-development/task.json @@ -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": {} +} \ No newline at end of file diff --git a/src/main/java/com/mzh/library/controller/BorrowingManagementServlet.java b/src/main/java/com/mzh/library/controller/BorrowingManagementServlet.java new file mode 100644 index 0000000..7b920f4 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/BorrowingManagementServlet.java @@ -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> 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 values = formValues(request); + ServiceResult 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 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 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 formValues, + Map 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 formValues(HttpServletRequest request) { + Map 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(); + } +} diff --git a/src/main/java/com/mzh/library/controller/ReaderLoanHistoryServlet.java b/src/main/java/com/mzh/library/controller/ReaderLoanHistoryServlet.java new file mode 100644 index 0000000..8a32f04 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/ReaderLoanHistoryServlet.java @@ -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> 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; + } +} diff --git a/src/main/java/com/mzh/library/dao/BorrowRecordDao.java b/src/main/java/com/mzh/library/dao/BorrowRecordDao.java new file mode 100644 index 0000000..2f380d4 --- /dev/null +++ b/src/main/java/com/mzh/library/dao/BorrowRecordDao.java @@ -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 search(BorrowRecordSearchCriteria criteria); + + List findByReaderId(long readerId); + + Optional findReaderByUserId(long userId); + + Optional findReaderByIdentifierForUpdate(Connection connection, String identifier); + + Optional findBookByIdentifierForUpdate(Connection connection, String identifier); + + Optional 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); +} diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcBorrowRecordDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcBorrowRecordDao.java new file mode 100644 index 0000000..6c28b3f --- /dev/null +++ b/src/main/java/com/mzh/library/dao/impl/JdbcBorrowRecordDao.java @@ -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 search(BorrowRecordSearchCriteria criteria) { + BorrowRecordSearchCriteria normalized = criteria == null ? new BorrowRecordSearchCriteria() : criteria; + List 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 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 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 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 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 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 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 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 parameters) throws SQLException { + for (int i = 0; i < parameters.size(); i++) { + statement.setString(i + 1, parameters.get(i).toString()); + } + } + + private List mapRecords(ResultSet resultSet) throws SQLException { + List 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(); + } +} diff --git a/src/main/java/com/mzh/library/entity/BorrowRecord.java b/src/main/java/com/mzh/library/entity/BorrowRecord.java new file mode 100644 index 0000000..f31a01c --- /dev/null +++ b/src/main/java/com/mzh/library/entity/BorrowRecord.java @@ -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); + } +} diff --git a/src/main/java/com/mzh/library/entity/BorrowRecordSearchCriteria.java b/src/main/java/com/mzh/library/entity/BorrowRecordSearchCriteria.java new file mode 100644 index 0000000..e2de1ec --- /dev/null +++ b/src/main/java/com/mzh/library/entity/BorrowRecordSearchCriteria.java @@ -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(); + } +} diff --git a/src/main/java/com/mzh/library/entity/BorrowRecordStatus.java b/src/main/java/com/mzh/library/entity/BorrowRecordStatus.java new file mode 100644 index 0000000..179669e --- /dev/null +++ b/src/main/java/com/mzh/library/entity/BorrowRecordStatus.java @@ -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); + } +} diff --git a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java index db8cc44..676bbe2 100644 --- a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java +++ b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java @@ -3,6 +3,7 @@ package com.mzh.library.filter; import com.mzh.library.dao.impl.JdbcUserDao; import com.mzh.library.entity.AuthenticatedUser; import com.mzh.library.entity.Permission; +import com.mzh.library.entity.Role; import com.mzh.library.service.AuthService; import com.mzh.library.service.impl.AuthServiceImpl; import com.mzh.library.util.SessionAttributes; @@ -25,11 +26,13 @@ public class AuthorizationFilter implements Filter { private static final Logger LOGGER = Logger.getLogger(AuthorizationFilter.class.getName()); private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp"; private static final List RULES = Arrays.asList( + new PathRule("/borrowing", Permission.MANAGE_BORROWING), new PathRule("/books", Permission.MANAGE_BOOKS), new PathRule("/readers", Permission.MANAGE_READERS), new PathRule("/catalog", Permission.VIEW_CATALOG), new PathRule("/admin", Permission.MANAGE_USERS), new PathRule("/librarian", Permission.MANAGE_BORROWING), + new PathRule("/reader/loans", Permission.BORROW_BOOKS, Role.READER), new PathRule("/reader", Permission.VIEW_CATALOG) ); @@ -41,38 +44,39 @@ public class AuthorizationFilter implements Filter { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; String path = relativePath(httpRequest); - Permission requiredPermission = requiredPermission(path); + PathRule requiredRule = requiredRule(path); - if (requiredPermission == null) { + if (requiredRule == null) { chain.doFilter(request, response); return; } AuthenticatedUser user = currentUser(httpRequest.getSession(false)); - if (authService.hasPermission(user, requiredPermission)) { + if (requiredRule.allows(authService, user)) { chain.doFilter(request, response); return; } - logDeniedAccess(user, requiredPermission, path); + logDeniedAccess(user, requiredRule, path); httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); request.setAttribute("errorMessage", "You do not have permission to access this page."); request.getRequestDispatcher(UNAUTHORIZED_JSP).forward(request, response); } - private void logDeniedAccess(AuthenticatedUser user, Permission requiredPermission, String path) { + private void logDeniedAccess(AuthenticatedUser user, PathRule requiredRule, String path) { String actor = user == null ? "anonymous" : "userId=" + user.getId() + " role=" + user.getRole().getCode(); LOGGER.warning("Permission denied path=" + path - + " requiredPermission=" + requiredPermission.getCode() + + " requiredPermission=" + requiredRule.permission.getCode() + + " requiredRole=" + requiredRule.requiredRoleCode() + " actor=" + actor); } - private Permission requiredPermission(String path) { + private PathRule requiredRule(String path) { for (PathRule rule : RULES) { if (path.equals(rule.prefix) || path.startsWith(rule.prefix + "/")) { - return rule.permission; + return rule; } } return null; @@ -95,10 +99,25 @@ public class AuthorizationFilter implements Filter { private static final class PathRule { private final String prefix; private final Permission permission; + private final Role requiredRole; private PathRule(String prefix, Permission permission) { + this(prefix, permission, null); + } + + private PathRule(String prefix, Permission permission, Role requiredRole) { this.prefix = prefix; this.permission = permission; + this.requiredRole = requiredRole; + } + + private boolean allows(AuthService authService, AuthenticatedUser user) { + boolean roleAllowed = requiredRole == null || (user != null && user.getRole() == requiredRole); + return roleAllowed && authService.hasPermission(user, permission); + } + + private String requiredRoleCode() { + return requiredRole == null ? "any" : requiredRole.getCode(); } } } diff --git a/src/main/java/com/mzh/library/service/BorrowingService.java b/src/main/java/com/mzh/library/service/BorrowingService.java new file mode 100644 index 0000000..8603b12 --- /dev/null +++ b/src/main/java/com/mzh/library/service/BorrowingService.java @@ -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> searchRecords(AuthenticatedUser actor, BorrowRecordSearchCriteria criteria); + + ServiceResult borrowBook(AuthenticatedUser actor, String readerIdentifier, String bookIdentifier); + + ServiceResult returnBook(AuthenticatedUser actor, long recordId); + + ServiceResult renewLoan(AuthenticatedUser actor, long recordId); + + ServiceResult> listCurrentReaderHistory(AuthenticatedUser actor); +} diff --git a/src/main/java/com/mzh/library/service/impl/BorrowingServiceImpl.java b/src/main/java/com/mzh/library/service/impl/BorrowingServiceImpl.java new file mode 100644 index 0000000..5f6915a --- /dev/null +++ b/src/main/java/com/mzh/library/service/impl/BorrowingServiceImpl.java @@ -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 execute(JdbcUtil.TransactionCallback 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> searchRecords(AuthenticatedUser actor, BorrowRecordSearchCriteria criteria) { + if (!canManageBorrowing(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + BorrowRecordSearchCriteria normalized = criteria == null ? new BorrowRecordSearchCriteria() : criteria; + Map 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 borrowBook(AuthenticatedUser actor, String readerIdentifier, String bookIdentifier) { + if (!canManageBorrowing(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + String normalizedReaderIdentifier = trim(readerIdentifier); + String normalizedBookIdentifier = trim(bookIdentifier); + Map errors = validateBorrowIdentifiers(normalizedReaderIdentifier, normalizedBookIdentifier); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + return transactionExecutor.execute(connection -> { + Map transactionErrors = new LinkedHashMap<>(); + Optional readerResult = borrowRecordDao.findReaderByIdentifierForUpdate(connection, + normalizedReaderIdentifier); + if (!readerResult.isPresent()) { + transactionErrors.put("readerIdentifier", "Reader was not found."); + } + + Optional 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 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 recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId); + if (!recordResult.isPresent()) { + return ServiceResult.failure("Borrowing record was not found."); + } + + BorrowRecord record = recordResult.get(); + Map 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 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 recordResult = borrowRecordDao.findByIdForUpdate(connection, recordId); + if (!recordResult.isPresent()) { + return ServiceResult.failure("Borrowing record was not found."); + } + + BorrowRecord record = recordResult.get(); + Map 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> listCurrentReaderHistory(AuthenticatedUser actor) { + if (!canViewOwnHistory(actor)) { + return ServiceResult.failure(HISTORY_DENIED_MESSAGE); + } + + try { + Optional 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 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 validateSearch(BorrowRecordSearchCriteria criteria) { + Map 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 validateBorrowIdentifiers(String readerIdentifier, String bookIdentifier) { + Map errors = new LinkedHashMap<>(); + requireLength(errors, "readerIdentifier", readerIdentifier, "Reader ID", 64); + requireLength(errors, "bookIdentifier", bookIdentifier, "Book ID", 64); + return errors; + } + + private Map validateActiveLoan(BorrowRecord record) { + Map 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 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 execute(JdbcUtil.TransactionCallback callback) { + return JdbcUtil.executeInTransaction(callback); + } + } + + public static final class DirectTransactionExecutor implements TransactionExecutor { + @Override + public T execute(JdbcUtil.TransactionCallback callback) { + try { + return callback.execute(null); + } catch (SQLException ex) { + throw new DaoException("Unable to execute direct transaction", ex); + } + } + } +} diff --git a/src/main/java/com/mzh/library/util/JdbcUtil.java b/src/main/java/com/mzh/library/util/JdbcUtil.java index be1015a..82ce508 100644 --- a/src/main/java/com/mzh/library/util/JdbcUtil.java +++ b/src/main/java/com/mzh/library/util/JdbcUtil.java @@ -13,6 +13,11 @@ public final class JdbcUtil { private static final String CONFIG_FILE = "db.properties"; private static final String DEFAULT_DRIVER = "com.mysql.cj.jdbc.Driver"; + @FunctionalInterface + public interface TransactionCallback { + T execute(Connection connection) throws SQLException; + } + private JdbcUtil() { } @@ -31,6 +36,33 @@ public final class JdbcUtil { } } + public static T executeInTransaction(TransactionCallback callback) { + try (Connection connection = getConnection()) { + connection.setAutoCommit(false); + try { + T result = callback.execute(connection); + connection.commit(); + return result; + } catch (SQLException ex) { + rollbackQuietly(connection); + throw new DaoException("Unable to complete database transaction", ex); + } catch (RuntimeException ex) { + rollbackQuietly(connection); + throw ex; + } + } catch (SQLException ex) { + throw new DaoException("Unable to complete database transaction", ex); + } + } + + private static void rollbackQuietly(Connection connection) { + try { + connection.rollback(); + } catch (SQLException ignored) { + // Preserve the original transaction failure for callers and logs. + } + } + private static Properties loadProperties() { try (InputStream inputStream = Thread.currentThread() .getContextClassLoader() diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 725908c..5d9dc04 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -117,6 +117,31 @@ CREATE TABLE IF NOT EXISTS books ( CHECK (status IN ('available', 'unavailable', 'archived')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS borrow_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + reader_id BIGINT NOT NULL, + book_id BIGINT NOT NULL, + borrowed_at DATETIME NOT NULL, + due_at DATETIME NOT NULL, + returned_at DATETIME NULL, + renewal_count INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_borrow_records_reader_id (reader_id), + KEY idx_borrow_records_book_id (book_id), + KEY idx_borrow_records_status (status), + KEY idx_borrow_records_due_at (due_at), + CONSTRAINT fk_borrow_records_reader + FOREIGN KEY (reader_id) REFERENCES readers (id), + CONSTRAINT fk_borrow_records_book + FOREIGN KEY (book_id) REFERENCES books (id), + CONSTRAINT chk_borrow_records_renewal_count + CHECK (renewal_count >= 0), + CONSTRAINT chk_borrow_records_status + CHECK (status IN ('active', 'returned')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + INSERT INTO roles (code, name, description) VALUES ('administrator', 'Administrator', 'Full system administration role'), ('librarian', 'Librarian', 'Library operation and borrowing management role'), @@ -146,7 +171,6 @@ INSERT IGNORE INTO role_permissions (role_code, permission_code) VALUES ('administrator', 'view_reports'), ('administrator', 'view_system_logs'), ('administrator', 'view_catalog'), - ('administrator', 'borrow_books'), ('librarian', 'manage_books'), ('librarian', 'manage_readers'), ('librarian', 'manage_borrowing'), @@ -162,17 +186,10 @@ INSERT IGNORE INTO users (username, password_hash, display_name, role_code, acti ('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1), ('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 1); -INSERT INTO readers (reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) VALUES +INSERT IGNORE INTO readers (reader_identifier, user_id, full_name, phone, email, status, max_borrow_count) VALUES ('RD-0001', (SELECT id FROM users WHERE username = 'reader'), 'Demo Reader', '13800000000', 'reader@example.com', 'active', 5), - ('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3) -ON DUPLICATE KEY UPDATE - user_id = VALUES(user_id), - full_name = VALUES(full_name), - phone = VALUES(phone), - email = VALUES(email), - status = VALUES(status), - max_borrow_count = VALUES(max_borrow_count); + ('RD-0002', NULL, 'Suspended Reader', '13900000000', 'suspended.reader@example.com', 'suspended', 3); INSERT INTO book_categories (name, description) VALUES ('Computer Science', 'Programming, software engineering, and systems books'), @@ -182,7 +199,7 @@ INSERT INTO book_categories (name, description) VALUES ON DUPLICATE KEY UPDATE description = VALUES(description); -INSERT INTO books (book_identifier, title, author, category_id, total_copies, available_copies, status) VALUES +INSERT IGNORE INTO books (book_identifier, title, author, category_id, total_copies, available_copies, status) VALUES ('BK-0001', 'Effective Java', 'Joshua Bloch', (SELECT id FROM book_categories WHERE name = 'Computer Science'), 5, 4, 'available'), ('BK-0002', 'Clean Code', 'Robert C. Martin', @@ -190,11 +207,4 @@ INSERT INTO books (book_identifier, title, author, category_id, total_copies, av ('BK-0003', 'Pride and Prejudice', 'Jane Austen', (SELECT id FROM book_categories WHERE name = 'Literature'), 3, 3, 'available'), ('BK-0004', 'A Brief History of Time', 'Stephen Hawking', - (SELECT id FROM book_categories WHERE name = 'Science'), 2, 1, 'available') -ON DUPLICATE KEY UPDATE - title = VALUES(title), - author = VALUES(author), - category_id = VALUES(category_id), - total_copies = VALUES(total_copies), - available_copies = VALUES(available_copies), - status = VALUES(status); + (SELECT id FROM book_categories WHERE name = 'Science'), 2, 1, 'available'); diff --git a/src/main/webapp/WEB-INF/jsp/borrowing/form.jsp b/src/main/webapp/WEB-INF/jsp/borrowing/form.jsp new file mode 100644 index 0000000..d5a5e54 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/borrowing/form.jsp @@ -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" %> + + + + + + New Borrow - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+

Borrowing Management

+

New borrow

+ + + + + + + + +
+
+
+ + + + + +
+ +
+ + + + + +
+
+ +
+ + Cancel +
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp b/src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp new file mode 100644 index 0000000..285d189 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/borrowing/manage.jsp @@ -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" %> + + + + + + Borrowing Management - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+
+

Borrowing Management

+

Manage borrowing

+

Create borrow records, process returns, renew active loans, and review overdue items.

+
+ New borrow +
+ + +
+ +
+
+ + + + +
+
+
+ + +
+ +
+ + +
+ +
+ + + + + +
+ + + Clear +
+
+ +
+

Borrowing records

+ + +

No borrowing records match the current filters.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReaderBookBorrowedDueReturnedRenewalsStatusActions
+ +
+
+ +
+
+ + + + + Not returned + + / + + + + + + +
+
+ + +
+ +
+ + +
+
+
+
+ + Complete + +
+
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/common/header.jspf b/src/main/webapp/WEB-INF/jsp/common/header.jspf index 9d549d4..e92045b 100644 --- a/src/main/webapp/WEB-INF/jsp/common/header.jspf +++ b/src/main/webapp/WEB-INF/jsp/common/header.jspf @@ -12,8 +12,12 @@ Librarian Books Readers + Borrowing Reader + + My Loans + diff --git a/src/main/webapp/WEB-INF/jsp/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/dashboard.jsp index a80ba42..b285eb1 100644 --- a/src/main/webapp/WEB-INF/jsp/dashboard.jsp +++ b/src/main/webapp/WEB-INF/jsp/dashboard.jsp @@ -46,6 +46,12 @@

Create, update, deactivate, and review reader eligibility records.

Open + +
+

Borrowing Management

+

Create loans, process returns, renew active records, and review overdue items.

+ Open +
@@ -56,9 +62,17 @@

Reader Center

-

Reader self-service entry point.

+

Reader self-service entry point for catalog access and loan history.

Open
+ + +
+

My Loan History

+

Review your active, returned, and overdue borrowing records.

+ Open +
+
diff --git a/src/main/webapp/WEB-INF/jsp/reader/loans.jsp b/src/main/webapp/WEB-INF/jsp/reader/loans.jsp new file mode 100644 index 0000000..0895a41 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/reader/loans.jsp @@ -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" %> + + + + + + Loan History - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+
+

Reader Center

+

Loan history

+

Review your active, returned, and overdue borrowing records.

+
+ Search catalog +
+ + +
+ +
+
+ + + + +
+

Borrowing records

+ + +

No borrowing records are available for this account.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Book IDTitleBorrowedDueReturnedRenewalsStatus
+ + + + + Not returned + + + + + +
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/role-home.jsp b/src/main/webapp/WEB-INF/jsp/role-home.jsp index 6ec1291..1a27c03 100644 --- a/src/main/webapp/WEB-INF/jsp/role-home.jsp +++ b/src/main/webapp/WEB-INF/jsp/role-home.jsp @@ -39,6 +39,20 @@

Create, update, deactivate, and review eligibility fields for reader records.

Manage readers
+ +
+

Borrowing Management

+

Create loans, process returns, renew records, and review overdue items.

+ Manage borrowing +
+ + + +
+

My Loan History

+

Review active loans, returned records, renewal counts, and overdue status.

+ View history +
diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 8202aa3..5f2dc23 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -110,6 +110,28 @@ /readers/delete + + BorrowingManagementServlet + com.mzh.library.controller.BorrowingManagementServlet + + + BorrowingManagementServlet + /borrowing + /borrowing/new + /borrowing/create + /borrowing/return + /borrowing/renew + + + + ReaderLoanHistoryServlet + com.mzh.library.controller.ReaderLoanHistoryServlet + + + ReaderLoanHistoryServlet + /reader/loans + + UnauthorizedServlet com.mzh.library.controller.UnauthorizedServlet diff --git a/src/main/webapp/static/css/app.css b/src/main/webapp/static/css/app.css index 5b62a3e..d038d87 100644 --- a/src/main/webapp/static/css/app.css +++ b/src/main/webapp/static/css/app.css @@ -289,6 +289,10 @@ h2 { align-items: end; } +.borrowing-search-form { + grid-template-columns: repeat(3, minmax(120px, 1fr)) auto auto; +} + .search-field { display: grid; gap: 6px; @@ -305,7 +309,8 @@ h2 { .book-form input, .book-form select, .reader-form input, -.reader-form select { +.reader-form select, +.borrow-form input { width: 100%; min-height: 42px; padding: 9px 11px; @@ -320,7 +325,8 @@ h2 { .book-form input:focus, .book-form select:focus, .reader-form input:focus, -.reader-form select:focus { +.reader-form select:focus, +.borrow-form input:focus { outline: 3px solid rgba(37, 111, 108, 0.18); border-color: var(--color-primary); } @@ -337,6 +343,10 @@ h2 { font-size: 14px; } +.borrowing-table { + min-width: 980px; +} + .data-table th, .data-table td { padding: 12px 10px; @@ -397,6 +407,21 @@ h2 { background: #eef1f5; } +.status-returned { + color: var(--color-muted); + background: #eef1f5; +} + +.status-overdue { + color: #7a211a; + background: #fff0ee; +} + +.muted-text { + color: var(--color-muted); + font-size: 13px; +} + .table-actions { display: flex; gap: 8px; @@ -413,7 +438,8 @@ h2 { } .book-form, -.reader-form { +.reader-form, +.borrow-form { display: grid; gap: 20px; } diff --git a/src/test/java/com/mzh/library/service/BorrowingServiceCheck.java b/src/test/java/com/mzh/library/service/BorrowingServiceCheck.java new file mode 100644 index 0000000..9871fb5 --- /dev/null +++ b/src/test/java/com/mzh/library/service/BorrowingServiceCheck.java @@ -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 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 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 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 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 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 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> 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> 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 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 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 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 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> 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> 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> 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 readers = new LinkedHashMap<>(); + private final Map books = new LinkedHashMap<>(); + private final Map 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 search(BorrowRecordSearchCriteria criteria) { + List 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 findByReaderId(long readerId) { + List matches = new ArrayList<>(); + for (BorrowRecord record : records.values()) { + if (record.getReaderId() == readerId) { + matches.add(enrich(copy(record))); + } + } + return matches; + } + + @Override + public Optional 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 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 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 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 search(BorrowRecordSearchCriteria criteria) { + throw new DaoException("Simulated search failure", null); + } + + @Override + public List findByReaderId(long readerId) { + throw new DaoException("Simulated history failure", null); + } + + @Override + public Optional findReaderByUserId(long userId) { + throw new DaoException("Simulated reader failure", null); + } + + @Override + public Optional findReaderByIdentifierForUpdate(Connection connection, String identifier) { + throw new DaoException("Simulated reader lock failure", null); + } + + @Override + public Optional findBookByIdentifierForUpdate(Connection connection, String identifier) { + throw new DaoException("Simulated book lock failure", null); + } + + @Override + public Optional 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); + } + } +} diff --git a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java index 25ba4ba..4400bb2 100644 --- a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java +++ b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java @@ -13,8 +13,11 @@ public final class PermissionPolicyCheck { require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users"); require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing"); require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers"); + require(!policy.allows(Role.LIBRARIAN, Permission.BORROW_BOOKS), "librarian should not borrow as a reader"); require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users"); require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog"); + require(policy.allows(Role.READER, Permission.BORROW_BOOKS), "reader should view borrowing capabilities"); + require(!policy.allows(Role.READER, Permission.MANAGE_BORROWING), "reader should not manage borrowing"); require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books"); require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers"); }