From 763830f76779e94710aa31f9c937030e816fa9cd Mon Sep 17 00:00:00 2001 From: Zzzz Date: Mon, 27 Apr 2026 19:49:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B9=A6=E7=B1=8D=E8=A1=A8?= =?UTF-8?q?=E3=80=81=E5=88=97=E8=A1=A8/=E6=90=9C=E7=B4=A2=E3=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98/=E9=A6=86=E5=91=98=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/backend/database-guidelines.md | 118 +++++- .../check.jsonl | 13 + .../implement.jsonl | 13 + .../04-27-continue-improve-program/prd.md | 97 +++++ .../04-27-continue-improve-program/task.json | 26 ++ .../controller/BookCatalogServlet.java | 98 +++++ .../controller/BookManagementServlet.java | 371 ++++++++++++++++++ .../java/com/mzh/library/dao/BookDao.java | 24 ++ .../com/mzh/library/dao/impl/JdbcBookDao.java | 224 +++++++++++ .../java/com/mzh/library/entity/Book.java | 105 +++++ .../com/mzh/library/entity/BookCategory.java | 31 ++ .../library/entity/BookSearchCriteria.java | 54 +++ .../com/mzh/library/entity/BookStatus.java | 40 ++ .../mzh/library/exception/DaoException.java | 2 + .../library/filter/AuthorizationFilter.java | 2 + .../com/mzh/library/service/BookService.java | 23 ++ .../mzh/library/service/ServiceResult.java | 55 +++ .../library/service/impl/BookServiceImpl.java | 218 ++++++++++ src/main/resources/db/schema.sql | 60 +++ src/main/webapp/WEB-INF/jsp/books/catalog.jsp | 109 +++++ src/main/webapp/WEB-INF/jsp/books/form.jsp | 119 ++++++ src/main/webapp/WEB-INF/jsp/books/manage.jsp | 126 ++++++ .../webapp/WEB-INF/jsp/common/header.jspf | 2 + src/main/webapp/WEB-INF/jsp/dashboard.jsp | 14 +- src/main/webapp/WEB-INF/jsp/role-home.jsp | 16 + src/main/webapp/WEB-INF/web.xml | 22 ++ src/main/webapp/static/css/app.css | 199 +++++++++- .../mzh/library/service/BookServiceCheck.java | 219 +++++++++++ 28 files changed, 2392 insertions(+), 8 deletions(-) create mode 100644 .trellis/tasks/04-27-continue-improve-program/check.jsonl create mode 100644 .trellis/tasks/04-27-continue-improve-program/implement.jsonl create mode 100644 .trellis/tasks/04-27-continue-improve-program/prd.md create mode 100644 .trellis/tasks/04-27-continue-improve-program/task.json create mode 100644 src/main/java/com/mzh/library/controller/BookCatalogServlet.java create mode 100644 src/main/java/com/mzh/library/controller/BookManagementServlet.java create mode 100644 src/main/java/com/mzh/library/dao/BookDao.java create mode 100644 src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java create mode 100644 src/main/java/com/mzh/library/entity/Book.java create mode 100644 src/main/java/com/mzh/library/entity/BookCategory.java create mode 100644 src/main/java/com/mzh/library/entity/BookSearchCriteria.java create mode 100644 src/main/java/com/mzh/library/entity/BookStatus.java create mode 100644 src/main/java/com/mzh/library/service/BookService.java create mode 100644 src/main/java/com/mzh/library/service/ServiceResult.java create mode 100644 src/main/java/com/mzh/library/service/impl/BookServiceImpl.java create mode 100644 src/main/webapp/WEB-INF/jsp/books/catalog.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/books/form.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/books/manage.jsp create mode 100644 src/test/java/com/mzh/library/service/BookServiceCheck.java diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index fe3d779..b3f3aff 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -24,14 +24,14 @@ Implemented scaffold tables: - `role_permissions`: role-to-permission mapping. - `users`: login accounts for administrator, librarian, and reader roles. - `system_logs`: key operation logs, backup events, and exception traces. +- `book_categories`: category names and descriptions for catalog grouping. +- `books`: book information, category reference, inventory counts, and catalog + status. Planned module tables: -- `books`: book information, inventory count/status, category reference. -- `book_categories`: category names and descriptions. - `readers`: reader profiles, borrowing eligibility, contact information. - `borrow_records`: book-reader borrowing, return, renew, and overdue data. -- `system_logs`: key operation logs, backup events, and exception traces. Record new schema changes in `src/main/resources/db/schema.sql` and update this spec with exact table names, key columns, and DAO/service contracts. @@ -76,8 +76,116 @@ spec with exact table names, key columns, and DAO/service contracts. - `users.role_code` must reference `roles.code`. - `role_permissions.role_code` must reference `roles.code`. - `role_permissions.permission_code` must reference `permissions.code`. -- Prefer explicit status columns/enums for inventory and borrowing states, then - document the chosen values once code exists. +- `books.status` must match `BookStatus` enum codes: `available`, + `unavailable`, and `archived`. + +## Scenario: Book Catalog And Management Slice + +### 1. Scope / Trigger + +- Trigger: the first concrete business module introduced catalog search and + basic book management across MySQL, DAO, service, Servlet, and JSP layers. +- Schema path: `src/main/resources/db/schema.sql`. +- JSP paths: `WEB-INF/jsp/books/catalog.jsp`, `books/manage.jsp`, and + `books/form.jsp`. + +### 2. Signatures + +- DAO signatures: `BookDao.findAllCategories()`, + `BookDao.search(BookSearchCriteria criteria)`, `findById(long id)`, + `findByIdentifier(String identifier)`, `create(Book book)`, + `update(Book book)`, and `delete(long id)`. +- Entity/search signatures: `Book` fields are `id`, `identifier`, `title`, + `author`, `categoryId`, `categoryName`, `totalCopies`, `availableCopies`, + `status`, `createdAt`, and `updatedAt`; `BookSearchCriteria` fields are + `identifier`, `title`, `author`, and nullable `categoryId`. +- Service signatures: `BookService.listCategories()`, + `searchBooks(BookSearchCriteria criteria)`, `findBook(long id)`, + `createBook(AuthenticatedUser actor, Book book)`, + `updateBook(AuthenticatedUser actor, Book book)`, and + `deleteBook(AuthenticatedUser actor, long id)`, all returning + `ServiceResult`. +- Read route: `GET /catalog` with optional `identifier`, `title`, `author`, + and `categoryId` query fields. +- Management routes: `GET /books`, `GET /books/new`, `GET /books/edit?id=...`, + `POST /books`, `POST /books/update`, and `POST /books/delete`. +- Protected permissions: `/catalog` requires `VIEW_CATALOG`; `/books*` + requires `MANAGE_BOOKS`. +- DB signatures: + - `book_categories(id, name, description, created_at, updated_at)`, with + unique key `uk_book_categories_name(name)`. + - `books(id, book_identifier, title, author, category_id, total_copies, + available_copies, status, created_at, updated_at)`, with unique key + `uk_books_identifier(book_identifier)`, indexes on title, author, category, + and status, foreign key `fk_books_category`, and checks for non-negative + copy counts and allowed status values. + +### 3. Contracts + +- `book_categories.name` is unique and displayed through category selectors. +- `books.book_identifier` is the unique user-facing book ID. +- `books.category_id` references `book_categories.id`. +- `books.total_copies` and `books.available_copies` are non-negative, and + available copies cannot exceed total copies. +- `books.status` stores the Java `BookStatus` code exactly. +- Servlet controllers parse request fields and set JSP attributes such as + `criteria`, `categories`, `books`, `book`, `statuses`, `errors`, + `errorMessage`, and `successMessage`. +- `ServiceResult` is the service-to-controller response contract: + `successful`, nullable `data`, nullable `message`, and field-level + `errors`. Controllers must pass validation errors to JSPs so form/search + redisplay can highlight the exact field, for example `errors.categoryId`. +- JSP pages render JavaBean properties only; they must not call DAOs or embed + SQL. + +### 4. Validation & Error Matrix + +- Missing identifier, title, author, category, copy counts, or status -> return + to `books/form.jsp` with field errors. +- Negative total or available copies -> return a field error. +- Available copies greater than total copies -> return + `Available copies cannot exceed total copies.` +- Duplicate `book_identifier` -> return a field error on `identifier`. +- Reader or unauthenticated actor attempts write -> HTTP 403 through + authorization filter or service denial. +- DAO failure during list/search/write -> log server-side details and return + `Book service is temporarily unavailable. Please try again later.` +- Successful create/update/delete -> redirect to `/books` with a short flash + message. + +### 5. Good/Base/Bad Cases + +- Good: a librarian creates `BK-1002`, sees it in `/books`, and readers can + find it from `/catalog` without write controls. +- Base: catalog search with no filters lists available records ordered by + title, author, and identifier. +- Bad: a JSP opens JDBC, builds SQL from request parameters, or renders a stack + trace from a failed DAO call. + +### 6. Tests Required + +- Run `BookServiceCheck` or equivalent assertions for invalid inventory, + duplicate identifiers, reader write denial, successful librarian CRUD, search, + and DAO failure fallback. +- Run `PermissionPolicyCheck` to confirm readers lack `MANAGE_BOOKS` and retain + `VIEW_CATALOG`. +- 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 +books/form.jsp -> JDBC -> INSERT INTO books using request parameters +``` + +#### Correct + +```text +books/form.jsp -> BookManagementServlet -> BookService -> BookDao -> books +``` ## Scenario: Login And Permission Scaffold Schema diff --git a/.trellis/tasks/04-27-continue-improve-program/check.jsonl b/.trellis/tasks/04-27-continue-improve-program/check.jsonl new file mode 100644 index 0000000..e0cbbcd --- /dev/null +++ b/.trellis/tasks/04-27-continue-improve-program/check.jsonl @@ -0,0 +1,13 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview and checklist for verification."} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Verify package layout and layer boundaries."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Verify schema, DAO, search, permission-code, and test expectations."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify validation, DAO/service failure handling, and safe JSP errors."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Verify logging for key operations and failures."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Verify backend quality gate and boundary constraints."} +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend architecture overview and checklist for verification."} +{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Verify JSP and asset placement."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Verify forms, tables, and navigation follow JSP conventions."} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "Verify server-rendered request/session/form state usage."} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Verify JSP request attribute and Servlet validation contracts."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Verify presentation quality for JSP/CSS changes."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Verify the implementation matches the original book/catalog module requirements."} diff --git a/.trellis/tasks/04-27-continue-improve-program/implement.jsonl b/.trellis/tasks/04-27-continue-improve-program/implement.jsonl new file mode 100644 index 0000000..f824254 --- /dev/null +++ b/.trellis/tasks/04-27-continue-improve-program/implement.jsonl @@ -0,0 +1,13 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture overview and checklist for JSP/Servlet/MySQL work."} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Required package layout and layer boundaries for controllers, services, DAOs, entities, filters, and utilities."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "MySQL schema, DAO CRUD, search, permission-code, and test expectations for book/catalog work."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Servlet validation, service failure handling, DAO exception behavior, and safe user-facing errors."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Logging conventions for key operations and failures."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Backend quality gate and layer-boundary constraints."} +{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend architecture overview and checklist for JSP/CSS work."} +{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "JSP, fragment, CSS, JS, and image asset placement conventions."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "JSP form, table, navigation, and reusable component conventions for catalog/management pages."} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "Server-rendered request/session/form state conventions."} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "JSP request attribute and Servlet validation contracts."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Presentation quality checks for JSP/CSS changes."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements for book management, catalog search, entities, and layered architecture."} diff --git a/.trellis/tasks/04-27-continue-improve-program/prd.md b/.trellis/tasks/04-27-continue-improve-program/prd.md new file mode 100644 index 0000000..b6c53a4 --- /dev/null +++ b/.trellis/tasks/04-27-continue-improve-program/prd.md @@ -0,0 +1,97 @@ +# brainstorm: 继续完善程序 + +## Goal + +Continue improving the MZH Library Management system by adding the first concrete business slice: book catalog search and basic book management. + +## What I already know + +* User asked to "继续完善程序" without specifying the target module yet. +* The project is a Java 11 Maven WAR application using JSP, Servlet, Tomcat, MySQL, and JDBC DAOs. +* Current scaffold includes login, logout, authentication filter, authorization filter, role-aware dashboard, and placeholder role home pages. +* Existing roles are administrator, librarian, and reader. +* Existing permissions include user management, book management, reader management, borrowing management, report viewing, system log viewing, catalog viewing, and book borrowing. +* `src/main/resources/db/schema.sql` currently defines role, permission, role-permission, user, and system-log tables, but not book/catalog/reader/borrowing domain tables. +* `src/main/webapp/WEB-INF/jsp/role-home.jsp` is still a generic placeholder page. +* User selected the book catalog / book management slice. +* `Permission.MANAGE_BOOKS` already exists for administrator and librarian users. +* `Permission.VIEW_CATALOG` already exists for administrator, librarian, and reader users. + +## Assumptions (temporary) + +* The implementation should keep the existing layered structure: JSP/CSS presentation -> Servlet controller -> Service -> DAO -> MySQL. +* The work should remain focused enough to finish in one task with build/test verification. +* Book management should be available to administrators and librarians; readers should have read-only catalog search. + +## Open Questions + +* None currently blocking. Default scope is catalog list/search plus create/update/delete management for books and categories only as needed to support book records. + +## Requirements (evolving) + +* Preserve the existing login, role, permission, and dashboard behavior. +* Add book catalog search using the existing Java/JSP/Servlet/JDBC style. +* Add a `books` table and a category representation in `src/main/resources/db/schema.sql`. +* Support searching/listing books by title, author, category, and book identifier. +* Provide administrator/librarian book-management actions for creating, editing, and deleting book records. +* Keep reader-facing catalog access read-only. +* Link the catalog and management pages from the existing dashboard or role workspace. +* Protect write actions with `MANAGE_BOOKS`; allow read-only catalog access with `VIEW_CATALOG`. +* Track inventory fields needed for later borrowing work, such as total copies, available copies, and status. +* Seed enough demo book/category data for local scaffold verification. +* Add or update focused checks/tests for business rules that can be verified without a running Tomcat instance. +* Update user-facing JSP pages and CSS only as needed for the selected workflow. + +## Acceptance Criteria (evolving) + +* [x] Authenticated users with `VIEW_CATALOG` can reach a catalog page and search by title, author, category, or book identifier. +* [x] Administrators and librarians can reach book management pages and create, edit, and delete book records. +* [x] Readers cannot reach write actions for book management. +* [x] Required form/query validation returns clear user-facing errors. +* [x] Book inventory fields reject invalid values such as negative copy counts or available copies greater than total copies. +* [x] DAO/service failures are handled without exposing internal exceptions to JSPs. +* [x] Maven build or equivalent compile/test checks pass in the local environment. + +## Definition of Done (team quality bar) + +* Tests added/updated where appropriate. +* Lint / typecheck / compile checks green where available. +* Docs/notes updated if behavior changes. +* Rollout/rollback considered if risky. + +## Out of Scope (explicit) + +* Replacing JSP/Servlet with another framework. +* Production deployment automation. +* Large visual redesign unrelated to the selected workflow. +* Borrowing, returning, renewing, overdue handling, and reader profile management. +* Full report/statistics dashboards beyond catalog search. + +## Technical Notes + +* Current task directory: `.trellis/tasks/04-27-continue-improve-program`. +* Main app entry points inspected: + * `README.md` + * `pom.xml` + * `src/main/webapp/WEB-INF/web.xml` + * `src/main/resources/db/schema.sql` + * `src/main/webapp/WEB-INF/jsp/dashboard.jsp` + * `src/main/webapp/WEB-INF/jsp/role-home.jsp` + * `src/main/java/com/mzh/library/controller/RoleAreaServlet.java` + * `src/main/java/com/mzh/library/filter/AuthorizationFilter.java` +* Existing checks inspected: + * `src/test/java/com/mzh/library/service/AuthServiceCheck.java` + * `src/test/java/com/mzh/library/service/PermissionPolicyCheck.java` +* Book-module implementation should reuse: + * `Permission.MANAGE_BOOKS` + * `Permission.VIEW_CATALOG` + * Existing controller construction style from `LoginServlet` and `RoleAreaServlet` + * Existing DAO error style from `JdbcUserDao` + * Existing service error style from `AuthServiceImpl` + * Existing role policy in `PermissionPolicy` +* Final verification notes: + * `mvn` is not installed in this shell, so WAR packaging could not be run locally. + * Fallback `javac -Xlint:all` compile passed for non-Servlet app layers and check classes. + * `AuthServiceCheck`, `PermissionPolicyCheck`, and `BookServiceCheck` passed. + * `git diff --check` passed. + * JSP scriptlet and JSP/static SQL/JDBC scans passed. diff --git a/.trellis/tasks/04-27-continue-improve-program/task.json b/.trellis/tasks/04-27-continue-improve-program/task.json new file mode 100644 index 0000000..1bcf1ac --- /dev/null +++ b/.trellis/tasks/04-27-continue-improve-program/task.json @@ -0,0 +1,26 @@ +{ + "id": "continue-improve-program", + "name": "continue-improve-program", + "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/BookCatalogServlet.java b/src/main/java/com/mzh/library/controller/BookCatalogServlet.java new file mode 100644 index 0000000..d43dfe1 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/BookCatalogServlet.java @@ -0,0 +1,98 @@ +package com.mzh.library.controller; + +import com.mzh.library.dao.impl.JdbcBookDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Book; +import com.mzh.library.entity.BookCategory; +import com.mzh.library.entity.BookSearchCriteria; +import com.mzh.library.entity.Permission; +import com.mzh.library.service.BookService; +import com.mzh.library.service.PermissionPolicy; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.impl.BookServiceImpl; +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 BookCatalogServlet extends HttpServlet { + private static final String CATALOG_JSP = "/WEB-INF/jsp/books/catalog.jsp"; + + private BookService bookService; + private PermissionPolicy permissionPolicy; + + @Override + public void init() { + this.bookService = new BookServiceImpl(new JdbcBookDao()); + this.permissionPolicy = new PermissionPolicy(); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + BookSearchCriteria criteria = searchCriteria(request); + request.setAttribute("criteria", criteria); + request.setAttribute("canManageBooks", canManageBooks(request)); + + ServiceResult> categoryResult = bookService.listCategories(); + request.setAttribute("categories", categoryResult.isSuccessful() + ? categoryResult.getData() + : Collections.emptyList()); + if (!categoryResult.isSuccessful()) { + request.setAttribute("errorMessage", categoryResult.getMessage()); + } + + ServiceResult> searchResult = bookService.searchBooks(criteria); + request.setAttribute("books", searchResult.isSuccessful() + ? searchResult.getData() + : Collections.emptyList()); + if (!searchResult.isSuccessful()) { + request.setAttribute("errorMessage", searchResult.getMessage()); + request.setAttribute("errors", searchResult.getErrors()); + } + + request.getRequestDispatcher(CATALOG_JSP).forward(request, response); + } + + private BookSearchCriteria searchCriteria(HttpServletRequest request) { + return new BookSearchCriteria( + request.getParameter("identifier"), + request.getParameter("title"), + request.getParameter("author"), + optionalLong(request.getParameter("categoryId")) + ); + } + + private Long optionalLong(String value) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + return null; + } + try { + return Long.valueOf(trimmed); + } catch (NumberFormatException ex) { + return -1L; + } + } + + private boolean canManageBooks(HttpServletRequest request) { + AuthenticatedUser user = currentUser(request); + return user != null && permissionPolicy.allows(user.getRole(), Permission.MANAGE_BOOKS); + } + + 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 String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/mzh/library/controller/BookManagementServlet.java b/src/main/java/com/mzh/library/controller/BookManagementServlet.java new file mode 100644 index 0000000..f5a6da0 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/BookManagementServlet.java @@ -0,0 +1,371 @@ +package com.mzh.library.controller; + +import com.mzh.library.dao.impl.JdbcBookDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Book; +import com.mzh.library.entity.BookCategory; +import com.mzh.library.entity.BookSearchCriteria; +import com.mzh.library.entity.BookStatus; +import com.mzh.library.service.BookService; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.impl.BookServiceImpl; +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 java.util.Optional; + +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 BookManagementServlet extends HttpServlet { + private static final String MANAGE_JSP = "/WEB-INF/jsp/books/manage.jsp"; + private static final String FORM_JSP = "/WEB-INF/jsp/books/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 BookService bookService; + + @Override + public void init() { + this.bookService = new BookServiceImpl(new JdbcBookDao()); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + String path = request.getServletPath(); + if ("/books/new".equals(path)) { + renderForm(request, response, "Create book", "/books", new Book(), Collections.emptyMap(), + Collections.emptyMap(), null); + return; + } + if ("/books/edit".equals(path)) { + showEditForm(request, response); + return; + } + if (!"/books".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 ("/books".equals(path)) { + createBook(request, response); + return; + } + if ("/books/update".equals(path)) { + updateBook(request, response); + return; + } + if ("/books/delete".equals(path)) { + deleteBook(request, response); + return; + } + + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + + private void showManagementList(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + BookSearchCriteria criteria = searchCriteria(request); + request.setAttribute("criteria", criteria); + applyFlash(request); + + ServiceResult> categoryResult = bookService.listCategories(); + request.setAttribute("categories", categoryResult.isSuccessful() + ? categoryResult.getData() + : Collections.emptyList()); + if (!categoryResult.isSuccessful()) { + request.setAttribute("errorMessage", categoryResult.getMessage()); + } + + ServiceResult> searchResult = bookService.searchBooks(criteria); + request.setAttribute("books", searchResult.isSuccessful() + ? searchResult.getData() + : Collections.emptyList()); + if (!searchResult.isSuccessful()) { + request.setAttribute("errorMessage", searchResult.getMessage()); + request.setAttribute("errors", searchResult.getErrors()); + } + + request.getRequestDispatcher(MANAGE_JSP).forward(request, response); + } + + private void showEditForm(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult> result = bookService.findBook(id); + if (!result.isSuccessful() || !result.getData().isPresent()) { + flashError(request, result.isSuccessful() ? "Book was not found." : result.getMessage()); + response.sendRedirect(request.getContextPath() + "/books"); + return; + } + + renderForm(request, response, "Edit book", "/books/update", result.getData().get(), + Collections.emptyMap(), Collections.emptyMap(), null); + } + + private void createBook(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + BookForm form = readBookForm(request, false); + if (!form.getErrors().isEmpty()) { + renderForm(request, response, "Create book", "/books", form.getBook(), form.getValues(), + form.getErrors(), "Please correct the highlighted book fields."); + return; + } + + ServiceResult result = bookService.createBook(currentUser(request), form.getBook()); + if (!result.isSuccessful()) { + handleFormFailure(request, response, "Create book", "/books", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/books"); + } + + private void updateBook(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + BookForm form = readBookForm(request, true); + if (!form.getErrors().isEmpty()) { + renderForm(request, response, "Edit book", "/books/update", form.getBook(), form.getValues(), + form.getErrors(), "Please correct the highlighted book fields."); + return; + } + + ServiceResult result = bookService.updateBook(currentUser(request), form.getBook()); + if (!result.isSuccessful()) { + handleFormFailure(request, response, "Edit book", "/books/update", form, result); + return; + } + + flashSuccess(request, result.getMessage()); + response.sendRedirect(request.getContextPath() + "/books"); + } + + private void deleteBook(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + long id = requiredLong(request.getParameter("id"), -1L); + ServiceResult result = bookService.deleteBook(currentUser(request), id); + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + if (result.isSuccessful()) { + flashSuccess(request, result.getMessage()); + } else { + flashError(request, result.getMessage()); + } + response.sendRedirect(request.getContextPath() + "/books"); + } + + private void handleFormFailure(HttpServletRequest request, HttpServletResponse response, String title, + String action, BookForm form, ServiceResult result) + throws ServletException, IOException { + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + renderForm(request, response, title, action, form.getBook(), form.getValues(), result.getErrors(), + result.getMessage()); + } + + private void renderForm(HttpServletRequest request, HttpServletResponse response, String title, String action, + Book book, Map formValues, Map errors, String errorMessage) + throws ServletException, IOException { + ServiceResult> categoryResult = bookService.listCategories(); + request.setAttribute("categories", categoryResult.isSuccessful() + ? categoryResult.getData() + : Collections.emptyList()); + request.setAttribute("statuses", BookStatus.values()); + request.setAttribute("formTitle", title); + request.setAttribute("formAction", action); + request.setAttribute("book", book); + request.setAttribute("formValues", formValues); + request.setAttribute("errors", errors); + if (errorMessage != null && !errorMessage.isEmpty()) { + request.setAttribute("errorMessage", errorMessage); + } else if (!categoryResult.isSuccessful()) { + request.setAttribute("errorMessage", categoryResult.getMessage()); + } + request.getRequestDispatcher(FORM_JSP).forward(request, response); + } + + private BookForm readBookForm(HttpServletRequest request, boolean requireId) { + Map values = formValues(request); + Map errors = new LinkedHashMap<>(); + Book book = new Book(); + + if (requireId) { + book.setId(parseLong(values.get("id"), "id", "Select a valid book.", errors)); + } + book.setIdentifier(values.get("identifier")); + book.setTitle(values.get("title")); + book.setAuthor(values.get("author")); + book.setCategoryId(parseLong(values.get("categoryId"), "categoryId", "Select a category.", errors)); + book.setTotalCopies(parseInt(values.get("totalCopies"), "totalCopies", "Enter a valid total copy count.", errors)); + book.setAvailableCopies(parseInt(values.get("availableCopies"), "availableCopies", + "Enter a valid available copy count.", errors)); + + try { + book.setStatus(BookStatus.fromCode(values.get("status"))); + } catch (IllegalArgumentException ex) { + errors.put("status", "Select a status."); + } + + return new BookForm(book, values, errors); + } + + private Map formValues(HttpServletRequest request) { + Map values = new LinkedHashMap<>(); + values.put("id", trim(request.getParameter("id"))); + values.put("identifier", trim(request.getParameter("identifier"))); + values.put("title", trim(request.getParameter("title"))); + values.put("author", trim(request.getParameter("author"))); + values.put("categoryId", trim(request.getParameter("categoryId"))); + values.put("totalCopies", trim(request.getParameter("totalCopies"))); + values.put("availableCopies", trim(request.getParameter("availableCopies"))); + values.put("status", trim(request.getParameter("status"))); + return values; + } + + private BookSearchCriteria searchCriteria(HttpServletRequest request) { + return new BookSearchCriteria( + request.getParameter("identifier"), + request.getParameter("title"), + request.getParameter("author"), + optionalLong(request.getParameter("categoryId")) + ); + } + + private Long optionalLong(String value) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + return null; + } + try { + return Long.valueOf(trimmed); + } catch (NumberFormatException ex) { + return -1L; + } + } + + private long parseLong(String value, String field, String message, Map errors) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + errors.put(field, message); + return 0L; + } + try { + long parsed = Long.parseLong(trimmed); + if (parsed <= 0) { + errors.put(field, message); + } + return parsed; + } catch (NumberFormatException ex) { + errors.put(field, message); + return 0L; + } + } + + private int parseInt(String value, String field, String message, Map errors) { + String trimmed = trim(value); + if (trimmed.isEmpty()) { + errors.put(field, message); + return -1; + } + try { + return Integer.parseInt(trimmed); + } catch (NumberFormatException ex) { + errors.put(field, message); + return -1; + } + } + + 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 boolean isPermissionDenied(ServiceResult result) { + return !result.isSuccessful() && "You do not have permission to manage books.".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(); + } + + private static final class BookForm { + private final Book book; + private final Map values; + private final Map errors; + + private BookForm(Book book, Map values, Map errors) { + this.book = book; + this.values = values; + this.errors = errors; + } + + private Book getBook() { + return book; + } + + private Map getValues() { + return values; + } + + private Map getErrors() { + return errors; + } + } +} diff --git a/src/main/java/com/mzh/library/dao/BookDao.java b/src/main/java/com/mzh/library/dao/BookDao.java new file mode 100644 index 0000000..b845d90 --- /dev/null +++ b/src/main/java/com/mzh/library/dao/BookDao.java @@ -0,0 +1,24 @@ +package com.mzh.library.dao; + +import com.mzh.library.entity.Book; +import com.mzh.library.entity.BookCategory; +import com.mzh.library.entity.BookSearchCriteria; + +import java.util.List; +import java.util.Optional; + +public interface BookDao { + List findAllCategories(); + + List search(BookSearchCriteria criteria); + + Optional findById(long id); + + Optional findByIdentifier(String identifier); + + long create(Book book); + + boolean update(Book book); + + boolean delete(long id); +} diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java new file mode 100644 index 0000000..9d68b14 --- /dev/null +++ b/src/main/java/com/mzh/library/dao/impl/JdbcBookDao.java @@ -0,0 +1,224 @@ +package com.mzh.library.dao.impl; + +import com.mzh.library.dao.BookDao; +import com.mzh.library.entity.Book; +import com.mzh.library.entity.BookCategory; +import com.mzh.library.entity.BookSearchCriteria; +import com.mzh.library.entity.BookStatus; +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 JdbcBookDao implements BookDao { + 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_ALL_CATEGORIES = "" + + "SELECT id, name, description " + + "FROM book_categories " + + "ORDER BY name"; + + private static final String FIND_BY_ID = "SELECT " + BOOK_COLUMNS + BOOK_FROM + "WHERE b.id = ?"; + + private static final String FIND_BY_IDENTIFIER = "SELECT " + BOOK_COLUMNS + BOOK_FROM + + "WHERE b.book_identifier = ?"; + + private static final String CREATE = "" + + "INSERT INTO books " + + "(book_identifier, title, author, category_id, total_copies, available_copies, status) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)"; + + private static final String UPDATE = "" + + "UPDATE books " + + "SET book_identifier = ?, title = ?, author = ?, category_id = ?, total_copies = ?, " + + "available_copies = ?, status = ? " + + "WHERE id = ?"; + + private static final String DELETE = "DELETE FROM books WHERE id = ?"; + + @Override + public List findAllCategories() { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_ALL_CATEGORIES); + ResultSet resultSet = statement.executeQuery()) { + List categories = new ArrayList<>(); + while (resultSet.next()) { + categories.add(mapCategory(resultSet)); + } + return categories; + } catch (SQLException ex) { + throw new DaoException("Unable to load book categories", ex); + } + } + + @Override + public List search(BookSearchCriteria criteria) { + List parameters = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT ") + .append(BOOK_COLUMNS) + .append(BOOK_FROM) + .append("WHERE 1 = 1 "); + + appendLike(sql, parameters, "b.book_identifier", criteria.getIdentifier()); + appendLike(sql, parameters, "b.title", criteria.getTitle()); + appendLike(sql, parameters, "b.author", criteria.getAuthor()); + if (criteria.getCategoryId() != null) { + sql.append("AND b.category_id = ? "); + parameters.add(criteria.getCategoryId()); + } + sql.append("ORDER BY b.title, b.author, b.book_identifier"); + + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + bind(statement, parameters); + try (ResultSet resultSet = statement.executeQuery()) { + List books = new ArrayList<>(); + while (resultSet.next()) { + books.add(mapBook(resultSet)); + } + return books; + } + } catch (SQLException | IllegalArgumentException ex) { + throw new DaoException("Unable to search books", ex); + } + } + + @Override + public Optional findById(long id) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_BY_ID)) { + statement.setLong(1, id); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? Optional.of(mapBook(resultSet)) : Optional.empty(); + } + } catch (SQLException | IllegalArgumentException ex) { + throw new DaoException("Unable to load book by id", ex); + } + } + + @Override + public Optional findByIdentifier(String identifier) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(FIND_BY_IDENTIFIER)) { + 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 load book by identifier", ex); + } + } + + @Override + public long create(Book book) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(CREATE, Statement.RETURN_GENERATED_KEYS)) { + bindBook(statement, book); + statement.executeUpdate(); + + try (ResultSet generatedKeys = statement.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } + } + throw new DaoException("Unable to read generated book id", null); + } catch (SQLException ex) { + throw new DaoException("Unable to create book", ex); + } + } + + @Override + public boolean update(Book book) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(UPDATE)) { + bindBook(statement, book); + statement.setLong(8, book.getId()); + return statement.executeUpdate() == 1; + } catch (SQLException ex) { + throw new DaoException("Unable to update book", ex); + } + } + + @Override + public boolean delete(long id) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(DELETE)) { + statement.setLong(1, id); + return statement.executeUpdate() == 1; + } catch (SQLException ex) { + throw new DaoException("Unable to delete book", 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 bind(PreparedStatement statement, List parameters) throws SQLException { + for (int i = 0; i < parameters.size(); i++) { + Object value = parameters.get(i); + if (value instanceof Long) { + statement.setLong(i + 1, (Long) value); + } else { + statement.setString(i + 1, value.toString()); + } + } + } + + private void bindBook(PreparedStatement statement, Book book) throws SQLException { + statement.setString(1, book.getIdentifier()); + statement.setString(2, book.getTitle()); + statement.setString(3, book.getAuthor()); + statement.setLong(4, book.getCategoryId()); + statement.setInt(5, book.getTotalCopies()); + statement.setInt(6, book.getAvailableCopies()); + statement.setString(7, book.getStatus().getCode()); + } + + 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 BookCategory mapCategory(ResultSet resultSet) throws SQLException { + BookCategory category = new BookCategory(); + category.setId(resultSet.getLong("id")); + category.setName(resultSet.getString("name")); + category.setDescription(resultSet.getString("description")); + return category; + } + + private LocalDateTime toLocalDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } +} diff --git a/src/main/java/com/mzh/library/entity/Book.java b/src/main/java/com/mzh/library/entity/Book.java new file mode 100644 index 0000000..1785fb6 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/Book.java @@ -0,0 +1,105 @@ +package com.mzh.library.entity; + +import java.time.LocalDateTime; + +public class Book { + private long id; + private String identifier; + private String title; + private String author; + private long categoryId; + private String categoryName; + private int totalCopies; + private int availableCopies; + private BookStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public long getCategoryId() { + return categoryId; + } + + public void setCategoryId(long categoryId) { + this.categoryId = categoryId; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + public int getTotalCopies() { + return totalCopies; + } + + public void setTotalCopies(int totalCopies) { + this.totalCopies = totalCopies; + } + + public int getAvailableCopies() { + return availableCopies; + } + + public void setAvailableCopies(int availableCopies) { + this.availableCopies = availableCopies; + } + + public BookStatus getStatus() { + return status; + } + + public void setStatus(BookStatus 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; + } +} diff --git a/src/main/java/com/mzh/library/entity/BookCategory.java b/src/main/java/com/mzh/library/entity/BookCategory.java new file mode 100644 index 0000000..5f6ff83 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/BookCategory.java @@ -0,0 +1,31 @@ +package com.mzh.library.entity; + +public class BookCategory { + private long id; + private String name; + private String description; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/src/main/java/com/mzh/library/entity/BookSearchCriteria.java b/src/main/java/com/mzh/library/entity/BookSearchCriteria.java new file mode 100644 index 0000000..4e8832b --- /dev/null +++ b/src/main/java/com/mzh/library/entity/BookSearchCriteria.java @@ -0,0 +1,54 @@ +package com.mzh.library.entity; + +public class BookSearchCriteria { + private String identifier; + private String title; + private String author; + private Long categoryId; + + public BookSearchCriteria() { + } + + public BookSearchCriteria(String identifier, String title, String author, Long categoryId) { + this.identifier = trim(identifier); + this.title = trim(title); + this.author = trim(author); + this.categoryId = categoryId; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = trim(identifier); + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = trim(title); + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = trim(author); + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/java/com/mzh/library/entity/BookStatus.java b/src/main/java/com/mzh/library/entity/BookStatus.java new file mode 100644 index 0000000..860b1ba --- /dev/null +++ b/src/main/java/com/mzh/library/entity/BookStatus.java @@ -0,0 +1,40 @@ +package com.mzh.library.entity; + +import java.util.Locale; + +public enum BookStatus { + AVAILABLE("available", "Available"), + UNAVAILABLE("unavailable", "Unavailable"), + ARCHIVED("archived", "Archived"); + + private final String code; + private final String displayName; + + BookStatus(String code, String displayName) { + this.code = code; + this.displayName = displayName; + } + + public String getCode() { + return code; + } + + public String getDisplayName() { + return displayName; + } + + public static BookStatus fromCode(String code) { + if (code == null || code.trim().isEmpty()) { + throw new IllegalArgumentException("Book status is required"); + } + + String normalized = code.trim().toLowerCase(Locale.ROOT); + for (BookStatus status : values()) { + if (status.code.equals(normalized)) { + return status; + } + } + + throw new IllegalArgumentException("Unsupported book status: " + code); + } +} diff --git a/src/main/java/com/mzh/library/exception/DaoException.java b/src/main/java/com/mzh/library/exception/DaoException.java index ef3d51c..65243cd 100644 --- a/src/main/java/com/mzh/library/exception/DaoException.java +++ b/src/main/java/com/mzh/library/exception/DaoException.java @@ -1,6 +1,8 @@ package com.mzh.library.exception; public class DaoException extends RuntimeException { + private static final long serialVersionUID = 1L; + public DaoException(String message, Throwable cause) { super(message, cause); } diff --git a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java index 0907ff0..61bd939 100644 --- a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java +++ b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java @@ -25,6 +25,8 @@ 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("/books", Permission.MANAGE_BOOKS), + new PathRule("/catalog", Permission.VIEW_CATALOG), new PathRule("/admin", Permission.MANAGE_USERS), new PathRule("/librarian", Permission.MANAGE_BORROWING), new PathRule("/reader", Permission.VIEW_CATALOG) diff --git a/src/main/java/com/mzh/library/service/BookService.java b/src/main/java/com/mzh/library/service/BookService.java new file mode 100644 index 0000000..dae33a0 --- /dev/null +++ b/src/main/java/com/mzh/library/service/BookService.java @@ -0,0 +1,23 @@ +package com.mzh.library.service; + +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Book; +import com.mzh.library.entity.BookCategory; +import com.mzh.library.entity.BookSearchCriteria; + +import java.util.List; +import java.util.Optional; + +public interface BookService { + ServiceResult> listCategories(); + + ServiceResult> searchBooks(BookSearchCriteria criteria); + + ServiceResult> findBook(long id); + + ServiceResult createBook(AuthenticatedUser actor, Book book); + + ServiceResult updateBook(AuthenticatedUser actor, Book book); + + ServiceResult deleteBook(AuthenticatedUser actor, long id); +} diff --git a/src/main/java/com/mzh/library/service/ServiceResult.java b/src/main/java/com/mzh/library/service/ServiceResult.java new file mode 100644 index 0000000..6e390fb --- /dev/null +++ b/src/main/java/com/mzh/library/service/ServiceResult.java @@ -0,0 +1,55 @@ +package com.mzh.library.service; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class ServiceResult { + private final boolean successful; + private final T data; + private final String message; + private final Map errors; + + private ServiceResult(boolean successful, T data, String message, Map errors) { + this.successful = successful; + this.data = data; + this.message = message; + this.errors = Collections.unmodifiableMap(new LinkedHashMap<>(errors)); + } + + public static ServiceResult success(T data) { + return new ServiceResult<>(true, data, null, Collections.emptyMap()); + } + + public static ServiceResult success(T data, String message) { + return new ServiceResult<>(true, data, message, Collections.emptyMap()); + } + + public static ServiceResult failure(String message) { + return new ServiceResult<>(false, null, message, Collections.emptyMap()); + } + + public static ServiceResult validationFailure(String message, Map errors) { + return new ServiceResult<>(false, null, message, errors); + } + + public boolean isSuccessful() { + return successful; + } + + public T getData() { + return data; + } + + public String getMessage() { + return message; + } + + public Map getErrors() { + return errors; + } + + public boolean hasErrors() { + return !errors.isEmpty(); + } +} diff --git a/src/main/java/com/mzh/library/service/impl/BookServiceImpl.java b/src/main/java/com/mzh/library/service/impl/BookServiceImpl.java new file mode 100644 index 0000000..21ad578 --- /dev/null +++ b/src/main/java/com/mzh/library/service/impl/BookServiceImpl.java @@ -0,0 +1,218 @@ +package com.mzh.library.service.impl; + +import com.mzh.library.dao.BookDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Book; +import com.mzh.library.entity.BookCategory; +import com.mzh.library.entity.BookSearchCriteria; +import com.mzh.library.entity.Permission; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.BookService; +import com.mzh.library.service.PermissionPolicy; +import com.mzh.library.service.ServiceResult; + +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 BookServiceImpl implements BookService { + private static final Logger LOGGER = Logger.getLogger(BookServiceImpl.class.getName()); + private static final String UNAVAILABLE_MESSAGE = + "Book service is temporarily unavailable. Please try again later."; + private static final String VALIDATION_MESSAGE = "Please correct the highlighted book fields."; + private static final String DENIED_MESSAGE = "You do not have permission to manage books."; + + private final BookDao bookDao; + private final PermissionPolicy permissionPolicy; + + public BookServiceImpl(BookDao bookDao) { + this(bookDao, new PermissionPolicy()); + } + + public BookServiceImpl(BookDao bookDao, PermissionPolicy permissionPolicy) { + this.bookDao = bookDao; + this.permissionPolicy = permissionPolicy; + } + + @Override + public ServiceResult> listCategories() { + try { + return ServiceResult.success(bookDao.findAllCategories()); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to list book categories", ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult> searchBooks(BookSearchCriteria criteria) { + BookSearchCriteria normalized = criteria == null ? new BookSearchCriteria() : criteria; + if (normalized.getCategoryId() != null && normalized.getCategoryId() <= 0) { + Map errors = new LinkedHashMap<>(); + errors.put("categoryId", "Select a valid category."); + return ServiceResult.validationFailure("Please correct the catalog search filters.", errors); + } + + try { + return ServiceResult.success(bookDao.search(normalized)); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to search books", ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult> findBook(long id) { + if (id <= 0) { + return ServiceResult.failure("Select a valid book."); + } + + try { + return ServiceResult.success(bookDao.findById(id)); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to load book id=" + id, ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult createBook(AuthenticatedUser actor, Book book) { + if (!canManageBooks(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(book); + Map errors = validate(book, false); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + if (bookDao.findByIdentifier(book.getIdentifier()).isPresent()) { + errors.put("identifier", "Book identifier is already in use."); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + long id = bookDao.create(book); + LOGGER.info("Created book id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(id, "Book created."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to create book actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult updateBook(AuthenticatedUser actor, Book book) { + if (!canManageBooks(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + normalize(book); + Map errors = validate(book, true); + if (!errors.isEmpty()) { + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + try { + Optional existingWithIdentifier = bookDao.findByIdentifier(book.getIdentifier()); + if (existingWithIdentifier.isPresent() && existingWithIdentifier.get().getId() != book.getId()) { + errors.put("identifier", "Book identifier is already in use."); + return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors); + } + + if (!bookDao.update(book)) { + return ServiceResult.failure("Book was not found."); + } + + LOGGER.info("Updated book id=" + book.getId() + " actorId=" + actor.getId()); + return ServiceResult.success(null, "Book updated."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to update book id=" + book.getId() + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + @Override + public ServiceResult deleteBook(AuthenticatedUser actor, long id) { + if (!canManageBooks(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + if (id <= 0) { + return ServiceResult.failure("Select a valid book."); + } + + try { + if (!bookDao.delete(id)) { + return ServiceResult.failure("Book was not found."); + } + + LOGGER.info("Deleted book id=" + id + " actorId=" + actor.getId()); + return ServiceResult.success(null, "Book deleted."); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to delete book id=" + id + " actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + private boolean canManageBooks(AuthenticatedUser actor) { + return actor != null && permissionPolicy.allows(actor.getRole(), Permission.MANAGE_BOOKS); + } + + private void normalize(Book book) { + if (book == null) { + return; + } + book.setIdentifier(trim(book.getIdentifier())); + book.setTitle(trim(book.getTitle())); + book.setAuthor(trim(book.getAuthor())); + } + + private Map validate(Book book, boolean requireId) { + Map errors = new LinkedHashMap<>(); + if (book == null) { + errors.put("book", "Book details are required."); + return errors; + } + + if (requireId && book.getId() <= 0) { + errors.put("id", "Select a valid book."); + } + requireLength(errors, "identifier", book.getIdentifier(), "Book identifier", 64); + requireLength(errors, "title", book.getTitle(), "Title", 200); + requireLength(errors, "author", book.getAuthor(), "Author", 120); + if (book.getCategoryId() <= 0) { + errors.put("categoryId", "Select a category."); + } + if (book.getTotalCopies() < 0) { + errors.put("totalCopies", "Total copies cannot be negative."); + } + if (book.getAvailableCopies() < 0) { + errors.put("availableCopies", "Available copies cannot be negative."); + } + if (book.getAvailableCopies() > book.getTotalCopies()) { + errors.put("availableCopies", "Available copies cannot exceed total copies."); + } + if (book.getStatus() == null) { + errors.put("status", "Select a status."); + } + 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 String trim(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index ef044fa..82a3671 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -57,6 +57,41 @@ CREATE TABLE IF NOT EXISTS system_logs ( KEY idx_system_logs_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS book_categories ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(96) NOT NULL, + description VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_book_categories_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS books ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + book_identifier VARCHAR(64) NOT NULL, + title VARCHAR(200) NOT NULL, + author VARCHAR(120) NOT NULL, + category_id BIGINT NOT NULL, + total_copies INT NOT NULL DEFAULT 0, + available_copies INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'available', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_books_identifier (book_identifier), + KEY idx_books_title (title), + KEY idx_books_author (author), + KEY idx_books_category_id (category_id), + KEY idx_books_status (status), + CONSTRAINT fk_books_category + FOREIGN KEY (category_id) REFERENCES book_categories (id), + CONSTRAINT chk_books_total_copies + CHECK (total_copies >= 0), + CONSTRAINT chk_books_available_copies + CHECK (available_copies >= 0 AND available_copies <= total_copies), + CONSTRAINT chk_books_status + CHECK (status IN ('available', 'unavailable', 'archived')) +) 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'), @@ -101,3 +136,28 @@ INSERT IGNORE INTO users (username, password_hash, display_name, role_code, acti ('admin', 'pbkdf2_sha256$60000$bXpoLWFkbWluLWRlbW8tc2FsdA==$RwBCvhf3Wsc0jemnHlir4mdNZF4ZhHjrfHx/b1Bera0=', 'System Administrator', 'administrator', 1), ('librarian', 'pbkdf2_sha256$60000$bXpoLWxpYnJhcmlhbi1kZW1vLXNhbHQ=$StIdJGDRIiF4aCr+qKuwvob5sL3+6j1caF2sQNqFi78=', 'Library Staff', 'librarian', 1), ('reader', 'pbkdf2_sha256$60000$bXpoLXJlYWRlci1kZW1vLXNhbHQ=$iaiZPGhaIQ+2R2o9UQRj6wsrmYSJ4efqS3jCzM/XU7g=', 'Demo Reader', 'reader', 1); + +INSERT INTO book_categories (name, description) VALUES + ('Computer Science', 'Programming, software engineering, and systems books'), + ('Literature', 'Classic and modern literature'), + ('History', 'World and regional history'), + ('Science', 'Natural science and popular science') +ON DUPLICATE KEY UPDATE + description = VALUES(description); + +INSERT 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', + (SELECT id FROM book_categories WHERE name = 'Computer Science'), 4, 2, 'available'), + ('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); diff --git a/src/main/webapp/WEB-INF/jsp/books/catalog.jsp b/src/main/webapp/WEB-INF/jsp/books/catalog.jsp new file mode 100644 index 0000000..d1f4976 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/books/catalog.jsp @@ -0,0 +1,109 @@ +<%@ 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" %> + + + + + + Catalog - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+

Catalog

+

Book catalog

+

Search the library collection by identifier, title, author, or category.

+
+ + + + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ + + Clear + + Manage books + +
+
+ +
+

Results

+ + +

No books match the current filters.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Book IDTitleAuthorCategoryCopiesStatus
/ + + + +
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/books/form.jsp b/src/main/webapp/WEB-INF/jsp/books/form.jsp new file mode 100644 index 0000000..d612cf2 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/books/form.jsp @@ -0,0 +1,119 @@ +<%@ 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" %> + + + + + + <c:out value="${formTitle}" /> - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+

Book Management

+

+ + + + + + + + + + + + + + +
+ + + + +
+
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+
+ +
+ + Cancel +
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/books/manage.jsp b/src/main/webapp/WEB-INF/jsp/books/manage.jsp new file mode 100644 index 0000000..37472ad --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/books/manage.jsp @@ -0,0 +1,126 @@ +<%@ 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" %> + + + + + + Manage Books - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+

Book Management

+

Manage books

+

Create, update, delete, and review inventory for catalog records.

+ New book +
+ + +
+ +
+
+ + + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + +
+ + + Clear + View catalog +
+
+ +
+

Book records

+ + +

No book records match the current filters.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Book IDTitleAuthorCategoryCopiesStatusActions
/ + + + + +
+ Edit +
+ + +
+
+
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/common/header.jspf b/src/main/webapp/WEB-INF/jsp/common/header.jspf index b70ec42..5b88a5e 100644 --- a/src/main/webapp/WEB-INF/jsp/common/header.jspf +++ b/src/main/webapp/WEB-INF/jsp/common/header.jspf @@ -4,11 +4,13 @@