简介:这是一份Java Web网上书店后台管理系统课程设计文档,面向计算机科学与技术等专业学生及Java Web初学者,用于完成基于B/S架构的管理系统课设任务。文档围绕网上书店场景,涉及JSP、Servlet、JavaBean、MySQL与Eclipse、Tomcat开发环境,包含需求分析、系统介绍、数据库表设计、模块结构图、主要功能流程图,并覆盖注册登录、购物车、后台登录、用户管理、图书管理等功能模块;数据库部分给出userdetail表中用户名、密码、权限、注册时间、登录次数,以及books表中图书编码、书名、出版社ID、价格、库存、简介等字段设计,并附有部分页面代码实现。资源包仅含1个doc文件,约845KB,结构完整,文档按系统介绍、数据库表结构、模块结构图、功能流程图及心得体会等顺序编排,便于直接查阅或改编为课程设计报告。目前已有548人学习下载,适合需要参考完整课程设计思路、数据库设计、功能模块划分与实验报告写法的读者,也可作为Java Web综合实践项目的排错与扩展参考。
1. 小型网上书店系统的技术选型与整体认知
课程设计里出现频率很高的一类题目就是网上书店系统,看着简单,真动手会发现它把 Java Web 的核心链路几乎全串了一遍:数据库建模、DAO 封装、Servlet 请求分发、Session 状态管理、JSP 页面渲染、事务控制、分页查询。小型网上书店系统的业务边界通常限定在图书检索浏览、加入购物车、下单结算、订单查询这四条主线上,不做支付对接、不做物流跟踪。技术栈按课程设计最常见、资料最全的组合来选就是 JDK 8 及以上、Servlet 3.1 规范、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、JDBC 直连不走 ORM 框架。这么做的好处是每一层都看得见,答辩时老师随手翻一处代码就能问到底层 SQL 怎么写,不存在框架帮你把逻辑藏起来的情况。适合刚学完 Java SE 和数据库、需要一次性把前后端串起来的中高年级学生,也适合想拿一个完整小项目复习 Servlet 生命周期的开发者。
2. 网上书店系统的数据库表设计与 DAO 层 JDBC 封装
答辩里最容易被追问的不是页面长什么样,而是表建得对不对、SQL 写在哪里。网上书店系统要落地的数据关系并不复杂,但涉及用户、图书、交易三种类型的数据,如果不提前把表结构定清楚,写到购物车和订单模块时就会反复改表。常见做法是先画 ER 图,再写建表 SQL,最后按表逐个写 DAO。这个顺序不能反过来,否则你很容易在 DAO 里写一堆临时拼出来的字段,后面改一处字段名就要改三四个文件。
2.1 用户、图书、订单三张核心表的字段设计
先看核心表结构的字段规划,建表时把主键、索引和约束一次定好,后面 DAO 层就能按固定字段来写。
| 表名 | 核心字段 | 用途说明 |
|---|---|---|
user | id, username, password, email, create_time | 存储注册用户,password 存 MD5 加盐值 |
book | id, isbn, title, author, publisher, price, stock, category_id, cover_url | 图书主数据,stock 直接放这里 |
category | id, name, sort_order | 图书分类,用于前台筛选 |
orders | id, order_no, user_id, total_amount, status, create_time | 订单主表,记录一笔交易的整体信息 |
order_item | id, order_id, book_id, quantity, unit_price | 订单明细,记录成交时的单价 |
重点说两个设计决策。第一,stock库存字段直接放在book表上,不单独拉一张库存表,因为小型网上书店系统不存在多仓库出库场景,拆表反而增加 join 成本,事务控制也更麻烦。第二,订单明细里必须冗余记录unit_price成交单价,不能只存book_id然后 join 回图书表取价格,否则图书调价后历史订单金额就跟着变了。order_no用一个带时间戳和随机数的字符串生成,不要用自增 id 当订单号暴露给前端。图书表的title字段加普通索引,用于关键词检索时的模糊匹配。
2.2 用 JDBC 工具类封装连接与 DAO 基类
课程设计不引入连接池框架也能跑,但连接必须统一管理,不能在每个 DAO 方法里各写各的DriverManager.getConnection()。
public class DBUtil { // 数据库地址、用户名、密码按实际环境修改 private static final String URL = "jdbc:mysql://localhost:3306/bookstore" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { // MySQL 8 使用 com.mysql.cj.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL 驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 逆序关闭:先关结果集,再关语句,最后关连接 try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (ps != null) ps.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }JDBC 工具类解决的是连接从哪来的问题,DAO 基类解决的是增删改查模板代码重复的问题。常见做法是定义一个泛型基类,把update和query两个方法固定下来,子类只需要传 SQL 和参数。
public abstract class BaseDao { // 通用更新:返回受影响行数 protected int update(String sql, Object... params) throws SQLException { try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // 参数从 1 开始绑定,注意不是 0 for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } return ps.executeUpdate(); } } // 通用查询:由子类实现 RowMapper 完成结果集到对象的映射 protected <T> List<T> query(String sql, RowMapper<T> mapper, Object... params) throws SQLException { List<T> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapper.map(rs)); } } } return list; } }RowMapper是一个只有一个方法的接口,子类用 lambda 或匿名类实现它,把ResultSet当前行转成 Java 对象。这种写法比在每个 DAO 里重复写while(rs.next())循环要干净得多,答辩时也容易讲清楚封装思路。update方法里使用 try-with-resources 自动关连接和语句,但query里手动多套了一层ResultSet的关闭,因为 try-with-resources 的关闭顺序同样是从内到外,这样更稳妥。
注意:
Class.forName在 Servlet 容器里只加载一次,写在静态代码块中可以利用类加载机制保证驱动一定在第一次getConnection之前完成注册。
2.3 图书实体类与三层架构的职责边界
实体类和数据表一一对应,字段名用驼峰对应下划线,DAO 层负责映射。实体类里不写业务逻辑,只放属性、getter/setter 和构造方法。三层架构的职责边界在课程设计里必须说清楚:Servlet 负责接收参数和转发页面,Service 层负责组装业务逻辑和事务,DAO 层只做单表 SQL 操作。最常见的错误是把事务写在 DAO 里,导致 Service 无法控制多个 DAO 调用在同一个事务中完成。
public class Book { private Integer id; private String isbn; private String title; private String author; private String publisher; private BigDecimal price; // 金额用 BigDecimal,不用 double private Integer stock; private Integer categoryId; private String coverUrl; // 省略 getter/setter,IDEA 快捷键 Alt+Insert 生成 }BookDao继承BaseDao,在类里定义一个ROW_MAPPER常量实现RowMapper<Book>,把所有字段逐一从ResultSet里取出并塞进新对象。后面按关键词分页查书的findByPage和按 id 查单本书的findById都复用这个映射器。
3. Servlet 与 JSP 实现图书浏览检索与购物车链路
前端页面和 Servlet 的配合是课程设计里代码量最大的部分。图书列表要支持分页和关键词检索,购物车要支持增删改查,页面要能实时反映会话状态。这一层的关键不是写多少页面,而是请求参数的校验和会话数据结构的选型。
3.1 BookListServlet 处理分页查询与关键词检索
列表页的核心是分页参数解析和关键词拼接。如果用字符串拼接 SQL,keyword参数必须做空值和转义处理,否则要么语法报错,要么被注入。
@WebServlet("/book/list") public class BookListServlet extends HttpServlet { private final BookDao bookDao = new BookDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 当前页码默认 1,每页条数默认 8 int page = parseIntOrDefault(req.getParameter("page"), 1); int size = parseIntOrDefault(req.getParameter("size"), 8); String keyword = req.getParameter("keyword"); // 空字符串统一按 null 处理,DAO 层用 <if> 判断是否拼接 LIKE 条件 if (keyword != null && keyword.trim().isEmpty()) { keyword = null; } List<Book> books = bookDao.findByPage(keyword, (page - 1) * size, size); int total = bookDao.countByKeyword(keyword); req.setAttribute("books", books); req.setAttribute("total", total); req.setAttribute("page", page); // 向上取整计算总页数,避免最后一页丢失 req.setAttribute("totalPages", (total + size - 1) / size); req.setAttribute("keyword", keyword); req.getRequestDispatcher("/WEB-INF/jsp/book_list.jsp").forward(req, resp); } private int parseIntOrDefault(String value, int def) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { return def; } } }page参数是用户可控的,如果不做数字校验,传page=abc会直接抛异常导致 500。(page - 1) * size就是 SQL 里LIMIT的第一个参数偏移量。totalPages的计算一定要用向上取整公式(total + size - 1) / size,用整除会丢掉最后一页不满的数据。关键词检索走LIKE '%keyword%',但只在 DAO 方法内拼接%号,Servlet 只传原始关键词,职责分开。
3.2 HttpSession 实现购物车增删改查
购物车的存储方案直接决定后面的代码结构,课程设计里三种方案的对比如下。
| 方案 | 存储位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Session 购物车 | 服务器内存 | 实现简单,与用户会话天然绑定 | 分布式部署不共享,服务重启丢失 | 课程设计、单机部署 |
| Cookie 购物车 | 客户端浏览器 | 不占服务器内存 | 容量受限,用户可篡改 | 未登录用户临时存储 |
| 数据库购物车 | 数据库表 | 持久化,可跨设备访问 | 每次操作都读写数据库 | 登录用户的正式系统 |
课程设计一律推荐 Session 购物车,用LinkedHashMap<Integer, CartItem>来存,key 是图书 id,value 包含图书对象和数量。
// 加入购物车:先取会话里的购物车,没有就新建 @SuppressWarnings("unchecked") Map<Integer, CartItem> cart = (Map<Integer, CartItem>) req.getSession().getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<>(); // LinkedHashMap 保证按加入顺序展示 req.getSession().setAttribute("cart", cart); } int bookId = Integer.parseInt(req.getParameter("bookId")); CartItem item = cart.get(bookId); if (item == null) { Book book = bookDao.findById(bookId); if (book == null || book.getStock() <= 0) { resp.sendRedirect(req.getContextPath() + "/book/list?msg=stock_empty"); return; } cart.put(bookId, new CartItem(book, 1)); } else { // 数量不能超过库存 if (item.getQuantity() + 1 <= item.getBook().getStock()) { item.setQuantity(item.getQuantity() + 1); } } resp.sendRedirect(req.getContextPath() + "/cart/list");用LinkedHashMap而不是HashMap的原因很实际:用户在购物车页面看到的顺序应该和加入顺序一致,HashMap的迭代顺序不稳定,会出现商品在购物车里“跳位置”的现象。加入购物车时校验库存,避免把已经售罄的书加进去,下单时再校验一次。修改数量和删除商品只需从Map里取出CartItem改数量或remove(bookId),再重定向回购物车列表页即可。
3.3 JSP 与 JSTL 渲染图书列表和购物车页面
JSP 页面禁止写 Java 脚本片段,所有逻辑在 Servlet 里完成。页面只做展示和循环。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <div class="book-grid"> <c:forEach items="${books}" var="book"> <div class="book-card"> <img src="${pageContext.request.contextPath}/static/cover/${book.coverUrl}" alt="${book.title}"> <h3>${book.title}</h3> <p>作者:${book.author}</p> <p class="price">¥<fmt:formatNumber value="${book.price}" pattern="#,##0.00"/></p> <c:choose> <c:when test="${book.stock > 0}"> <a href="${pageContext.request.contextPath}/cart/add?bookId=${book.id}">加入购物车</a> </c:when> <c:otherwise><span class="sold-out">无货</span></c:otherwise> </c:choose> </div> </c:forEach> </div>fmt:formatNumber用于金额格式化,把BigDecimal显示成两位小数加千分位。c:choose根据库存决定显示“加入购物车”还是“无货”。pageContext.request.contextPath保证上下文路径变化时链接不失效。分页导航条放在列表下方,用c:if控制上一页/下一页按钮的显示状态,当前页码加粗高亮。
提示:JSP 放在
/WEB-INF/jsp/目录下,浏览器不能直接访问,只能通过 Servlet 转发,这是 MVC 规范的基本要求。
4. 订单结算模块的 JDBC 事务与库存扣减并发控制
下单是整个系统里唯一需要同时操作多张表的业务。订单主表、订单明细表、图书库存表三者必须在同一个事务里完成,任何一步失败都要整体回滚。这一章把事务控制和并发场景分开讲,因为课程设计里能跑通事务是一回事,能不能扛住并发是另一回事。
4.1 订单主表与明细表的拆分设计
订单拆成主表和明细表是电商类系统的标准做法。主表记录订单号、用户、总金额、状态;明细表记录每一本书的 id、数量、成交单价。
| 状态值 | 含义 | 触发条件 | 可流转到 |
|---|---|---|---|
| 0 | 待支付 | 用户提交订单 | 1 或 -1 |
| 1 | 已支付 | 支付成功(模拟) | 2 或 -1 |
| 2 | 已发货 | 管理员操作 | 3 |
| 3 | 已完成 | 用户确认收货 | 终态 |
| -1 | 已取消 | 用户取消或超时 | 终态 |
订单号生成规则用yyyyMMddHHmmss加 4 位随机数,保证同一秒内不会重复。总金额在插入订单前由购物车里的单价乘以数量累加得出,存到orders.total_amount字段。状态字段用整数而不是字符串,便于后续按状态做索引查询。明细表的外键order_id关联主表,级联删除不设,因为订单数据不应该被物理删除。
4.2 手动事务控制实现下单与扣库存的原子操作
JDBC 默认是自动提交模式,每条 SQL 执行完立刻生效。下单需要手动把autoCommit设为false,在所有操作成功后再显式commit,出错时rollback。
public boolean createOrder(Order order, List<OrderItem> items) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 第一步:插入订单主表,并取回自增主键 String orderSql = "INSERT INTO orders(order_no, user_id, total_amount, status, create_time) " + "VALUES(?, ?, ?, 0, NOW())"; PreparedStatement ps1 = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); ps1.setString(1, order.getOrderNo()); ps1.setInt(2, order.getUserId()); ps1.setBigDecimal(3, order.getTotalAmount()); ps1.executeUpdate(); ResultSet rs = ps1.getGeneratedKeys(); int orderId = 0; if (rs.next()) orderId = rs.getInt(1); // 第二步:逐条扣减库存并写入订单明细 String stockSql = "UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?"; String itemSql = "INSERT INTO order_item(order_id, book_id, quantity, unit_price) " + "VALUES(?, ?, ?, ?)"; PreparedStatement ps2 = conn.prepareStatement(stockSql); PreparedStatement ps3 = conn.prepareStatement(itemSql); for (OrderItem item : items) { ps2.setInt(1, item.getQuantity()); ps2.setInt(2, item.getBookId()); ps2.setInt(3, item.getQuantity()); // 库存不足时 affected 为 0 int affected = ps2.executeUpdate(); if (affected == 0) { throw new RuntimeException("库存不足,bookId=" + item.getBookId()); } ps3.setInt(1, orderId); ps3.setInt(2, item.getBookId()); ps3.setInt(3, item.getQuantity()); ps3.setBigDecimal(4, item.getUnitPrice()); ps3.executeUpdate(); } conn.commit(); // 全部成功,提交事务 return true; } catch (Exception e) { // 任何一步异常都回滚,保证不出现半截订单 if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } } }关键点在UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?这条 SQL 上。把库存判断条件写进WHERE里,由数据库保证原子性,而不是先SELECT查库存再判断。任何一条明细扣减失败都抛异常触发回滚,已插入的订单主表和之前的明细全部撤销。finally里恢复autoCommit并关闭连接,防止连接归还到连接池后仍是手动提交模式。
4.3 并发场景下的库存超卖与乐观锁方案
上面的事务写法能解决“插入失败回滚”的问题,但解决不了两个用户同时下单最后一本书的并发问题。比如库存只剩 1 本,两个线程几乎同时执行UPDATE,MySQL 的行锁会让它们串行执行,第一个扣减成功,第二个affected为 0 抛异常回滚——这个逻辑本身是对的。真正需要额外处理的是购物车层面的读取和下单之间的时间差,用户 A 看到有货,加入购物车,下单时可能已经没货了。
-- 乐观锁写法:在 book 表加 version 字段,扣减时带上版本号 UPDATE book SET stock = stock - ?, version = version + 1 WHERE id = ? AND stock >= ? AND version = ?;乐观锁适合并发冲突不高的课程设计场景,每次下单前从购物车项里取出图书的 version 值,更新时比对。如果返回 0,说明版本已被别人改过,提示用户刷新重试。悲观锁用SELECT ... FOR UPDATE也行,但会阻塞其他读操作,课程设计里不推荐,答辩时说清楚两种方案的取舍就够了。
注意:无论用乐观锁还是悲观锁,购物车中存储的图书快照(价格、库存)在每次进入结算页时都要重新从数据库读取,不能直接用 Session 里的旧数据。
5. 小型网上书店系统的分页查询优化与部署排错
前面四章把功能链路跑通了,这一章收在两个具体技巧上:一个是从数据量增长后才会暴露的分页性能问题,另一个是部署到 Tomcat 后最常见的几类报错。这两块内容在答辩时属于加分项,因为大部分课程设计只保证功能可用,不会主动考虑查询效率和线上排查。
5.1 MySQL 深分页的 SQL 优化方案
课程设计的数据量通常只有几百条,LIMIT怎么写性能都一样。但答辩时老师可能会问“数据量大了怎么优化”,这里准备一个说法。
-- 普通分页:偏移量越大,MySQL 需要扫描并丢弃的行越多 SELECT * FROM book ORDER BY id LIMIT 10000, 10; -- 延迟关联优化:先用覆盖索引取主键,再用主键回表取完整行 SELECT b.* FROM book b INNER JOIN (SELECT id FROM book ORDER BY id LIMIT 10000, 10) t ON b.id = t.id; -- 游标法:记住上一页最后一条记录的 id,下一页按 id > lastId 查询 SELECT * FROM book WHERE id > 10000 ORDER BY id LIMIT 10;延迟关联的核心思路是让子查询只扫描id这一列,走覆盖索引,拿到 10 个 id 后再回表取整行数据。游标法适合“下一页”这种顺序翻页的场景,不适合跳页。课程设计里如果用了 MyBatis 的分页插件,也要能说出底层生成的就是LIMIT offset, size这种 SQL,以及为什么深分页会慢。图书列表页只查询book表,id是主键,延迟关联优化生效的前提是ORDER BY id走主键索引,如果按create_time排序,需要给create_time加索引后再对比执行计划。
5.2 部署验证清单与常见报错排查
代码写完只是第一步,把项目扔进 Tomcat 的webapps目录后,能否正常访问、中文是否乱码、数据库能否连通,都需要逐项验证。
| 检查项 | 验证方式 | 常见问题 |
|---|---|---|
| 数据库连通 | 启动后查看控制台日志 | Communications link failure,检查 MySQL 是否启动、端口是否对 |
| 驱动加载 | 确认WEB-INF/lib下有驱动 jar | ClassNotFoundException,驱动包缺失或版本不对 |
| 中文编码 | 页面输入中文搜索,看是否乱码 | 请求解码用request.setCharacterEncoding("UTF-8"),数据库连接串带characterEncoding=utf8 |
| Servlet 映射 | 直接访问对应 URL | 404 通常是@WebServlet路径写错或web.xml版本不匹配 |
| 静态资源 | 访问 CSS/JS 是否 200 | 路径要带contextPath,否则部署到非 ROOT 上下文会 404 |
中文乱码是最常踩的坑。GET 请求的中文参数在 Tomcat 8 以后默认按UTF-8解码,一般没问题;POST 请求必须在读取参数之前调用request.setCharacterEncoding("UTF-8"),写在doPost方法的第一行。数据库连接串里characterEncoding=utf8和serverTimezone一个都不能少,少了前者存进去是问号,少了后者启动就报时区异常。排错的基本顺序是:先看 Tomcat 控制台的堆栈信息定位到具体行,再看浏览器 Network 面板的请求响应,最后单独用 JDBC 小程序连一次数据库确认连接串本身没问题。把这个顺序固化成习惯,答辩演示时遇到报错也不至于卡壳。
本文还有配套的精品资源,点击获取