简介javaweb期末大作业在线购书系统基于JSPServletMySQL采用MVC三层架构面向期末大作业、课程设计及需要完整项目参考的Java学习者。代码注释拉满覆盖图书展示、购物车、订单管理等核心功能新手也能看懂。压缩包共75个文件大小47.77MB内含16张系统截图、14个JSP页面、12个Java类、数据库SQL脚本、CSS样式配置以及一个MP4演示视频和README说明从页面表现到后台逻辑均有对应文件便于按MVC分层研读。已有347人学习下载。项目功能完善、界面美观只需简单部署即可使用附带的演示视频可帮助快速了解系统效果适合答辩展示或直接作为高分课程设计模板能大幅节省开发与调试时间。1. 选「在线购书系统」做 javaweb 期末大作业不是因为它简单「javaweb 期末大作业」列表里在线购书系统是出现频率最高的题目原因不是它简单而是它天然覆盖了 jspServletMySQL 的每一个必考点商品列表、搜索分页、注册登录、购物车、下单扣库存外加一个完整的 MVC 三层架构要求。市面上流通的「源码数据库」包大多能跑但验收时老师最常问的是「购物车为什么放 Session、下单为什么用事务、三层架构到底哪三层」这些靠背代码答不出来得把架构讲清楚。这篇文章按「分层怎么分、代码怎么落、坑在哪」的顺序把整套方案过成你可以对着源码复述的版本。适合期末冲刺的学生也适合要把第一个项目讲成加分项的初级开发。2. MVC 三层架构划分jspServletMySQL 项目的包结构设计老师让用 MVC 架构不等于把代码塞进 jsp、servlet 和普通 java 三个目录就完事。这一章先把概念对齐再给出一套可以直接套用的包结构和表设计后面所有代码都按这个骨架来。2.1 三层架构和 MVC 的对应关系V 是 JSPC 是 ServletM 拆成两层MVC 是设计模式三层架构是工程分层两者套在 javaweb 项目里的对应关系是JSP 充当 ViewServlet 充当 ControllerModel 则拆成 Service 和 DAO 两层。View 只负责用 EL 和 JSTL 展示数据不写 Java 业务代码Controller 只做四件事——收参数、调 Service、拿结果、转发或重定向业务规则写在 ServiceSQL 只出现在 DAO。最常见的扣分点就是 Servlet 里直接写 JDBC那叫两层结构不叫 MVC 三层架构。Entity 必须是规范的 JavaBean私有属性加 getter/setter让 JSP 能用${book.price}取值这就是 javabean 在 MVC 里的实际意义。如果你对 Spring MVC 有了解可以顺带记住后来 DispatcherServlet 就是把「前端控制器统一收请求再分发给处理器」这个模式用框架固化理解了原生 Servlet 的分层迁到 Spring MVC 只是配置问题而不是重新学一遍。2.2 包结构从 com.bookshop 出发的目录规划建议直接按下面的包结构建dao 和 service 都拆接口加实现这是答辩时「三层架构」最直观的呈现方式。src/main/java/com/bookshop ├── entity/ User.java Book.java Category.java CartItem.java Order.java OrderItem.java ├── dao/ UserDao.java BookDao.java OrderDao.java接口 ├── dao/impl/ UserDaoImpl.java BookDaoImpl.java OrderDaoImpl.java ├── service/ UserService.java BookService.java OrderService.java接口 ├── service/impl/ UserServiceImpl.java BookServiceImpl.java OrderServiceImpl.java ├── web/ LoginServlet.java RegisterServlet.java BookServlet.java CartServlet.java OrderServlet.java ├── filter/ EncodingFilter.java LoginFilter.java ├── util/ DBUtil.java PageBean.java └── listener/ SessionListener.java可选 src/main/webapp ├── jsp/ login.jsp register.jsp book_list.jsp cart.jsp order_confirm.jsp ├── static/ css/ js/ images/ └── WEB-INF/ web.xmldao 和 service 为什么要拆接口加实现答辩话术就一句话接口定义行为实现决定数据库方言将来从 MySQL 换 Oracle 只新增一个 impl不动调用方。DAO 只负责单表增删改查跨表的「订单主表订单项扣库存」这类多个写操作放在 Service 里用事务包起来这是分层最关键的边界线。util 里 DBUtil 封装连接和释放PageBean 封装分页参数。如果环境不支持 Maven用 Eclipse 建 Dynamic Web Project把同样的包结构建在 src 下、jar 放 WEB-INF/lib效果完全一致。2.3 数据库表设计6 张表覆盖全部增删改查在线购书系统的表就按下面的 6 张来全部用 InnoDB 引擎字符集用 utf8mb4排序规则选utf8mb4_general_ci或utf8mb4_unicode_ci都行。表名角色关键字段说明user用户表id, username, password, nickname, create_timeusername 加唯一索引category分类表id, name, sort列表页的分类筛选下拉book图书表id, title, author, price, stock, sales, cover, category_idstock 是扣库存的并发重点cart_item购物车表id, user_id, book_id, quantity用 Session 存购物车时可不建orders订单主表id, order_no, user_id, total_amount, status, create_timeorder_no 加唯一索引order_item订单项表id, order_id, book_id, book_title, price, quantity冗余 title/price 是订单快照设计要点有三个。第一主键都用自增 id不携带任何业务意义。第二order_item 里冗余 book_title 和 price图书价格以后会调但订单里的成交价和历史书名不能跟着变「订单快照」这个概念答辩时非常加分。第三orders.status 用 tinyint 不用字符串0 待支付、1 已支付、2 已发货、3 已完成、4 已取消枚举值在 Service 层用常量类维护后面做统计 SQL 也方便。2.4 环境与连接MySQL 8.0 驱动、时区和字符集先写工具类 DBUtil它是整个项目连接 MySQL 的唯一入口package com.bookshop.util; import java.sql.*; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/bookshop ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD 你的密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 按 rs - stmt - conn 的顺序释放调用方传 null 也能安全跳过 } }URL 里四个参数各管一件事useUnicode加characterEncodingutf8管中文写入serverTimezoneAsia/Shanghai治「server time zone 时区错误」useSSLfalse去掉 SSL 握手警告allowPublicKeyRetrievaltrue解决 MySQL 8.0 用 root 连接时caching_sha2_password的认证报错。MySQL 8.0 的驱动类名是com.mysql.cj.jdbc.Driver老教程里的com.mysql.jdbc.Driver在新驱动上会有兼容警告。最常见的ClassNotFoundException: com.mysql.cj.jdbc.Driver基本就是驱动 jar 没放进 WEB-INF/lib 或者放进了 Tomcat 的 lib。提示Tomcat 8.5 或 9 配 JDK 8 是 javaweb 课程最稳的组合。误用 Tomcat 10 时项目里import javax.servlet.*会全部报找不到类因为 Tomcat 10 把包名改成了jakarta.servlet。这是换环境后最隐蔽的坑优先排查。3. 登录、购物车和分页三个必考功能的 Servlet 实现在线购书系统演示时最常被点名的三个功能登录注册、购物车、分页搜索。这一章把它们的 Servlet 实现和容易答错的细节都过一遍。3.1 Servlet 映射用注解还是 web.xmlWebServlet(/login)注解从 Servlet 3.0 开始支持代码量少现在课程都认可。但要知道它和 web.xml 的关系注解在容器启动时扫描注册web.xml 显式声明两者等价如果两边都配且路径冲突web.xml 会覆盖注解。答辩被问「一个请求是怎么走到你这个类的」标准回答是浏览器发/book/list容器按 context path 定位项目查 Servlet 映射表命中后调用对应 Servlet 的 service 方法。顺带把 Servlet 生命周期说清楚构造、init 执行一次、service 循环调用、destroy 在容器关闭时执行。实例是单例多线程的所以 Servlet 里别写共享的可变成员变量这是经典的面试扣分点。用 Eclipse 创建基于 Maven 的 servlet 项目时web.xml 结构可以直接改两种写法在课程范围内都算标准。3.2 登录校验与 Session 传递forward 和 redirect 别用反登录是整个系统的状态入口代码写好后要能解释清楚每一行的去向WebServlet(/login) public class LoginServlet extends HttpServlet { private UserService userService new UserServiceImpl(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); User user userService.login(username, password); if (user null) { req.setAttribute(msg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } req.getSession().setAttribute(loginUser, user); resp.sendRedirect(req.getContextPath() /book/list); } }失败时用 forward 转发这是同一次请求login.jsp 里${msg}能直接显示错误信息地址栏不变成功时必须 sendRedirect因为转发到首页后用户刷新会重放一遍提交表单的动作造成重复登录甚至重复下单。密码字段在数据库里存的是摘要而不是明文注册时对密码做 MD5登录时同样摘要后比对。期末项目写 MD5 可以但要主动说出它的局限——没有加盐、撞库容易生产环境会用 BCrypt这比假装它安全要诚实得多。登录成功后 user 对象放 Session后续所有「判断是否登录」都取req.getSession().getAttribute(loginUser)为后面的 Filter 统一校验做铺垫。3.3 购物车用 Session 还是数据库表两种方案都能过。表驱动 cart_item 的好处是换设备不丢坏处是要为「加购、改数量、删除、结算」四个动作写 CRUD还要在订单事务里清购物车。Session 方案把购物车看成「用户浏览会话期间的数据」实现量小、答辩好解释缺点是占服务器内存、不能持久化。期末场景建议用 Session 方案但表照建这样回答「为什么不用数据库存购物车」时才有对比依据。WebServlet(/cart/add) public class CartServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } int bookId Integer.parseInt(req.getParameter(bookId)); Book book new BookServiceImpl().findById(bookId); // 用 Map 存key 是 bookId避免同一本书出现多行 MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMap(); } CartItem item cart.get(bookId); if (item null) { item new CartItem(book, 1); } else { item.setQuantity(item.getQuantity() 1); } cart.put(bookId, item); session.setAttribute(cart, cart); resp.sendRedirect(req.getContextPath() /cart.jsp); } }为什么用 Map 不用 List——O(1) 查找重复加购直接改数量。CartItem 持有 Book 对象和 quantity 两个字段JSP 里${item.book.title}取书名。注意开头要校验登录否则空 Session 直接空指针。JSP 遍历用 JSTLc:forEach items${sessionScope.cart} varentry这里取出来就是 Map 的 entryentry.value.quantity就是数量。3.4 分页查询LIMIT 参数怎么算图书列表和搜索结果必须分页这也是项目里最固定的一道题。两个 SQL一个 count 一个查当前页-- 统计总条数 SELECT COUNT(*) FROM book WHERE title LIKE ? OR author LIKE ?; -- 查当前页数据以第 2 页每页 8 条为例 SELECT * FROM book WHERE title LIKE ? OR author LIKE ? ORDER BY sales DESC LIMIT 8, 8; -- 偏移量 offset (currentPage - 1) * pageSize页码从 1 开始第 1 页就是LIMIT 0, 8第 2 页是LIMIT 8, 8。PreparedStatement 执行时把%三体%作为参数 setString 传进去永远不要字符串拼接 SQL——拼接既会引入 SQL 注入中文条件还容易因为编码不一致查不出来。Java 这边的搜索语句和普通查询只有 WHERE 条件的差别参数化写法完全一致。Service 层返回 PageBeancurrentPage、pageSize、totalCount、totalPage、dataJSP 只显示上一页、下一页和当前第 X/Y 页。能提前答出的一个性能点数据量大时LIMIT 100000, 8会越来越慢更优解是WHERE id 上一页最大 id的游标分页答辩时提一句就显深度。4. 下单事务与库存扣减MySQL 事务和并发防护下单是整个系统里业务复杂度最高的动作也是「满分项目」和普通项目的分水岭。这一章写清楚事务边界、防超卖写法以及订单状态设计。4.1 下单是多个写操作为什么必须包成一个事务下单动作拆开是四个写操作插入 orders 主表、批量插入 order_item、扣减 book.stock、清空购物车。任何一个失败前面的都不能留着否则会出现「订单生成了但库存没扣」或者「库存扣了订单没了」。数据库事务的定义就是要么全部提交要么全部回滚。在 jspServlet 里没有 Spring 帮你管理事务只能手动控制 Connection这正是这个项目比增删改查 demo 值钱的地方。public Order createOrder(Integer userId, MapInteger, CartItem cart) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交事务边界从这里开始 // 1. 插入订单主表并拿到自增主键 String insertOrder INSERT INTO orders(user_id, order_no, total_amount, status) VALUES(?, ?, ?, 0); PreparedStatement ps conn.prepareStatement(insertOrder, Statement.RETURN_GENERATED_KEYS); ps.setInt(1, userId); ps.setString(2, generateOrderNo()); ps.setBigDecimal(3, totalAmount); ps.executeUpdate(); ResultSet keys ps.getGeneratedKeys(); int orderId -1; if (keys.next()) { orderId keys.getInt(1); } // 2. 循环遍历 cart.values()逐条插入 order_itemSQL 略 // 3. 扣库存UPDATE book SET stock stock - ? WHERE id ? AND stock ? // 4. 清空 Session 购物车 conn.commit(); } catch (SQLException e) { if (conn ! null) { conn.rollback(); } throw new RuntimeException(下单失败库存或订单数据已回滚, e); } finally { if (conn ! null) { conn.setAutoCommit(true); // 归还连接前恢复默认避免连接池污染 conn.close(); } } return order; }setAutoCommit(false)是关键一行从这开始所有 SQL 都处于同一个事务commit 和 rollback 二选一。RETURN_GENERATED_KEYS拿自增主键是为了往 order_item 里填 order_id。conn.setAutoCommit(true)写在 finally 里是很多教程忽略的细节如果用连接池连接归还时不恢复原子性状态下一次复用时会出现预期外的自动提交行为。4.2 防止超卖扣库存的 UPDATE 必须带库存条件把扣库存写成「先 SELECT stock 出来Java 里判断 stock 是否够再 UPDATE」是错的两个用户同时读到 stock1都通过判断都执行 UPDATE库存就变成负数。正确写法是把「判断扣减」压缩进一条 UPDATEUPDATE book SET stock stock - ? WHERE id ? AND stock ?;executeUpdate()返回的受影响行数是唯一的真值判断返回 0 说明stock ?不满足直接抛异常回滚并提示「库存不足」。这个写法依赖 InnoDB 的行锁同一行的 UPDATE 天然串行是并发环境下的最小可用方案。MySQL 默认隔离级别是 RRrepeatable readInnoDB 的当前读走的是最新已提交数据所以这里也不会出现更新丢失。老师如果追问要不要悲观锁可以回答单表扣减场景里带条件的 UPDATE 自带锁不需要额外拿锁涉及多表状态联动时才考虑SELECT ... FOR UPDATE。提示MySQL 不允许 UPDATE 目标表和子查询里 FROM 同一张表。比如UPDATE book SET stock 0 WHERE id IN (SELECT id FROM book WHERE stock 0)会直接报错 1093需要包一层派生表SELECT id FROM (SELECT id FROM book WHERE stock 0) t。写库存统计 SQL 时容易踩记一下。4.3 订单状态机与唯一索引两道送分的追问订单状态用下面的枚举值维护状态只能单向流转status 值含义触发动作0待支付下单成功1已支付用户模拟支付2已发货后台发货3已完成用户确认收货4已取消超时或手动取消状态流转用常量类 OrderStatus 维护Service 每个方法先校验「当前状态是否允许变更」比如已发货才能变已完成。order_no 生成规则用时间戳加用户 id 加四位随机数加唯一索引保证不重复。针对用户连点两次提交造成的重复下单页面端禁用按钮只是第一道防线更硬的保障是点亮后查「该用户是否已有同一批图书的待支付订单」。这里也可以补一句MySQL 存储过程能把扣库存和插入订单项封装成单次调用InnoDB 下效果和程序内事务一致属于「非必须但显得熟」的加分点。用 Navicat 或命令行验证回滚后的数据时检查 orders、order_item、book 三张表在抛错后是否同时保持原样。5. 换机演示必测的 3 个场景和 Filter 这个加分项上课跑通和老师验收是两回事演示看功能验收看你在新环境下的鲁棒性。下面 3 个场景是换机演示最常翻车的位置外加 1 个让项目结构显得完整的加分技巧。5.1 换机必测的 3 个场景场景操作预期结果排查定位未登录访问受保护页地址栏直接输入 /cart跳到 login.jsp不报 500LoginFilter 的放行列表重复提交下单下单页连点两次提交orders 表只有一条记录按钮禁用 OrderService 状态校验中文乱码搜索「三体」后看列表不出现 ? 号乱码EncodingFilter、JSP pageEncoding、MySQL 字符集第一项失败说明 LoginFilter 没生效或放行路径写错第二项除了前端禁用硬保障在 Service 层对订单状态做校验第三项要同时查代码和 MySQL 端字符集用 Navicat 看表结构的排序规则是不是 utf8mb4 结尾。另外改完 JSP 不生效时删掉 Tomcat 的work/Catalina/localhost/项目名/org/apache/jsp目录再重启jsp 编译生成的 class 文件就保存在这里这个问题课上被问过无数次。5.2 加分技巧Filter 统一编码和登录校验WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); chain.doFilter(request, response); } }有了它每个 Servlet 里零散的setCharacterEncoding(UTF-8)都可以删掉编码逻辑收进唯一入口。LoginFilter 做登录校验时记得放行/login、/register和 css/js 静态资源否则登录页样式全部丢失。Filter 的执行顺序由 web.xml 中 filter-mapping 的声明顺序决定答辩时主动说出这一点再配合换机演示前清掉 work 目录重启 Tomcat整套验收流程就稳了。本文还有配套的精品资源点击获取