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
+<%@ 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.
+
+
+
+
+
+
+
+
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" %>
+
+
+