☰
JSP网上书店系统设计:MVC分层、购物车与订单事务实现
2026/10/28 13:36:22 网站建设 项目流程

简介:围绕网上书店系统的设计与实现,面向进行Java Web课程设计、毕业设计及JSP入门实践的学生与开发者,提供图书展示、查询、购物车、订单和后台管理等模块的参考代码,可用于理解B/S三层结构、JSP页面生命周期、JavaBean与Servlet协作以及JDBC数据库访问的组织方式。压缩包共60个文件,约2.19MB,以21张jpg页面配图、20个asp页面脚本、8个gif素材为主,另含doc说明文档、htm静态页、db与mdb数据库及asa全局配置文件,便于对照页面、数据表和素材查看整体结构。已有54人学习,适合需要快速搭建网上书店原型、梳理功能流程、完善课程设计文档或准备答辩说明的读者参考,也可作为数据库表结构与说明文档整理的辅助材料。

1. 从一份课程设计需求说起:网上书店系统设计代码要解决的三件事

做网上书店系统设计,最容易被低估的不是页面数量,而是数据在几张表之间的流转:游客翻书、登录后加购物车、结算生成订单、后台改库存发货,每一步都牵扯会话、事务和权限。很多 JSP 课设卡在同一个地方——页面能打开,购物车也能加,但一下单库存就对不上,或者刷新一次就多出一笔订单。这套网上书店系统设计代码把用户端和后台拆成两条线,JSP 只做视图,Servlet 负责收参和跳转,JavaBean 承载数据,DAO 封装 SQL,最终落到一套能跑、能改、能讲清楚分层的代码结构上。它适合要交 JavaWeb 课程设计或毕设初稿的人,也适合手头已经有一套 JSP 模板、想把它改成有事务和权限的完整项目的人。

2. 技术选型与总体架构:JSP+Servlet+JavaBean 的三层怎么落

2.1 为什么把一页 JSP 拆成四层

我见过最多的 JSP 书店长什么样:index.jsp 顶部直接Class.forName加载驱动,下面while(rs.next())拼 HTML,加购物车的按钮点下去就在页面里执行一条 insert。这种写法能跑,但三个问题躲不掉——改一个字段要在全项目搜字符串;出错了只有一张 500 页面,不知道断在哪一步;答辩时老师问「你的业务逻辑在哪一层」,答不上来。

拆成 MVC 之后职责就清楚了。JSP 只从 request 域里拿数据渲染,连java.sql.*都不该 import;Servlet 接收参数、做基础校验、决定是 forward 还是 redirect;Service 管业务规则和事务边界,比如「下单要同时扣库存、写订单、清购物车」这种要求整体成功或整体失败的动作;DAO 里只有 SQL 和结果集映射;entity 是纯 JavaBean,字段和表一一对应。依赖方向是单向的:controller 调 service,service 调 dao,谁都不反向依赖视图,这样以后要把 JSP 换成模板引擎,业务代码一行不用动。

选型上我没有引 Spring 和 MyBatis。课程设计的环境通常是现成的 Tomcat 加一个 JDK,多一个 jar 就多一个部署失败点,而且答辩老师多半按 JSP+Servlet 这条教学主线提问,讲偏了反而扣分。用 JSTL 加 EL 替掉页面里的<% %>脚本片段,是这套结构里成本最低、观感最好的一步;一个 JSP 里如果还在写 for 循环拼表格,分层基本等于没做。

2.2 目录结构与依赖方向

先看工程骨架,这个结构决定了后面所有代码往哪儿放:

bookstore/ ├── src/main/java/com/example/bookstore/ │ ├── controller/ BookServlet、CartServlet、OrderServlet、AdminBookServlet │ ├── service/ BookService、CartService、OrderService、UserService │ ├── dao/ BookDao、CartDao、OrderDao、UserDao │ ├── entity/ Book、User、CartItem、Order、OrderItem │ ├── common/ DBUtil、PageBean、OrderNoUtil、MD5Util │ └── filter/ EncodingFilter、LoginFilter ├── src/main/webapp/ │ ├── WEB-INF/jsp/ 所有页面放这里,浏览器无法直接访问 │ ├── css/ js/ images/ │ └── index.jsp 只做一次跳转 └── sql/bookstore.sql 建表语句与初始数据

页面全部塞进WEB-INF是这套结构里最关键的一条约定。放在这里的 JSP 浏览器敲地址访问不到,必须经过 Servlet 转发,权限校验和参数绑定才有唯一的入口。index.jsp 里只写一行response.sendRedirect("book?action=list"),它存在的意义是让部署后的根路径能直接打开。

数据库连接统一走一个工具类,不要在每个 DAO 里各写一遍驱动和 URL:

package com.example.bookstore.common; import java.sql.*; public final class DBUtil { // 连接参数集中在这里,换机器只改这一处 private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动未加载:" + e.getMessage()); } } private DBUtil() {} public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 关闭顺序与打开顺序相反,每个 close 单独 try // 一个异常不能吞掉后面两个资源的关闭 public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) try { rs.close(); } catch (SQLException ignored) {} if (stmt != null) try { stmt.close(); } catch (SQLException ignored) {} if (conn != null) try { conn.close(); } catch (SQLException ignored) {} } }

URL 里的characterEncoding=UTF-8保证读写中文不乱码,serverTimezone少了它,插入的时间和本机差八个小时,查「今天下的单」永远查不到当天的。USER和PASSWORD按本机环境改,其他代码不用动。关闭方法拆成三段写,是我踩过的坑:早先把三个 close 写在一个 try 里,ResultSet 关闭时抛异常,后面的 Statement 和 Connection 全被跳过,压测半小时连接就用满了。课设阶段用 DriverManager 每次新建连接够用,前提是每个 DAO 方法都在 finally 里关掉。

2.3 一次「加入购物车」要经过哪些类

把请求链路走一遍,比看十张架构图都直观。用户在详情页点「加入购物车」,表单 POST 到/cart,隐藏域带action=add、bookId、quantity;CartServlet 先判断 action,再把参数转成 int 并做上下界收敛;从 session 里取当前登录用户;调用 CartService,Service 里查一下这本书是否上架、库存是否大于 0;最后 CartDao 执行「有则累加、无则插入」;写完之后 302 重定向到购物车列表页。

@WebServlet("/cart") public class CartServlet extends HttpServlet { private final CartService cartService = new CartService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("list".equals(action)) { User user = (User) req.getSession().getAttribute("loginUser"); req.setAttribute("items", cartService.listByUser(user.getId())); // 转发到 WEB-INF 下的页面,浏览器地址栏不变 req.getRequestDispatcher("/WEB-INF/jsp/cart.jsp").forward(req, resp); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("add".equals(action)) { User user = (User) req.getSession().getAttribute("loginUser"); int bookId = Integer.parseInt(req.getParameter("bookId")); int qty = parseIntOrDefault(req.getParameter("quantity"), 1); // 前端 max 属性可以绕过,服务端必须再收敛一次 qty = Math.max(1, Math.min(qty, 99)); cartService.add(user.getId(), bookId, qty); // PRG 模式:写完就重定向,刷新页面不会重复加购 resp.sendRedirect(req.getContextPath() + "/cart?action=list"); } } private int parseIntOrDefault(String raw, int def) { try { return Integer.parseInt(raw); } catch (Exception e) { return def; } } }

这段代码里有三个容易被忽略的点。action用隐藏域区分读写,一个 Servlet 管一个模块,比每个操作写一个 Servlet 更好维护;数量在服务端用Math.min卡在 99,因为前端输入框的 max 属性改一下就能提交 9999,库存校验会直接失效;写操作一律走重定向而不是转发,否则用户按 F5 就会重复提交订单。这三个习惯在答辩时被问到「你怎么防止重复提交」,就是现成的答案。

3. 数据库设计:七张表与库存金额的字段口径

3.1 表清单与职责划分

书店的数据模型不复杂,但每张表该放什么、不该放什么,直接决定后面代码好不好写。这套方案用了七张表,其中 admin_log 是可选项,加上去答辩时能多讲一个「后台操作可追溯」的点。

表名职责关键字段设计说明
user用户与管理员共用id、username、password、rolerole=0 普通用户,1 管理员
category图书分类id、name、sort后台维护,前台按 sort 排序
book图书主数据id、title、price、stock、category_id、statusstatus 控制上架与下架
cart购物车id、user_id、book_id、quantity用户与图书建唯一索引
orders订单主表id、order_no、user_id、total_amount、statusorder_no 唯一
order_item订单明细id、order_id、book_id、book_title、price、quantity书名与单价存快照
admin_log后台操作日志id、admin_id、action、target_id、create_time可选,记录上下架与发货

购物车我选择落库而不是放 session,这是取舍题。放 session 的版本代码最少,关掉浏览器就没了;落库的版本多两张 SQL,但换设备登录购物车还在,后台也能看到加购数据做运营分析。答辩时被问「为什么不用 session 存购物车」,直接答「因为购物车是用户资产,会话结束不该丢」,这个理由是站得住的。

3.2 建表 SQL:唯一索引与查询索引

核心四张表的建表语句,其余三张结构类似:

CREATE TABLE `book` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(120) NOT NULL, `author` VARCHAR(60) NOT NULL, `publisher` VARCHAR(80) DEFAULT NULL, `isbn` VARCHAR(20) DEFAULT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `category_id` INT UNSIGNED NOT NULL, `cover` VARCHAR(200) DEFAULT NULL, `description` TEXT, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 上架 0 下架', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_title` (`title`), CONSTRAINT `chk_stock` CHECK (`stock` >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `cart` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL, `book_id` INT UNSIGNED NOT NULL, `quantity` INT NOT NULL DEFAULT 1, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_book` (`user_id`,`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` INT UNSIGNED NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3已完成 4已取消', `receiver` VARCHAR(40) NOT NULL, `phone` VARCHAR(20) NOT NULL, `address` VARCHAR(200) NOT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_time` (`user_id`,`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_id` BIGINT UNSIGNED NOT NULL, `book_id` INT UNSIGNED NOT NULL, `book_title` VARCHAR(120) NOT NULL COMMENT '下单时的书名快照', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时的单价快照', `quantity` INT NOT NULL, `subtotal` DECIMAL(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

索引不是越多越好,每一个都要对应一条真实查询。idx_category支撑分类浏览,idx_title支撑按书名模糊查,idx_user_time让「我的订单」按时间倒序不用额外排序,uk_user_book是购物车合并写入的最后一道保险,没有它,同一用户同一本书可能被并发插进两行。uk_order_no保证订单号不重复,这是后面排查「为什么下单报唯一键冲突」的锚点。

这里有一处要提醒自己:CHECK 约束在部分 MySQL 版本里只被解析、不被真正执行,别把「库存不会变负」全部押在它身上,真正的防线是下一节的条件更新语句。

3.3 金额用 decimal,库存用条件更新

金额字段用DECIMAL(10,2)而不是 double,理由不是洁癖。double 做 0.1 加 0.2 会得到 0.30000000000000004,购物车里三本书的金额加起来和订单总额差一分钱,对账页面就会飘红。DECIMAL(10,2)最大能存 99999999.99,单价和总价都够用,Java 侧对应BigDecimal,加减乘都用它自带的方法,别先转成 double 再转回来。

库存扣减有两种写法,差别很大。一种是先SELECT stock FROM book WHERE id = ?,在 Java 里判断够不够,再UPDATE book SET stock = stock - ?,两条语句中间有窗口,两个用户同时下单就能把库存扣成负数。另一种是条件更新:

UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ? AND status = 1;

执行完看返回的影响行数,等于 0 就说明库存不够或者书已下架,直接抛异常让事务回滚。这套写法把「判断」和「扣减」压进同一条语句,靠数据库的行锁保证原子性,课设并发量再低也建议这么写,答辩问「库存怎么防超卖」,这就是标准答案。

订单号用一个小工具生成,别用自增 id 直接当订单号,用户能从中推出你一天卖了多少单:

public static String generate(int userId) { // 年月日时分秒 + 四位用户号 + 三位随机数,共 21 位 String ts = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); return ts + String.format("%04d", userId % 10000) + String.format("%03d", ThreadLocalRandom.current().nextInt(1000)); }

varchar(32)留了余量,以后要换成「日期 + 号段」也不用改表。ThreadLocalRandom比Random少一层线程竞争,虽然在课设的量级下差别可以忽略,但写出来是加分项。

4. 核心模块实现:登录会话、购物车合并、下单事务

4.1 登录与权限过滤:Session 重建加 Filter 拦截

登录这块最容易写错的两个地方,一个是密码明文入库,一个是登录成功后没换 session。密码至少做一次摘要,别在 user 表里躺着 123456:

public static String md5(String raw) { try { MessageDigest md = MessageDigest.getInstance("MD5"); // 固定盐只是课设简化版,真实项目应给每个用户生成随机盐 byte[] digest = md.digest((raw + "bookstore").getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) sb.append(String.format("%02x", b)); return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }

MD5 在今天已经不适合做密码存储,这点要能讲清楚:它算得快,彩虹表一查就出。课设层面做到「不明文、有盐、登录时比对摘要」就够,答辩被追问时答「生产环境应该用 BCrypt 这类慢哈希,每个用户独立盐」,比硬撑着说 MD5 安全要好。

LoginServlet 的关键动作是登录成功后销毁旧会话、重新建一个:

@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); if (isBlank(username) || isBlank(password)) { req.setAttribute("msg", "用户名或密码不能为空"); req.getRequestDispatcher("/WEB-INF/jsp/login.jsp").forward(req, resp); return; } User user = userService.login(username.trim(), MD5Util.md5(password)); if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/WEB-INF/jsp/login.jsp").forward(req, resp); return; } // 换一个新 session,防止会话固定 req.getSession().invalidate(); req.getSession(true).setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/book?action=list"); } private boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } }

登录失败用 forward 回登录页,这样msg能显示出来;登录成功用 redirect,地址栏变成图书列表,用户刷新不会重复提交账号密码。invalidate再新建这一步很多课设会漏,攻击者可以先拿到一个匿名 session,再诱导用户用这个 session 登录,登录后就共用了身份。

光有登录不够,还得有拦截。过滤器负责把未登录的请求挡在门外:

@WebFilter(urlPatterns = {"/cart", "/order", "/user/*", "/admin/*"}) public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); String path = req.getRequestURI().substring(req.getContextPath().length()); // 后台和前台分开判断,普通用户不能进 /admin if (path.startsWith("/admin")) { if (session == null || session.getAttribute("adminUser") == null) { resp.sendRedirect(req.getContextPath() + "/admin/login.jsp"); return; } } else if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

getSession(false)表示没有就返回 null,不主动创建,避免每个匿名请求都生成一个空会话。路径判断用startsWith("/admin")而不是 equals,是因为后台有多个 Servlet 都要覆盖。这个过滤器只拦 Servlet 路径,如果 JSP 还放在 webapp 根目录下,用户直接敲/admin/bookList.jsp照样能进,所以 2.2 里把页面挪进 WEB-INF 不是可选项。

4.2 购物车合并:唯一索引兜底的写入

购物车的写入逻辑是「同一用户同一本书只保留一行,数量累加」,先查再决定插还是更新:

public void add(int userId, int bookId, int quantity) throws SQLException { String selectSql = "SELECT id, quantity FROM cart WHERE user_id = ? AND book_id = ?"; String insertSql = "INSERT INTO cart(user_id, book_id, quantity) VALUES(?, ?, ?)"; String updateSql = "UPDATE cart SET quantity = ? WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(selectSql)) { ps.setInt(1, userId); ps.setInt(2, bookId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { // 累加也要卡上限,和 Servlet 里的收敛保持一致 int newQty = Math.min(rs.getInt("quantity") + quantity, 99); try (PreparedStatement up = conn.prepareStatement(updateSql)) { up.setInt(1, newQty); up.setInt(2, rs.getInt("id")); up.executeUpdate(); } } else { try (PreparedStatement in = conn.prepareStatement(insertSql)) { in.setInt(1, userId); in.setInt(2, bookId); in.setInt(3, quantity); in.executeUpdate(); } } } } }

try-with-resources保证了即使中途抛异常,连接和语句也会关闭,比手写 finally 干净。参数口径很明确:userId 来自 session 而不是表单,bookId 和 quantity 来自请求并已在 Servlet 侧做过类型与范围校验,DAO 不再重复校验。

这段代码还有一个没写出来的分支:两个请求同时判断「不存在」,然后都去 insert,唯一索引会让第二个抛SQLIntegrityConstraintViolationException。处理方式是在 Service 层捕获这个异常后重试一次,第二次必然走 update 分支。课设环境很难复现,但要知道唯一索引在这里是兜底而不是装饰,答辩时主动提一句,比被问出来强。

4.3 下单事务:同一个 Connection 上的四步提交

下单是整个系统里唯一必须用事务的地方,四件事要么全成要么全败:逐本扣库存、写订单主表、写订单明细、清空购物

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询