新增书籍表、列表/搜索、管理员/馆员维护入口

This commit is contained in:
Zzzz
2026-04-27 19:49:14 +08:00
parent 8777efa21d
commit 763830f767
28 changed files with 2392 additions and 8 deletions
@@ -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<List<BookCategory>> categoryResult = bookService.listCategories();
request.setAttribute("categories", categoryResult.isSuccessful()
? categoryResult.getData()
: Collections.emptyList());
if (!categoryResult.isSuccessful()) {
request.setAttribute("errorMessage", categoryResult.getMessage());
}
ServiceResult<List<Book>> 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();
}
}
@@ -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<List<BookCategory>> categoryResult = bookService.listCategories();
request.setAttribute("categories", categoryResult.isSuccessful()
? categoryResult.getData()
: Collections.emptyList());
if (!categoryResult.isSuccessful()) {
request.setAttribute("errorMessage", categoryResult.getMessage());
}
ServiceResult<List<Book>> 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<Optional<Book>> 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<Long> 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<Void> 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<Void> 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<String, String> formValues, Map<String, String> errors, String errorMessage)
throws ServletException, IOException {
ServiceResult<List<BookCategory>> 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<String, String> values = formValues(request);
Map<String, String> 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<String, String> formValues(HttpServletRequest request) {
Map<String, String> 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<String, String> 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<String, String> 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<String, String> values;
private final Map<String, String> errors;
private BookForm(Book book, Map<String, String> values, Map<String, String> errors) {
this.book = book;
this.values = values;
this.errors = errors;
}
private Book getBook() {
return book;
}
private Map<String, String> getValues() {
return values;
}
private Map<String, String> getErrors() {
return errors;
}
}
}
@@ -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<BookCategory> findAllCategories();
List<Book> search(BookSearchCriteria criteria);
Optional<Book> findById(long id);
Optional<Book> findByIdentifier(String identifier);
long create(Book book);
boolean update(Book book);
boolean delete(long id);
}
@@ -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<BookCategory> findAllCategories() {
try (Connection connection = JdbcUtil.getConnection();
PreparedStatement statement = connection.prepareStatement(FIND_ALL_CATEGORIES);
ResultSet resultSet = statement.executeQuery()) {
List<BookCategory> 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<Book> search(BookSearchCriteria criteria) {
List<Object> 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<Book> 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<Book> 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<Book> 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<Object> parameters, String column, String value) {
if (value == null || value.trim().isEmpty()) {
return;
}
sql.append("AND ").append(column).append(" LIKE ? ");
parameters.add("%" + value.trim() + "%");
}
private void bind(PreparedStatement statement, List<Object> 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();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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<PathRule> 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)
@@ -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<List<BookCategory>> listCategories();
ServiceResult<List<Book>> searchBooks(BookSearchCriteria criteria);
ServiceResult<Optional<Book>> findBook(long id);
ServiceResult<Long> createBook(AuthenticatedUser actor, Book book);
ServiceResult<Void> updateBook(AuthenticatedUser actor, Book book);
ServiceResult<Void> deleteBook(AuthenticatedUser actor, long id);
}
@@ -0,0 +1,55 @@
package com.mzh.library.service;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public class ServiceResult<T> {
private final boolean successful;
private final T data;
private final String message;
private final Map<String, String> errors;
private ServiceResult(boolean successful, T data, String message, Map<String, String> errors) {
this.successful = successful;
this.data = data;
this.message = message;
this.errors = Collections.unmodifiableMap(new LinkedHashMap<>(errors));
}
public static <T> ServiceResult<T> success(T data) {
return new ServiceResult<>(true, data, null, Collections.emptyMap());
}
public static <T> ServiceResult<T> success(T data, String message) {
return new ServiceResult<>(true, data, message, Collections.emptyMap());
}
public static <T> ServiceResult<T> failure(String message) {
return new ServiceResult<>(false, null, message, Collections.emptyMap());
}
public static <T> ServiceResult<T> validationFailure(String message, Map<String, String> 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<String, String> getErrors() {
return errors;
}
public boolean hasErrors() {
return !errors.isEmpty();
}
}
@@ -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<List<BookCategory>> 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<List<Book>> searchBooks(BookSearchCriteria criteria) {
BookSearchCriteria normalized = criteria == null ? new BookSearchCriteria() : criteria;
if (normalized.getCategoryId() != null && normalized.getCategoryId() <= 0) {
Map<String, String> 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<Optional<Book>> 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<Long> createBook(AuthenticatedUser actor, Book book) {
if (!canManageBooks(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
normalize(book);
Map<String, String> 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<Void> updateBook(AuthenticatedUser actor, Book book) {
if (!canManageBooks(actor)) {
return ServiceResult.failure(DENIED_MESSAGE);
}
normalize(book);
Map<String, String> errors = validate(book, true);
if (!errors.isEmpty()) {
return ServiceResult.validationFailure(VALIDATION_MESSAGE, errors);
}
try {
Optional<Book> 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<Void> 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<String, String> validate(Book book, boolean requireId) {
Map<String, String> 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<String, String> errors, String field, String value, String label, int maxLength) {
if (value == null || value.isEmpty()) {
errors.put(field, label + " is required.");
return;
}
if (value.length() > maxLength) {
errors.put(field, label + " must be " + maxLength + " characters or fewer.");
}
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
}
+60
View File
@@ -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);
@@ -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" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Catalog - MZH Library</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
</head>
<body>
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
<main class="page-shell">
<section class="dashboard-hero catalog-hero" aria-labelledby="catalog-title">
<p class="eyebrow">Catalog</p>
<h1 id="catalog-title">Book catalog</h1>
<p>Search the library collection by identifier, title, author, or category.</p>
</section>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<section class="toolbar-panel" aria-label="Catalog search">
<form class="search-form" action="${pageContext.request.contextPath}/catalog" method="get">
<div class="search-field">
<label for="identifier">Book ID</label>
<input id="identifier" name="identifier" type="text" value="${fn:escapeXml(criteria.identifier)}">
</div>
<div class="search-field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="${fn:escapeXml(criteria.title)}">
</div>
<div class="search-field">
<label for="author">Author</label>
<input id="author" name="author" type="text" value="${fn:escapeXml(criteria.author)}">
</div>
<div class="search-field">
<label for="categoryId">Category</label>
<select id="categoryId" name="categoryId">
<option value="">All categories</option>
<c:forEach var="category" items="${categories}">
<option value="${category.id}" <c:if test="${criteria.categoryId == category.id}">selected</c:if>>
<c:out value="${category.name}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.categoryId}">
<span class="field-error"><c:out value="${errors.categoryId}" /></span>
</c:if>
</div>
<button class="button button-primary" type="submit">Search</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Clear</a>
<c:if test="${canManageBooks}">
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Manage books</a>
</c:if>
</form>
</section>
<section class="table-panel" aria-labelledby="catalog-results-title">
<h2 id="catalog-results-title">Results</h2>
<c:choose>
<c:when test="${empty books}">
<p class="empty-state">No books match the current filters.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col">Book ID</th>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Category</th>
<th scope="col">Copies</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<c:forEach var="book" items="${books}">
<tr>
<td><c:out value="${book.identifier}" /></td>
<td><c:out value="${book.title}" /></td>
<td><c:out value="${book.author}" /></td>
<td><c:out value="${book.categoryName}" /></td>
<td><c:out value="${book.availableCopies}" /> / <c:out value="${book.totalCopies}" /></td>
<td>
<span class="status-pill status-${book.status.code}">
<c:out value="${book.status.displayName}" />
</span>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
</main>
</body>
</html>
+119
View File
@@ -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" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><c:out value="${formTitle}" /> - MZH Library</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
</head>
<body>
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
<main class="page-shell">
<section class="form-panel" aria-labelledby="book-form-title">
<p class="eyebrow">Book Management</p>
<h1 id="book-form-title"><c:out value="${formTitle}" /></h1>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<c:set var="hasFormValues" value="${not empty formValues}" />
<c:set var="identifierValue" value="${hasFormValues ? formValues.identifier : book.identifier}" />
<c:set var="titleValue" value="${hasFormValues ? formValues.title : book.title}" />
<c:set var="authorValue" value="${hasFormValues ? formValues.author : book.author}" />
<c:set var="categoryValue" value="${book.categoryId}" />
<c:set var="totalCopiesValue" value="${hasFormValues ? formValues.totalCopies : book.totalCopies}" />
<c:set var="availableCopiesValue" value="${hasFormValues ? formValues.availableCopies : book.availableCopies}" />
<c:set var="statusValue" value="${hasFormValues ? formValues.status : book.status.code}" />
<form class="book-form" action="${pageContext.request.contextPath}${formAction}" method="post" novalidate>
<c:if test="${book.id > 0}">
<input type="hidden" name="id" value="${book.id}">
</c:if>
<div class="form-grid">
<div class="form-field">
<label for="identifier">Book ID</label>
<input id="identifier" name="identifier" type="text" value="${fn:escapeXml(identifierValue)}" required>
<c:if test="${not empty errors.identifier}">
<span class="field-error"><c:out value="${errors.identifier}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="${fn:escapeXml(titleValue)}" required>
<c:if test="${not empty errors.title}">
<span class="field-error"><c:out value="${errors.title}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="author">Author</label>
<input id="author" name="author" type="text" value="${fn:escapeXml(authorValue)}" required>
<c:if test="${not empty errors.author}">
<span class="field-error"><c:out value="${errors.author}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="categoryId">Category</label>
<select id="categoryId" name="categoryId" required>
<option value="">Select category</option>
<c:forEach var="category" items="${categories}">
<option value="${category.id}" <c:if test="${categoryValue == category.id}">selected</c:if>>
<c:out value="${category.name}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.categoryId}">
<span class="field-error"><c:out value="${errors.categoryId}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="totalCopies">Total copies</label>
<input id="totalCopies" name="totalCopies" type="number" min="0" value="${fn:escapeXml(totalCopiesValue)}" required>
<c:if test="${not empty errors.totalCopies}">
<span class="field-error"><c:out value="${errors.totalCopies}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="availableCopies">Available copies</label>
<input id="availableCopies" name="availableCopies" type="number" min="0" value="${fn:escapeXml(availableCopiesValue)}" required>
<c:if test="${not empty errors.availableCopies}">
<span class="field-error"><c:out value="${errors.availableCopies}" /></span>
</c:if>
</div>
<div class="form-field">
<label for="status">Status</label>
<select id="status" name="status" required>
<option value="">Select status</option>
<c:forEach var="status" items="${statuses}">
<option value="${status.code}" <c:if test="${statusValue == status.code}">selected</c:if>>
<c:out value="${status.displayName}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.status}">
<span class="field-error"><c:out value="${errors.status}" /></span>
</c:if>
</div>
</div>
<div class="form-actions">
<button class="button button-primary" type="submit">Save</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Cancel</a>
</div>
</form>
</section>
</main>
</body>
</html>
@@ -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" %>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Manage Books - MZH Library</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/app.css">
</head>
<body>
<%@ include file="/WEB-INF/jsp/common/header.jspf" %>
<main class="page-shell">
<section class="dashboard-hero catalog-hero" aria-labelledby="manage-title">
<p class="eyebrow">Book Management</p>
<h1 id="manage-title">Manage books</h1>
<p>Create, update, delete, and review inventory for catalog records.</p>
<a class="button button-primary" href="${pageContext.request.contextPath}/books/new">New book</a>
</section>
<c:if test="${not empty successMessage}">
<div class="message message-success" role="status">
<c:out value="${successMessage}" />
</div>
</c:if>
<c:if test="${not empty errorMessage}">
<div class="message message-error" role="alert">
<c:out value="${errorMessage}" />
</div>
</c:if>
<section class="toolbar-panel" aria-label="Book management search">
<form class="search-form" action="${pageContext.request.contextPath}/books" method="get">
<div class="search-field">
<label for="identifier">Book ID</label>
<input id="identifier" name="identifier" type="text" value="${fn:escapeXml(criteria.identifier)}">
</div>
<div class="search-field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="${fn:escapeXml(criteria.title)}">
</div>
<div class="search-field">
<label for="author">Author</label>
<input id="author" name="author" type="text" value="${fn:escapeXml(criteria.author)}">
</div>
<div class="search-field">
<label for="categoryId">Category</label>
<select id="categoryId" name="categoryId">
<option value="">All categories</option>
<c:forEach var="category" items="${categories}">
<option value="${category.id}" <c:if test="${criteria.categoryId == category.id}">selected</c:if>>
<c:out value="${category.name}" />
</option>
</c:forEach>
</select>
<c:if test="${not empty errors.categoryId}">
<span class="field-error"><c:out value="${errors.categoryId}" /></span>
</c:if>
</div>
<button class="button button-primary" type="submit">Search</button>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Clear</a>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">View catalog</a>
</form>
</section>
<section class="table-panel" aria-labelledby="management-results-title">
<h2 id="management-results-title">Book records</h2>
<c:choose>
<c:when test="${empty books}">
<p class="empty-state">No book records match the current filters.</p>
</c:when>
<c:otherwise>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th scope="col">Book ID</th>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Category</th>
<th scope="col">Copies</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<c:forEach var="book" items="${books}">
<tr>
<td><c:out value="${book.identifier}" /></td>
<td><c:out value="${book.title}" /></td>
<td><c:out value="${book.author}" /></td>
<td><c:out value="${book.categoryName}" /></td>
<td><c:out value="${book.availableCopies}" /> / <c:out value="${book.totalCopies}" /></td>
<td>
<span class="status-pill status-${book.status.code}">
<c:out value="${book.status.displayName}" />
</span>
</td>
<td>
<div class="table-actions">
<a class="button button-secondary"
href="${pageContext.request.contextPath}/books/edit?id=${book.id}">Edit</a>
<form action="${pageContext.request.contextPath}/books/delete"
method="post"
onsubmit="return confirm('Delete this book record?');">
<input type="hidden" name="id" value="${book.id}">
<button class="button button-danger" type="submit">Delete</button>
</form>
</div>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</div>
</c:otherwise>
</c:choose>
</section>
</main>
</body>
</html>
@@ -4,11 +4,13 @@
<c:if test="${not empty sessionScope.authenticatedUser}">
<nav class="top-nav" aria-label="Primary">
<a href="${pageContext.request.contextPath}/dashboard">Dashboard</a>
<a href="${pageContext.request.contextPath}/catalog">Catalog</a>
<c:if test="${sessionScope.userRole == 'administrator'}">
<a href="${pageContext.request.contextPath}/admin/home">Admin</a>
</c:if>
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<a href="${pageContext.request.contextPath}/librarian/home">Librarian</a>
<a href="${pageContext.request.contextPath}/books">Books</a>
</c:if>
<a href="${pageContext.request.contextPath}/reader/home">Reader</a>
<span class="user-pill">
+13 -1
View File
@@ -34,11 +34,23 @@
<p>Book, reader, borrowing, return, renewal, and overdue entry point.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/librarian/home">Open</a>
</article>
<article class="workspace-card">
<h2>Book Management</h2>
<p>Create, update, delete, and review book inventory records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Open</a>
</article>
</c:if>
<article class="workspace-card">
<h2>Book Catalog</h2>
<p>Search books by title, author, category, or book identifier.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Search</a>
</article>
<article class="workspace-card">
<h2>Reader Center</h2>
<p>Catalog search and reader self-service entry point.</p>
<p>Reader self-service entry point.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/reader/home">Open</a>
</article>
</section>
+16
View File
@@ -19,6 +19,22 @@
<p><c:out value="${areaSummary}" /></p>
<a class="button button-primary" href="${pageContext.request.contextPath}/dashboard">Back to dashboard</a>
</section>
<section class="card-grid role-actions" aria-label="Workspace actions">
<article class="workspace-card">
<h2>Book Catalog</h2>
<p>Search available collection records by title, author, category, or book identifier.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/catalog">Search catalog</a>
</article>
<c:if test="${sessionScope.userRole == 'administrator' or sessionScope.userRole == 'librarian'}">
<article class="workspace-card">
<h2>Book Management</h2>
<p>Create, update, delete, and review inventory fields for book records.</p>
<a class="button button-secondary" href="${pageContext.request.contextPath}/books">Manage books</a>
</article>
</c:if>
</section>
</main>
</body>
</html>
+22
View File
@@ -75,6 +75,28 @@
<url-pattern>/reader/home</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>BookCatalogServlet</servlet-name>
<servlet-class>com.mzh.library.controller.BookCatalogServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>BookCatalogServlet</servlet-name>
<url-pattern>/catalog</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>BookManagementServlet</servlet-name>
<servlet-class>com.mzh.library.controller.BookManagementServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>BookManagementServlet</servlet-name>
<url-pattern>/books</url-pattern>
<url-pattern>/books/new</url-pattern>
<url-pattern>/books/edit</url-pattern>
<url-pattern>/books/update</url-pattern>
<url-pattern>/books/delete</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>UnauthorizedServlet</servlet-name>
<servlet-class>com.mzh.library.controller.UnauthorizedServlet</servlet-class>
+197 -2
View File
@@ -8,6 +8,8 @@
--color-primary: #256f6c;
--color-primary-strong: #1b5654;
--color-accent: #b54238;
--color-success: #2f6f3e;
--color-warning: #8a5a00;
--shadow-panel: 0 18px 45px rgba(28, 39, 49, 0.12);
}
@@ -91,7 +93,10 @@ a {
.login-panel,
.notice-panel,
.dashboard-hero,
.workspace-card {
.workspace-card,
.toolbar-panel,
.table-panel,
.form-panel {
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-panel);
@@ -184,6 +189,15 @@ h2 {
background: #ffffff;
}
.button-danger {
color: #ffffff;
background: var(--color-accent);
}
.button-danger:hover {
background: #8f3028;
}
.message {
margin-bottom: 16px;
padding: 10px 12px;
@@ -197,6 +211,12 @@ h2 {
background: #fff0ee;
}
.message-success {
color: #1f572e;
border: 1px solid rgba(47, 111, 62, 0.3);
background: #effaf1;
}
.page-shell {
padding: 36px 0 56px;
}
@@ -239,6 +259,173 @@ h2 {
padding: 28px;
}
.role-actions {
margin-top: 24px;
}
.catalog-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
}
.catalog-hero .button {
margin-top: 0;
}
.toolbar-panel,
.table-panel,
.form-panel {
padding: 24px;
margin-bottom: 24px;
}
.search-form {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr)) auto auto auto;
gap: 10px;
align-items: end;
}
.search-field {
display: grid;
gap: 6px;
}
.search-form label {
color: var(--color-muted);
font-size: 13px;
font-weight: 700;
}
.search-form input,
.search-form select,
.book-form input,
.book-form select {
width: 100%;
min-height: 42px;
padding: 9px 11px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: #ffffff;
font: inherit;
}
.search-form input:focus,
.search-form select:focus,
.book-form input:focus,
.book-form select:focus {
outline: 3px solid rgba(37, 111, 108, 0.18);
border-color: var(--color-primary);
}
.table-scroll {
width: 100%;
overflow-x: auto;
}
.data-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
font-size: 14px;
}
.data-table th,
.data-table td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.data-table th {
color: var(--color-muted);
font-size: 13px;
font-weight: 700;
background: #f8fafc;
}
.empty-state {
margin-bottom: 0;
color: var(--color-muted);
}
.status-pill {
display: inline-flex;
min-height: 28px;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-available {
color: var(--color-success);
background: #edf8ef;
}
.status-unavailable {
color: var(--color-warning);
background: #fff7e5;
}
.status-archived {
color: var(--color-muted);
background: #eef1f5;
}
.table-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.table-actions form {
margin: 0;
}
.form-panel {
max-width: 860px;
}
.book-form {
display: grid;
gap: 20px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.form-field {
display: grid;
gap: 6px;
}
.form-field label {
color: var(--color-muted);
font-size: 14px;
font-weight: 700;
}
.field-error {
color: #7a211a;
font-size: 13px;
}
.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 720px) {
.app-header {
align-items: flex-start;
@@ -257,7 +444,15 @@ h2 {
.login-panel,
.notice-panel,
.dashboard-hero,
.workspace-card {
.workspace-card,
.toolbar-panel,
.table-panel,
.form-panel {
box-shadow: none;
}
.search-form,
.form-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,219 @@
package com.mzh.library.service;
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.BookStatus;
import com.mzh.library.entity.Permission;
import com.mzh.library.entity.Role;
import com.mzh.library.exception.DaoException;
import com.mzh.library.service.impl.BookServiceImpl;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class BookServiceCheck {
private static final String UNAVAILABLE_MESSAGE =
"Book service is temporarily unavailable. Please try again later.";
private BookServiceCheck() {
}
public static void main(String[] args) {
Logger.getLogger(BookServiceImpl.class.getName()).setLevel(Level.OFF);
InMemoryBookDao dao = new InMemoryBookDao();
BookService service = new BookServiceImpl(dao);
AuthenticatedUser librarian = user(10L, Role.LIBRARIAN);
AuthenticatedUser reader = user(20L, Role.READER);
ServiceResult<Long> invalidInventory = service.createBook(librarian,
book(0L, "BK-1000", "Invalid Copies", "Test Author", 1L, 2, 3, BookStatus.AVAILABLE));
require(!invalidInventory.isSuccessful(), "invalid inventory should fail");
require(invalidInventory.getErrors().containsKey("availableCopies"),
"available copies greater than total should be rejected");
ServiceResult<Long> denied = service.createBook(reader,
book(0L, "BK-1001", "Reader Write", "Test Author", 1L, 1, 1, BookStatus.AVAILABLE));
require(!denied.isSuccessful(), "reader write should fail");
require("You do not have permission to manage books.".equals(denied.getMessage()),
"reader write should return permission message");
ServiceResult<Long> created = service.createBook(librarian,
book(0L, "BK-1002", "Service Test", "Test Author", 1L, 2, 1, BookStatus.AVAILABLE));
require(created.isSuccessful(), "librarian should create a valid book");
long createdId = created.getData();
ServiceResult<Long> duplicate = service.createBook(librarian,
book(0L, "BK-1002", "Duplicate", "Test Author", 1L, 1, 1, BookStatus.AVAILABLE));
require(!duplicate.isSuccessful(), "duplicate identifier should fail");
require(duplicate.getErrors().containsKey("identifier"), "duplicate should target identifier field");
ServiceResult<Void> updated = service.updateBook(librarian,
book(createdId, "BK-1003", "Service Test Updated", "Test Author", 1L, 3, 3, BookStatus.AVAILABLE));
require(updated.isSuccessful(), "librarian should update a valid book");
require(dao.findById(createdId).get().getAvailableCopies() == 3, "update should persist available copies");
ServiceResult<List<Book>> search = service.searchBooks(new BookSearchCriteria("BK-1003", "", "", null));
require(search.isSuccessful(), "search should succeed");
require(search.getData().size() == 1, "search should find updated identifier");
ServiceResult<Void> deleted = service.deleteBook(librarian, createdId);
require(deleted.isSuccessful(), "librarian should delete a book");
require(!dao.findById(createdId).isPresent(), "delete should remove the record");
BookService failingService = new BookServiceImpl(new FailingBookDao());
ServiceResult<List<Book>> unavailable = failingService.searchBooks(new BookSearchCriteria());
require(!unavailable.isSuccessful(), "DAO failure should not escape service");
require(UNAVAILABLE_MESSAGE.equals(unavailable.getMessage()), "DAO failure should map to safe 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_BOOKS, Permission.VIEW_CATALOG));
}
private static Book book(long id, String identifier, String title, String author, long categoryId,
int totalCopies, int availableCopies, BookStatus status) {
Book book = new Book();
book.setId(id);
book.setIdentifier(identifier);
book.setTitle(title);
book.setAuthor(author);
book.setCategoryId(categoryId);
book.setTotalCopies(totalCopies);
book.setAvailableCopies(availableCopies);
book.setStatus(status);
return book;
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
private static final class InMemoryBookDao implements BookDao {
private final Map<Long, Book> books = new LinkedHashMap<>();
private long nextId = 1L;
@Override
public List<BookCategory> findAllCategories() {
BookCategory category = new BookCategory();
category.setId(1L);
category.setName("Computer Science");
return Collections.singletonList(category);
}
@Override
public List<Book> search(BookSearchCriteria criteria) {
List<Book> matches = new ArrayList<>();
for (Book book : books.values()) {
if (matches(criteria.getIdentifier(), book.getIdentifier())
&& matches(criteria.getTitle(), book.getTitle())
&& matches(criteria.getAuthor(), book.getAuthor())
&& (criteria.getCategoryId() == null || criteria.getCategoryId() == book.getCategoryId())) {
matches.add(copy(book));
}
}
return matches;
}
@Override
public Optional<Book> findById(long id) {
return Optional.ofNullable(books.get(id)).map(this::copy);
}
@Override
public Optional<Book> findByIdentifier(String identifier) {
for (Book book : books.values()) {
if (book.getIdentifier().equals(identifier)) {
return Optional.of(copy(book));
}
}
return Optional.empty();
}
@Override
public long create(Book book) {
long id = nextId++;
Book stored = copy(book);
stored.setId(id);
books.put(id, stored);
return id;
}
@Override
public boolean update(Book book) {
if (!books.containsKey(book.getId())) {
return false;
}
books.put(book.getId(), copy(book));
return true;
}
@Override
public boolean delete(long id) {
return books.remove(id) != null;
}
private boolean matches(String filter, String value) {
return filter == null || filter.isEmpty() || value.contains(filter);
}
private Book copy(Book source) {
Book copy = book(source.getId(), source.getIdentifier(), source.getTitle(), source.getAuthor(),
source.getCategoryId(), source.getTotalCopies(), source.getAvailableCopies(), source.getStatus());
copy.setCategoryName(source.getCategoryName());
return copy;
}
}
private static final class FailingBookDao implements BookDao {
@Override
public List<BookCategory> findAllCategories() {
throw new DaoException("Simulated category failure", null);
}
@Override
public List<Book> search(BookSearchCriteria criteria) {
throw new DaoException("Simulated search failure", null);
}
@Override
public Optional<Book> findById(long id) {
throw new DaoException("Simulated find failure", null);
}
@Override
public Optional<Book> findByIdentifier(String identifier) {
throw new DaoException("Simulated find failure", null);
}
@Override
public long create(Book book) {
throw new DaoException("Simulated create failure", null);
}
@Override
public boolean update(Book book) {
throw new DaoException("Simulated update failure", null);
}
@Override
public boolean delete(long id) {
throw new DaoException("Simulated delete failure", null);
}
}
}