diff --git a/.codex/config.toml b/.codex/config.toml index 3142a41..6b7b014 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -13,3 +13,8 @@ project_doc_fallback_filenames = ["AGENTS.md"] # # Without this flag, hooks.json is ignored and Trellis context won't # be injected into Codex sessions. + +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index c2e1885..9ebdee1 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -29,9 +29,6 @@ Implemented scaffold tables: status. - `readers`: reader profiles, optional login-account linkage, borrowing eligibility, contact information, and management status. - -Planned module tables: - - `borrow_records`: book-reader borrowing, return, renew, and overdue data. Record new schema changes in `src/main/resources/db/schema.sql` and update this @@ -427,6 +424,95 @@ borrowing/manage.jsp -> JDBC -> UPDATE books SET available_copies = ... borrowing/form.jsp -> BorrowingManagementServlet -> BorrowingService -> BorrowRecordDao -> borrow_records + books in one transaction ``` +## Scenario: Report Center Slice + +### 1. Scope / Trigger + +- Trigger: the report center adds staff-only operational reporting across + `books`, `readers`, and `borrow_records` without adding report tables. +- Schema path: `src/main/resources/db/schema.sql`. +- JSP path: `WEB-INF/jsp/reports/dashboard.jsp`. + +### 2. Signatures + +- Entity signatures: + - `InventorySummary(totalTitles, totalCopies, availableCopies, + unavailableOrEmptyTitles)`. + - `BorrowingSummary(activeLoans, returnedLoans, overdueLoans)`. + - `OverdueReportRow(readerIdentifier, readerName, bookIdentifier, + bookTitle, dueAt, overdueDays)`. + - `PopularBookReportRow(bookIdentifier, title, author, borrowCount)`. + - `ReportCenter(inventorySummary, borrowingSummary, overdueRows, + popularBooks)`. +- DAO signatures: `ReportDao.loadInventorySummary()`, + `loadBorrowingSummary()`, `findOverdueRows()`, and + `findPopularBooks(int limit)`. +- Service signature: `ReportService.loadReportCenter(AuthenticatedUser actor)` + returning `ServiceResult`. +- Servlet route: `GET /reports`. +- Protected permission: `/reports` requires `VIEW_REPORTS`. + +### 3. Contracts + +- Report data is read-only and derived from existing `books`, `readers`, and + `borrow_records` rows; do not introduce aggregate/cache tables for the MVP. +- `unavailableOrEmptyTitles` counts book rows where `books.status` is not + `available` or `books.available_copies <= 0`. +- `activeLoans` counts active borrow rows where `returned_at IS NULL`. +- `returnedLoans` counts rows with `status = returned`. +- `overdueLoans` and overdue rows use the derived rule + `status = active AND returned_at IS NULL AND due_at < CURRENT_TIMESTAMP`. +- Popular book ranking groups by book and orders by borrow record count + descending, with a service/DAO limit for the top rows. +- `ReportServlet` sets the `reportCenter` request attribute on success and + `errorMessage` on safe service failure. +- JSP pages render JavaBean properties only; they must not call DAOs or embed + SQL. +- Dashboard, role-home, and header navigation should expose reports only to + administrator/librarian users. + +### 4. Validation & Error Matrix + +- Missing or unauthenticated actor -> `You do not have permission to view reports.` +- Reader actor -> `You do not have permission to view reports.` +- DAO failure while loading any report section -> log server-side details and + return `Report service is temporarily unavailable. Please try again later.` +- Empty overdue list -> render a stable empty state, not an error. +- Empty popular ranking -> render a stable empty state, not an error. + +### 5. Good/Base/Bad Cases + +- Good: a librarian opens `/reports` and sees inventory totals, borrowing + counts, active overdue rows, and top borrowed books. +- Base: no borrowing records exist; summaries show zero counts and tables show + empty states. +- Bad: a reader reaches `/reports`, a JSP performs `SELECT` queries directly, + or report queries update inventory/borrow state. + +### 6. Tests Required + +- Run `ReportServiceCheck` assertions for reader denial, librarian success, + report section composition, and DAO failure fallback. +- Run `PermissionPolicyCheck` to confirm administrator/librarian roles allow + `VIEW_REPORTS` and readers do not. +- Scan report 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 +reports/dashboard.jsp -> JDBC -> SELECT COUNT(*) FROM borrow_records +``` + +#### Correct + +```text +reports/dashboard.jsp <- ReportServlet <- ReportService <- ReportDao <- books/readers/borrow_records +``` + ## Scenario: Login And Permission Scaffold Schema ### 1. Scope / Trigger diff --git a/.trellis/tasks/04-27-continue-program-sequence/check.jsonl b/.trellis/tasks/04-27-continue-program-sequence/check.jsonl new file mode 100644 index 0000000..0f1b5b9 --- /dev/null +++ b/.trellis/tasks/04-27-continue-program-sequence/check.jsonl @@ -0,0 +1,12 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture and checklist for reviewing Servlet-Service-DAO report changes."} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Verify report classes are placed consistently with backend structure."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Review JDBC aggregate queries and DAO boundaries for report data."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Verify servlet/service validation and failure behavior."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Check maintainability, focused tests, and layer boundaries."} +{"file": ".trellis/spec/frontend/index.md", "reason": "Review JSP report page against frontend conventions."} +{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Verify JSP placement and shared layout usage."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Check dashboard cards, tables, and report UI consistency."} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "Verify servlet-to-JSP request state is explicit and null-safe."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Check UI consistency and responsive/server-rendered page quality."} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Reports span DAO, service, servlet, JSP, and permissions."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements include reports and statistics expectations."} diff --git a/.trellis/tasks/04-27-continue-program-sequence/implement.jsonl b/.trellis/tasks/04-27-continue-program-sequence/implement.jsonl new file mode 100644 index 0000000..d6e5420 --- /dev/null +++ b/.trellis/tasks/04-27-continue-program-sequence/implement.jsonl @@ -0,0 +1,14 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend architecture and pre-development checklist for Servlet-Service-DAO work."} +{"file": ".trellis/spec/backend/directory-structure.md", "reason": "Place report controller, service, DAO, and entities consistently."} +{"file": ".trellis/spec/backend/database-guidelines.md", "reason": "Use MySQL/JDBC aggregate queries and DAO boundaries correctly."} +{"file": ".trellis/spec/backend/error-handling.md", "reason": "Follow existing servlet/service validation and safe failure patterns."} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "Consider reporting and maintenance logging conventions without over-scoping."} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Maintain layer boundaries and focused verification."} +{"file": ".trellis/spec/frontend/index.md", "reason": "JSP/CSS presentation conventions for server-rendered report pages."} +{"file": ".trellis/spec/frontend/directory-structure.md", "reason": "Place report JSPs and shared includes consistently."} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Reuse page, card, form, and table UI patterns."} +{"file": ".trellis/spec/frontend/state-management.md", "reason": "Use request/session state correctly for server-rendered reports."} +{"file": ".trellis/spec/frontend/type-safety.md", "reason": "Keep servlet-to-JSP display contracts explicit and null-safe."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "Keep the report UI consistent with existing JSP pages."} +{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "Reports span DAO, service, servlet, JSP, and permissions."} +{"file": ".trellis/tasks/archive/2026-04/00-bootstrap-guidelines/research/project-requirements.md", "reason": "Original project requirements include reports and statistics expectations."} diff --git a/.trellis/tasks/04-27-continue-program-sequence/prd.md b/.trellis/tasks/04-27-continue-program-sequence/prd.md new file mode 100644 index 0000000..cc70a2e --- /dev/null +++ b/.trellis/tasks/04-27-continue-program-sequence/prd.md @@ -0,0 +1,82 @@ +# 按顺序继续完成程序 + +## Goal + +继续完成 MZH Library Management 的下一组核心功能,让现有 JSP + Servlet + JDBC 图书馆系统从已完成的基础业务链路继续向完整后台能力推进。 + +## What I already know + +* 用户要求“按顺序继续完成程序”,当前没有 active task,因此本任务用于恢复开发方向并确认下一步范围。 +* 用户已选择下一步做 **报表中心**。 +* 项目是 Java 11 Maven WAR,使用 JSP + Servlet + MySQL/JDBC。 +* 已有认证、权限过滤、角色首页、图书目录、图书管理、读者管理、借书/还书/续借/逾期、读者借阅历史。 +* 最近提交顺序显示功能推进为:认证/权限 -> 图书 -> 读者 -> 借还续借逾期。 +* 数据库和权限模型中还预留了 `manage_users`、`view_reports`、`view_system_logs` 等能力。 +* 当前后台 Administration 卡片文案提到账号、角色、权限、系统维护入口,但实际还没有独立用户管理和系统日志页面。 + +## Assumptions (temporary) + +* “按顺序”优先遵循现有业务链和权限模型:借阅链路完成后,应补齐统计报表或管理后台缺口。 +* 本轮应选择一个清晰、可验收的 MVP 模块,不把用户管理、报表、日志全部混在一次任务里。 + +## Open Questions + +* None for this MVP. + +## Requirements + +* 继续沿用现有 Servlet -> Service -> DAO -> JSP 分层。 +* 新增报表中心,面向拥有 `view_reports` 权限的管理员和馆员。 +* 报表中心应至少展示: + * 图书库存概览:总书目数、总册数、可借册数、不可借/归档或无可借库存的提示统计。 + * 借阅概览:当前借出、已归还、已逾期数量。 + * 逾期清单:展示读者、图书、应还日期、逾期天数等关键字段。 + * 热门借阅排行:按借阅记录数量展示 Top 图书。 +* 新页面入口应出现在 dashboard / role-home 的管理员和馆员工作区中。 +* 新功能必须接入现有角色权限体系;读者不能访问报表中心。 +* 新功能应包含 focused service checks,维持当前项目的轻量测试风格。 +* 报表数据应基于现有 `books`、`readers`、`borrow_records` 表,不引入新表。 + +## Acceptance Criteria + +* [x] 明确下一步 MVP 模块:报表中心。 +* [x] 新模块有受权限保护的入口和页面。 +* [x] 新模块使用现有分层和错误处理风格。 +* [x] 管理员和馆员可打开报表中心,读者访问会被拒绝。 +* [x] 报表页面展示库存概览、借阅概览、逾期清单、热门借阅排行。 +* [x] 新增或更新服务层检查。 +* [ ] Maven 构建通过(当前环境未安装 `mvn`,已用 `javac` fallback 和轻量 checks 验证)。 + +## Definition of Done (team quality bar) + +* Tests added/updated where appropriate. +* Maven compile/package checks pass where available. +* Docs/notes updated if behavior changes. +* Rollout/rollback considered if risky. + +## Out of Scope (explicit) + +* 暂不一次性实现用户管理和系统日志页面。 +* 暂不实现图表库、导出 Excel/PDF、按日期筛选等高级报表能力。 +* 暂不引入新框架或前后端分离。 +* 暂不修改现有认证和核心借阅规则,除非所选模块必须依赖。 + +## Technical Approach + +Add a reports slice consistent with the existing architecture: entity/value objects for report summaries, DAO queries for aggregate data, a service facade for report composition and permission checks, a servlet mapped to `/reports`, and a JSP under `WEB-INF/jsp/reports/`. Reuse existing CSS patterns for compact dashboard cards and tables. + +## Decision (ADR-lite) + +**Context**: The borrowing workflow is now implemented, and the permission model already contains `view_reports`. + +**Decision**: Implement a server-rendered report center as the next MVP module, focused on operational summaries that can be derived from existing tables. + +**Consequences**: This gives administrators and librarians immediate visibility into inventory and borrowing health without introducing reporting infrastructure. Date filters, exports, and charts remain future enhancements. + +## Technical Notes + +* `README.md` 描述当前项目结构和本地部署方式。 +* `src/main/resources/db/schema.sql` 已有 `system_logs` 表、`view_reports` 和 `view_system_logs` 权限。 +* `src/main/webapp/WEB-INF/web.xml` 当前未映射 reports、logs 或 users 管理 servlet。 +* `src/main/webapp/WEB-INF/jsp/dashboard.jsp` 和 `role-home.jsp` 是新增入口的主要位置。 +* 语义代码检索 MCP 返回 403 权限错误;本轮自动上下文改用本地只读文件检查。 diff --git a/.trellis/tasks/04-27-continue-program-sequence/task.json b/.trellis/tasks/04-27-continue-program-sequence/task.json new file mode 100644 index 0000000..3f14bdb --- /dev/null +++ b/.trellis/tasks/04-27-continue-program-sequence/task.json @@ -0,0 +1,26 @@ +{ + "id": "continue-program-sequence", + "name": "continue-program-sequence", + "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/README.md b/README.md index a10d8a6..c6b966e 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,4 @@ mvn clean package 5. Deploy `target/library-management.war` to Tomcat. -The implemented scaffold slices now cover login/permission checks, catalog and book management, and reader profile management. Authentication stores only a safe authenticated-user snapshot in the HTTP session, and business workflows stay in Servlet -> Service -> DAO boundaries. +The implemented scaffold slices now cover login/permission checks, catalog and book management, reader profile management, borrowing circulation, reader loan history, and the staff report center. Authentication stores only a safe authenticated-user snapshot in the HTTP session, and business workflows stay in Servlet -> Service -> DAO boundaries. diff --git a/src/main/java/com/mzh/library/controller/ReportServlet.java b/src/main/java/com/mzh/library/controller/ReportServlet.java new file mode 100644 index 0000000..94dbf63 --- /dev/null +++ b/src/main/java/com/mzh/library/controller/ReportServlet.java @@ -0,0 +1,64 @@ +package com.mzh.library.controller; + +import com.mzh.library.dao.impl.JdbcReportDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.ReportCenter; +import com.mzh.library.service.ReportService; +import com.mzh.library.service.ServiceResult; +import com.mzh.library.service.impl.ReportServiceImpl; +import com.mzh.library.util.SessionAttributes; + +import java.io.IOException; + +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 ReportServlet extends HttpServlet { + private static final String REPORT_JSP = "/WEB-INF/jsp/reports/dashboard.jsp"; + private static final String UNAUTHORIZED_JSP = "/WEB-INF/jsp/auth/unauthorized.jsp"; + + private ReportService reportService; + + @Override + public void init() { + this.reportService = new ReportServiceImpl(new JdbcReportDao()); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + ServiceResult result = reportService.loadReportCenter(currentUser(request)); + if (isPermissionDenied(result)) { + forwardDenied(request, response, result.getMessage()); + return; + } + + if (result.isSuccessful()) { + request.setAttribute("reportCenter", result.getData()); + } else { + request.setAttribute("errorMessage", result.getMessage()); + } + + request.getRequestDispatcher(REPORT_JSP).forward(request, response); + } + + private boolean isPermissionDenied(ServiceResult result) { + return !result.isSuccessful() + && "You do not have permission to view reports.".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/ReportDao.java b/src/main/java/com/mzh/library/dao/ReportDao.java new file mode 100644 index 0000000..7474a6d --- /dev/null +++ b/src/main/java/com/mzh/library/dao/ReportDao.java @@ -0,0 +1,18 @@ +package com.mzh.library.dao; + +import com.mzh.library.entity.BorrowingSummary; +import com.mzh.library.entity.InventorySummary; +import com.mzh.library.entity.OverdueReportRow; +import com.mzh.library.entity.PopularBookReportRow; + +import java.util.List; + +public interface ReportDao { + InventorySummary loadInventorySummary(); + + BorrowingSummary loadBorrowingSummary(); + + List findOverdueRows(); + + List findPopularBooks(int limit); +} diff --git a/src/main/java/com/mzh/library/dao/impl/JdbcReportDao.java b/src/main/java/com/mzh/library/dao/impl/JdbcReportDao.java new file mode 100644 index 0000000..f94f62b --- /dev/null +++ b/src/main/java/com/mzh/library/dao/impl/JdbcReportDao.java @@ -0,0 +1,157 @@ +package com.mzh.library.dao.impl; + +import com.mzh.library.dao.ReportDao; +import com.mzh.library.entity.BookStatus; +import com.mzh.library.entity.BorrowRecordStatus; +import com.mzh.library.entity.BorrowingSummary; +import com.mzh.library.entity.InventorySummary; +import com.mzh.library.entity.OverdueReportRow; +import com.mzh.library.entity.PopularBookReportRow; +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.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +public class JdbcReportDao implements ReportDao { + private static final String INVENTORY_SUMMARY = "" + + "SELECT COUNT(*) AS total_titles, " + + "COALESCE(SUM(total_copies), 0) AS total_copies, " + + "COALESCE(SUM(available_copies), 0) AS available_copies, " + + "COALESCE(SUM(CASE WHEN status <> ? OR available_copies <= 0 THEN 1 ELSE 0 END), 0) " + + "AS unavailable_or_empty_titles " + + "FROM books"; + + private static final String BORROWING_SUMMARY = "" + + "SELECT " + + "COALESCE(SUM(CASE WHEN status = ? AND returned_at IS NULL THEN 1 ELSE 0 END), 0) AS active_loans, " + + "COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0) AS returned_loans, " + + "COALESCE(SUM(CASE WHEN status = ? AND returned_at IS NULL AND due_at < CURRENT_TIMESTAMP " + + "THEN 1 ELSE 0 END), 0) AS overdue_loans " + + "FROM borrow_records"; + + private static final String OVERDUE_ROWS = "" + + "SELECT r.reader_identifier, r.full_name AS reader_name, " + + "b.book_identifier, b.title AS book_title, br.due_at, " + + "GREATEST(DATEDIFF(CURRENT_DATE, DATE(br.due_at)), 0) AS overdue_days " + + "FROM borrow_records br " + + "JOIN readers r ON r.id = br.reader_id " + + "JOIN books b ON b.id = br.book_id " + + "WHERE br.status = ? AND br.returned_at IS NULL AND br.due_at < CURRENT_TIMESTAMP " + + "ORDER BY br.due_at, r.reader_identifier, b.book_identifier"; + + private static final String POPULAR_BOOKS = "" + + "SELECT b.book_identifier, b.title, b.author, COUNT(br.id) AS borrow_count " + + "FROM books b " + + "JOIN borrow_records br ON br.book_id = b.id " + + "GROUP BY b.id, b.book_identifier, b.title, b.author " + + "ORDER BY borrow_count DESC, b.title, b.book_identifier " + + "LIMIT ?"; + + @Override + public InventorySummary loadInventorySummary() { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(INVENTORY_SUMMARY)) { + statement.setString(1, BookStatus.AVAILABLE.getCode()); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + InventorySummary summary = new InventorySummary(); + summary.setTotalTitles(resultSet.getInt("total_titles")); + summary.setTotalCopies(resultSet.getInt("total_copies")); + summary.setAvailableCopies(resultSet.getInt("available_copies")); + summary.setUnavailableOrEmptyTitles(resultSet.getInt("unavailable_or_empty_titles")); + return summary; + } + return new InventorySummary(); + } + } catch (SQLException ex) { + throw new DaoException("Unable to load inventory report summary", ex); + } + } + + @Override + public BorrowingSummary loadBorrowingSummary() { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(BORROWING_SUMMARY)) { + statement.setString(1, BorrowRecordStatus.ACTIVE.getCode()); + statement.setString(2, BorrowRecordStatus.RETURNED.getCode()); + statement.setString(3, BorrowRecordStatus.ACTIVE.getCode()); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + BorrowingSummary summary = new BorrowingSummary(); + summary.setActiveLoans(resultSet.getInt("active_loans")); + summary.setReturnedLoans(resultSet.getInt("returned_loans")); + summary.setOverdueLoans(resultSet.getInt("overdue_loans")); + return summary; + } + return new BorrowingSummary(); + } + } catch (SQLException ex) { + throw new DaoException("Unable to load borrowing report summary", ex); + } + } + + @Override + public List findOverdueRows() { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(OVERDUE_ROWS)) { + statement.setString(1, BorrowRecordStatus.ACTIVE.getCode()); + try (ResultSet resultSet = statement.executeQuery()) { + List rows = new ArrayList<>(); + while (resultSet.next()) { + rows.add(mapOverdueRow(resultSet)); + } + return rows; + } + } catch (SQLException ex) { + throw new DaoException("Unable to load overdue report rows", ex); + } + } + + @Override + public List findPopularBooks(int limit) { + try (Connection connection = JdbcUtil.getConnection(); + PreparedStatement statement = connection.prepareStatement(POPULAR_BOOKS)) { + statement.setInt(1, limit); + try (ResultSet resultSet = statement.executeQuery()) { + List rows = new ArrayList<>(); + while (resultSet.next()) { + rows.add(mapPopularBook(resultSet)); + } + return rows; + } + } catch (SQLException ex) { + throw new DaoException("Unable to load popular book report rows", ex); + } + } + + private OverdueReportRow mapOverdueRow(ResultSet resultSet) throws SQLException { + OverdueReportRow row = new OverdueReportRow(); + row.setReaderIdentifier(resultSet.getString("reader_identifier")); + row.setReaderName(resultSet.getString("reader_name")); + row.setBookIdentifier(resultSet.getString("book_identifier")); + row.setBookTitle(resultSet.getString("book_title")); + row.setDueAt(toLocalDateTime(resultSet.getTimestamp("due_at"))); + row.setOverdueDays(resultSet.getLong("overdue_days")); + return row; + } + + private PopularBookReportRow mapPopularBook(ResultSet resultSet) throws SQLException { + PopularBookReportRow row = new PopularBookReportRow(); + row.setBookIdentifier(resultSet.getString("book_identifier")); + row.setTitle(resultSet.getString("title")); + row.setAuthor(resultSet.getString("author")); + row.setBorrowCount(resultSet.getInt("borrow_count")); + return row; + } + + private LocalDateTime toLocalDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } +} diff --git a/src/main/java/com/mzh/library/entity/BorrowingSummary.java b/src/main/java/com/mzh/library/entity/BorrowingSummary.java new file mode 100644 index 0000000..d105bbc --- /dev/null +++ b/src/main/java/com/mzh/library/entity/BorrowingSummary.java @@ -0,0 +1,31 @@ +package com.mzh.library.entity; + +public class BorrowingSummary { + private int activeLoans; + private int returnedLoans; + private int overdueLoans; + + public int getActiveLoans() { + return activeLoans; + } + + public void setActiveLoans(int activeLoans) { + this.activeLoans = activeLoans; + } + + public int getReturnedLoans() { + return returnedLoans; + } + + public void setReturnedLoans(int returnedLoans) { + this.returnedLoans = returnedLoans; + } + + public int getOverdueLoans() { + return overdueLoans; + } + + public void setOverdueLoans(int overdueLoans) { + this.overdueLoans = overdueLoans; + } +} diff --git a/src/main/java/com/mzh/library/entity/InventorySummary.java b/src/main/java/com/mzh/library/entity/InventorySummary.java new file mode 100644 index 0000000..4a4ef98 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/InventorySummary.java @@ -0,0 +1,40 @@ +package com.mzh.library.entity; + +public class InventorySummary { + private int totalTitles; + private int totalCopies; + private int availableCopies; + private int unavailableOrEmptyTitles; + + public int getTotalTitles() { + return totalTitles; + } + + public void setTotalTitles(int totalTitles) { + this.totalTitles = totalTitles; + } + + 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 int getUnavailableOrEmptyTitles() { + return unavailableOrEmptyTitles; + } + + public void setUnavailableOrEmptyTitles(int unavailableOrEmptyTitles) { + this.unavailableOrEmptyTitles = unavailableOrEmptyTitles; + } +} diff --git a/src/main/java/com/mzh/library/entity/OverdueReportRow.java b/src/main/java/com/mzh/library/entity/OverdueReportRow.java new file mode 100644 index 0000000..dcd9ad3 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/OverdueReportRow.java @@ -0,0 +1,67 @@ +package com.mzh.library.entity; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class OverdueReportRow { + private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private String readerIdentifier; + private String readerName; + private String bookIdentifier; + private String bookTitle; + private LocalDateTime dueAt; + private long overdueDays; + + public String getReaderIdentifier() { + return readerIdentifier; + } + + public void setReaderIdentifier(String readerIdentifier) { + this.readerIdentifier = readerIdentifier; + } + + public String getReaderName() { + return readerName; + } + + public void setReaderName(String readerName) { + this.readerName = readerName; + } + + public String getBookIdentifier() { + return bookIdentifier; + } + + public void setBookIdentifier(String bookIdentifier) { + this.bookIdentifier = bookIdentifier; + } + + public String getBookTitle() { + return bookTitle; + } + + public void setBookTitle(String bookTitle) { + this.bookTitle = bookTitle; + } + + public LocalDateTime getDueAt() { + return dueAt; + } + + public void setDueAt(LocalDateTime dueAt) { + this.dueAt = dueAt; + } + + public long getOverdueDays() { + return overdueDays; + } + + public void setOverdueDays(long overdueDays) { + this.overdueDays = overdueDays; + } + + public String getDueAtText() { + return dueAt == null ? "" : DISPLAY_FORMAT.format(dueAt); + } +} diff --git a/src/main/java/com/mzh/library/entity/PopularBookReportRow.java b/src/main/java/com/mzh/library/entity/PopularBookReportRow.java new file mode 100644 index 0000000..c3e9178 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/PopularBookReportRow.java @@ -0,0 +1,40 @@ +package com.mzh.library.entity; + +public class PopularBookReportRow { + private String bookIdentifier; + private String title; + private String author; + private int borrowCount; + + public String getBookIdentifier() { + return bookIdentifier; + } + + public void setBookIdentifier(String bookIdentifier) { + this.bookIdentifier = bookIdentifier; + } + + 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 int getBorrowCount() { + return borrowCount; + } + + public void setBorrowCount(int borrowCount) { + this.borrowCount = borrowCount; + } +} diff --git a/src/main/java/com/mzh/library/entity/ReportCenter.java b/src/main/java/com/mzh/library/entity/ReportCenter.java new file mode 100644 index 0000000..a885df1 --- /dev/null +++ b/src/main/java/com/mzh/library/entity/ReportCenter.java @@ -0,0 +1,51 @@ +package com.mzh.library.entity; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ReportCenter { + private InventorySummary inventorySummary; + private BorrowingSummary borrowingSummary; + private List overdueRows = Collections.emptyList(); + private List popularBooks = Collections.emptyList(); + + public InventorySummary getInventorySummary() { + return inventorySummary; + } + + public void setInventorySummary(InventorySummary inventorySummary) { + this.inventorySummary = inventorySummary; + } + + public BorrowingSummary getBorrowingSummary() { + return borrowingSummary; + } + + public void setBorrowingSummary(BorrowingSummary borrowingSummary) { + this.borrowingSummary = borrowingSummary; + } + + public List getOverdueRows() { + return overdueRows; + } + + public void setOverdueRows(List overdueRows) { + this.overdueRows = immutableCopy(overdueRows); + } + + public List getPopularBooks() { + return popularBooks; + } + + public void setPopularBooks(List popularBooks) { + this.popularBooks = immutableCopy(popularBooks); + } + + private static List immutableCopy(List source) { + if (source == null || source.isEmpty()) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(new ArrayList<>(source)); + } +} diff --git a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java index 676bbe2..4fbd1d0 100644 --- a/src/main/java/com/mzh/library/filter/AuthorizationFilter.java +++ b/src/main/java/com/mzh/library/filter/AuthorizationFilter.java @@ -26,6 +26,7 @@ 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("/reports", Permission.VIEW_REPORTS), new PathRule("/borrowing", Permission.MANAGE_BORROWING), new PathRule("/books", Permission.MANAGE_BOOKS), new PathRule("/readers", Permission.MANAGE_READERS), diff --git a/src/main/java/com/mzh/library/service/ReportService.java b/src/main/java/com/mzh/library/service/ReportService.java new file mode 100644 index 0000000..dde1caa --- /dev/null +++ b/src/main/java/com/mzh/library/service/ReportService.java @@ -0,0 +1,8 @@ +package com.mzh.library.service; + +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.ReportCenter; + +public interface ReportService { + ServiceResult loadReportCenter(AuthenticatedUser actor); +} diff --git a/src/main/java/com/mzh/library/service/impl/ReportServiceImpl.java b/src/main/java/com/mzh/library/service/impl/ReportServiceImpl.java new file mode 100644 index 0000000..9a6dc77 --- /dev/null +++ b/src/main/java/com/mzh/library/service/impl/ReportServiceImpl.java @@ -0,0 +1,56 @@ +package com.mzh.library.service.impl; + +import com.mzh.library.dao.ReportDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.ReportCenter; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.PermissionPolicy; +import com.mzh.library.service.ReportService; +import com.mzh.library.service.ServiceResult; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class ReportServiceImpl implements ReportService { + private static final Logger LOGGER = Logger.getLogger(ReportServiceImpl.class.getName()); + private static final String UNAVAILABLE_MESSAGE = + "Report service is temporarily unavailable. Please try again later."; + private static final String DENIED_MESSAGE = "You do not have permission to view reports."; + private static final int POPULAR_BOOK_LIMIT = 10; + + private final ReportDao reportDao; + private final PermissionPolicy permissionPolicy; + + public ReportServiceImpl(ReportDao reportDao) { + this(reportDao, new PermissionPolicy()); + } + + public ReportServiceImpl(ReportDao reportDao, PermissionPolicy permissionPolicy) { + this.reportDao = reportDao; + this.permissionPolicy = permissionPolicy; + } + + @Override + public ServiceResult loadReportCenter(AuthenticatedUser actor) { + if (!canViewReports(actor)) { + return ServiceResult.failure(DENIED_MESSAGE); + } + + try { + ReportCenter reportCenter = new ReportCenter(); + reportCenter.setInventorySummary(reportDao.loadInventorySummary()); + reportCenter.setBorrowingSummary(reportDao.loadBorrowingSummary()); + reportCenter.setOverdueRows(reportDao.findOverdueRows()); + reportCenter.setPopularBooks(reportDao.findPopularBooks(POPULAR_BOOK_LIMIT)); + return ServiceResult.success(reportCenter); + } catch (DaoException ex) { + LOGGER.log(Level.SEVERE, "Unable to load report center actorId=" + actor.getId(), ex); + return ServiceResult.failure(UNAVAILABLE_MESSAGE); + } + } + + private boolean canViewReports(AuthenticatedUser actor) { + return actor != null && permissionPolicy.allows(actor.getRole(), Permission.VIEW_REPORTS); + } +} diff --git a/src/main/webapp/WEB-INF/jsp/common/header.jspf b/src/main/webapp/WEB-INF/jsp/common/header.jspf index e92045b..b05b002 100644 --- a/src/main/webapp/WEB-INF/jsp/common/header.jspf +++ b/src/main/webapp/WEB-INF/jsp/common/header.jspf @@ -13,6 +13,7 @@ Books Readers Borrowing + Reports Reader diff --git a/src/main/webapp/WEB-INF/jsp/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/dashboard.jsp index b285eb1..739315f 100644 --- a/src/main/webapp/WEB-INF/jsp/dashboard.jsp +++ b/src/main/webapp/WEB-INF/jsp/dashboard.jsp @@ -52,6 +52,12 @@

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

Open + +
+

Report Center

+

Review inventory health, borrowing counts, overdue records, and popular books.

+ Open +
diff --git a/src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp b/src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp new file mode 100644 index 0000000..0df1501 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/reports/dashboard.jsp @@ -0,0 +1,147 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + + + + Reports - MZH Library + + + +<%@ include file="/WEB-INF/jsp/common/header.jspf" %> +
+
+
+

Reports

+

Report center

+

Review collection inventory, borrowing health, overdue loans, and popular books.

+
+ Borrowing records +
+ + + + + + +
+
+

Inventory

+

Total titles

+

+
+
+

Inventory

+

Total copies

+

+
+
+

Inventory

+

Available copies

+

+
+
+

Attention

+

Unavailable or empty

+

+
+
+

Borrowing

+

Currently borrowed

+

+
+
+

Borrowing

+

Returned records

+

+
+
+

Borrowing

+

Overdue loans

+

+
+
+ +
+

Overdue list

+ + +

No active overdue borrowing records.

+
+ +
+ + + + + + + + + + + + + + + + + + + +
ReaderBookDue dateOverdue days
+ +
+
+ +
+
+ + days + +
+
+
+
+
+ +
+ + + +

No borrowing records are available for ranking yet.

+
+ +
+ + + + + + + + + + + + + + + + + +
BookAuthorBorrow records
+ +
+
+
+
+
+
+
+
+ + diff --git a/src/main/webapp/WEB-INF/jsp/role-home.jsp b/src/main/webapp/WEB-INF/jsp/role-home.jsp index 1a27c03..c29c9f7 100644 --- a/src/main/webapp/WEB-INF/jsp/role-home.jsp +++ b/src/main/webapp/WEB-INF/jsp/role-home.jsp @@ -45,6 +45,12 @@

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

Manage borrowing
+ +
+

Report Center

+

Review inventory summaries, borrowing health, overdue lists, and popular books.

+ View reports +
diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 5f2dc23..0662853 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -132,6 +132,15 @@ /reader/loans + + ReportServlet + com.mzh.library.controller.ReportServlet + + + ReportServlet + /reports + + UnauthorizedServlet com.mzh.library.controller.UnauthorizedServlet diff --git a/src/main/webapp/static/css/app.css b/src/main/webapp/static/css/app.css index d038d87..d8ef89c 100644 --- a/src/main/webapp/static/css/app.css +++ b/src/main/webapp/static/css/app.css @@ -238,6 +238,13 @@ h2 { gap: 18px; } +.report-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + .workspace-card { min-height: 190px; display: flex; @@ -254,6 +261,32 @@ h2 { margin-top: auto; } +.report-card { + min-height: 150px; + padding: 20px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-panel); + box-shadow: var(--shadow-panel); +} + +.report-card h2 { + color: var(--color-muted); + font-size: 14px; +} + +.report-card-alert { + border-color: rgba(181, 66, 56, 0.28); +} + +.report-metric { + margin-bottom: 0; + color: var(--color-primary-strong); + font-size: 34px; + font-weight: 700; + line-height: 1; +} + .notice-panel { max-width: 680px; padding: 28px; @@ -491,6 +524,7 @@ h2 { .notice-panel, .dashboard-hero, .workspace-card, + .report-card, .toolbar-panel, .table-panel, .form-panel { diff --git a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java index 4400bb2..e9fc97c 100644 --- a/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java +++ b/src/test/java/com/mzh/library/service/PermissionPolicyCheck.java @@ -11,12 +11,15 @@ public final class PermissionPolicyCheck { PermissionPolicy policy = new PermissionPolicy(); require(policy.allows(Role.ADMINISTRATOR, Permission.MANAGE_USERS), "administrator should manage users"); + require(policy.allows(Role.ADMINISTRATOR, Permission.VIEW_REPORTS), "administrator should view reports"); require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_BORROWING), "librarian should manage borrowing"); require(policy.allows(Role.LIBRARIAN, Permission.MANAGE_READERS), "librarian should manage readers"); + require(policy.allows(Role.LIBRARIAN, Permission.VIEW_REPORTS), "librarian should view reports"); require(!policy.allows(Role.LIBRARIAN, Permission.BORROW_BOOKS), "librarian should not borrow as a reader"); require(!policy.allows(Role.LIBRARIAN, Permission.MANAGE_USERS), "librarian should not manage users"); require(policy.allows(Role.READER, Permission.VIEW_CATALOG), "reader should view catalog"); require(policy.allows(Role.READER, Permission.BORROW_BOOKS), "reader should view borrowing capabilities"); + require(!policy.allows(Role.READER, Permission.VIEW_REPORTS), "reader should not view reports"); require(!policy.allows(Role.READER, Permission.MANAGE_BORROWING), "reader should not manage borrowing"); require(!policy.allows(Role.READER, Permission.MANAGE_BOOKS), "reader should not manage books"); require(!policy.allows(Role.READER, Permission.MANAGE_READERS), "reader should not manage readers"); diff --git a/src/test/java/com/mzh/library/service/ReportServiceCheck.java b/src/test/java/com/mzh/library/service/ReportServiceCheck.java new file mode 100644 index 0000000..f78cf33 --- /dev/null +++ b/src/test/java/com/mzh/library/service/ReportServiceCheck.java @@ -0,0 +1,159 @@ +package com.mzh.library.service; + +import com.mzh.library.dao.ReportDao; +import com.mzh.library.entity.AuthenticatedUser; +import com.mzh.library.entity.BorrowingSummary; +import com.mzh.library.entity.InventorySummary; +import com.mzh.library.entity.OverdueReportRow; +import com.mzh.library.entity.Permission; +import com.mzh.library.entity.PopularBookReportRow; +import com.mzh.library.entity.ReportCenter; +import com.mzh.library.entity.Role; +import com.mzh.library.exception.DaoException; +import com.mzh.library.service.impl.ReportServiceImpl; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class ReportServiceCheck { + private static final String DENIED_MESSAGE = "You do not have permission to view reports."; + private static final String UNAVAILABLE_MESSAGE = + "Report service is temporarily unavailable. Please try again later."; + + private ReportServiceCheck() { + } + + public static void main(String[] args) { + Logger.getLogger(ReportServiceImpl.class.getName()).setLevel(Level.OFF); + + ReportService service = new ReportServiceImpl(new InMemoryReportDao()); + AuthenticatedUser administrator = user(5L, Role.ADMINISTRATOR); + AuthenticatedUser librarian = user(10L, Role.LIBRARIAN); + AuthenticatedUser reader = user(20L, Role.READER); + + ServiceResult anonymousDenied = service.loadReportCenter(null); + requireDenied(anonymousDenied, "anonymous user should not view reports"); + + ServiceResult readerDenied = service.loadReportCenter(reader); + requireDenied(readerDenied, "reader should not view reports"); + + ServiceResult adminReport = service.loadReportCenter(administrator); + require(adminReport.isSuccessful(), "administrator should load report center"); + + ServiceResult report = service.loadReportCenter(librarian); + require(report.isSuccessful(), "librarian should load report center"); + require(report.getData().getInventorySummary().getTotalTitles() == 4, + "inventory summary should expose total titles"); + require(report.getData().getInventorySummary().getUnavailableOrEmptyTitles() == 2, + "inventory summary should expose unavailable or empty titles"); + require(report.getData().getBorrowingSummary().getActiveLoans() == 3, + "borrowing summary should expose active loans"); + require(report.getData().getBorrowingSummary().getOverdueLoans() == 1, + "borrowing summary should expose overdue loans"); + require(report.getData().getOverdueRows().size() == 1, + "report center should include overdue rows"); + require(report.getData().getPopularBooks().size() == 2, + "report center should include popular book rows"); + + ReportService failingService = new ReportServiceImpl(new FailingReportDao()); + ServiceResult failure = failingService.loadReportCenter(librarian); + require(!failure.isSuccessful(), "DAO failure should not escape reports"); + require(UNAVAILABLE_MESSAGE.equals(failure.getMessage()), + "DAO failure should map to safe report message"); + } + + private static void requireDenied(ServiceResult result, String message) { + require(!result.isSuccessful(), message); + require(DENIED_MESSAGE.equals(result.getMessage()), + "report denial should use the report permission message"); + } + + private static AuthenticatedUser user(long id, Role role) { + return new AuthenticatedUser(id, role.getCode(), role.getDisplayName(), role, + role == Role.READER + ? EnumSet.of(Permission.VIEW_CATALOG, Permission.BORROW_BOOKS) + : EnumSet.of(Permission.MANAGE_BORROWING, Permission.VIEW_REPORTS, Permission.VIEW_CATALOG)); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static final class InMemoryReportDao implements ReportDao { + @Override + public InventorySummary loadInventorySummary() { + InventorySummary summary = new InventorySummary(); + summary.setTotalTitles(4); + summary.setTotalCopies(12); + summary.setAvailableCopies(7); + summary.setUnavailableOrEmptyTitles(2); + return summary; + } + + @Override + public BorrowingSummary loadBorrowingSummary() { + BorrowingSummary summary = new BorrowingSummary(); + summary.setActiveLoans(3); + summary.setReturnedLoans(5); + summary.setOverdueLoans(1); + return summary; + } + + @Override + public List findOverdueRows() { + OverdueReportRow row = new OverdueReportRow(); + row.setReaderIdentifier("RD-1000"); + row.setReaderName("Active Reader"); + row.setBookIdentifier("BK-1000"); + row.setBookTitle("Effective Java"); + row.setDueAt(LocalDateTime.of(2026, 4, 1, 12, 0)); + row.setOverdueDays(26); + return Collections.singletonList(row); + } + + @Override + public List findPopularBooks(int limit) { + PopularBookReportRow first = popularBook("BK-1000", "Effective Java", "Joshua Bloch", 5); + PopularBookReportRow second = popularBook("BK-1001", "Clean Code", "Robert C. Martin", 3); + return Arrays.asList(first, second); + } + + private PopularBookReportRow popularBook(String identifier, String title, String author, int borrowCount) { + PopularBookReportRow row = new PopularBookReportRow(); + row.setBookIdentifier(identifier); + row.setTitle(title); + row.setAuthor(author); + row.setBorrowCount(borrowCount); + return row; + } + } + + private static final class FailingReportDao implements ReportDao { + @Override + public InventorySummary loadInventorySummary() { + throw new DaoException("Simulated inventory report failure", null); + } + + @Override + public BorrowingSummary loadBorrowingSummary() { + throw new DaoException("Simulated borrowing report failure", null); + } + + @Override + public List findOverdueRows() { + throw new DaoException("Simulated overdue report failure", null); + } + + @Override + public List findPopularBooks(int limit) { + throw new DaoException("Simulated popular report failure", null); + } + } +}