简介:这是一份面向JavaWeb初学者与高校课程设计学生的完整在线购书系统实战项目,采用JSP+Servlet+MySQL实现MVC三层架构,覆盖用户注册登录、图书浏览、购物车管理、订单生成与后台图书管理等核心电商功能,助力期末大作业高效交付与高分呈现。资源包共75个文件,含14个JSP页面(前端交互)、12个Java类(Controller与DAO逻辑)、12个Class字节码(编译验证)、3个SQL脚本(数据库建表与初始化)、1个MP4项目演示视频及README说明文档,辅以CSS样式与图片资源,整体压缩后47.77MB,结构规范、注释详尽,新手可快速部署运行。已有349人学习下载,配套视频直观展示系统全流程操作,源码按MVC分层组织清晰,数据库脚本开箱即用,特别适合零基础学生理解Web开发全流程与工程化实践。
1. 这不是“抄个JSP页面就能交差”的期末作业:一个真正跑得通、改得动、能 debug 的在线购书系统,为什么必须用 JSP+Servlet+MySQL 搭 MVC 三层架构?
你手头那份被标为“满分项目”的《在线购书系统》源码,如果只是把 index.jsp 放 Tomcat 里一扔就弹出首页,那它连及格线都没摸到——真正的 JavaWeb 期末大作业,核心不在“能显示”,而在“能拆解、能修改、能定位问题”。我带过 7 届学生做 JavaWeb 课设,90% 的翻车现场都发生在:点击“加入购物车”报 500 错误却找不到是 Servlet 没映射、DAO 层 SQL 写错、还是 MySQL 连接池爆了;改个用户登录逻辑,结果订单页直接 404,因为没理清 Controller 转发路径和 JSP 页面跳转的耦合关系。这个项目之所以被反复标注“满分”,恰恰因为它用最朴素的 JSP+Servlet+MySQL 组合,把 MVC 三层(表现层 JSP、控制层 Servlet、数据层 JDBC+DAO)的职责边界划得清清楚楚:JSP 只负责渲染,不写业务逻辑;Servlet 只做请求分发和参数校验,不碰 SQL;MySQL 表结构设计预留了扩展字段(比如book.is_deleted和user.last_login_time),不是为应付检查,而是为后续加“软删除”或“登录统计”留活口。它适合两类人:一是刚学完 Servlet 生命周期、还没碰 Spring 的大三学生,需要一个能亲手打断点、看 request.getParameter() 值、单步进 DAO 方法的“黑匣子”;二是想验证自己是否真懂 JavaWeb 底层通信机制的自学者——当你在浏览器地址栏敲下http://localhost:8080/bookstore/login?username=admin&password=123,你能清晰说出 Tomcat 如何把 URL 解析成 Servlet 映射、如何调用doPost()、如何通过RequestDispatcher转发到 success.jsp,而不是依赖 IDEA 自动配置的@WebServlet("/login")注解。这不是怀旧,是夯实地基。
2. 从零启动:用最简环境跑通“用户登录-查看图书-下单”主链路
2.1 环境准备:拒绝“一键安装包”,手动配 JDK+Tomcat+MySQL 才能看清每一步依赖
很多同学卡在第一步:IDEA 里新建 Dynamic Web Project 后,右键 Run on Server 报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。这不是 jar 包没导对,而是根本没搞清类加载路径。正确做法是手动配,不依赖 IDE 自动补全:
# 1. JDK 8u291(必须!JSP EL 表达式在 JDK 11+ 有兼容性问题) $ java -version java version "1.8.0_291" # 2. Tomcat 9.0.83(Tomcat 10+ 默认用 Jakarta EE 命名空间,JSP 标签库会报错) $ cd /opt/tomcat/bin $ ./startup.sh # Linux/macOS;Windows 用 startup.bat # 3. MySQL 5.7.44(注意:MySQL 8.0 默认启用 caching_sha2_password 认证插件,JDBC 8.0+ 驱动才支持,但本项目用的是 mysql-connector-java-5.1.47.jar) $ mysql -u root -p mysql> CREATE DATABASE bookstore CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; mysql> CREATE USER 'bookuser'@'localhost' IDENTIFIED BY 'bookpass123'; mysql> GRANT ALL PRIVILEGES ON bookstore.* TO 'bookuser'@'localhost'; mysql> FLUSH PRIVILEGES;提示:
mysql-connector-java-5.1.47.jar必须放在WEB-INF/lib/目录下,不能只丢在 IDEA 的 Libraries 里。Tomcat 启动时只扫描WEB-INF/lib下的 jar,这是 ClassLoader 的双亲委派机制决定的——你改了驱动版本却没更新 lib 目录,重启 Tomcat 也无效。
2.2 数据库初始化:执行建表脚本前,先确认字符集和引擎
项目给的bookstore.sql脚本常被直接source bookstore.sql导入,结果中文乱码或外键失效。必须先检查脚本头部声明:
-- bookstore.sql 开头必须有这两行,否则 utf8mb4 不生效 SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; CREATE TABLE `users` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt 加密后的密码', `email` varchar(100) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 关键:ENGINE=InnoDB(支持事务和外键),不是 MyISAM -- CHARSET=utf8mb4(支持 emoji 和生僻字),不是 utf8(MySQL 中的 utf8 实际是 utf8mb3)导入后立刻验证:
mysql> USE bookstore; mysql> SHOW CREATE TABLE users\G # 输出中必须看到 ENGINE=InnoDB 和 DEFAULT CHARSET=utf8mb4 mysql> SELECT @@character_set_database, @@collation_database; # 必须返回 utf8mb4 和 utf8mb4_unicode_ci2.3 项目结构还原:MVC 三层不是目录名,是代码职责的物理隔离
把源码解压后,别急着 run。先打开src/目录,确认三层结构是否真实存在:
src/ ├── controller/ # Servlet 类,命名如 LoginServlet.java,只含 doGet/doPost 和 request.setAttribute() ├── service/ # 业务逻辑,如 UserServiceImpl.java,调用 DAO,处理密码加密、库存校验等 ├── dao/ # 数据访问,如 BookDao.java,只写 JDBC 操作,SQL 字符串硬编码或用 properties ├── entity/ # POJO,如 User.java,字段与数据库表严格一一对应,含 getter/setter └── util/ # 工具类,如 DBUtil.java(封装 Connection 获取和关闭)、BCryptUtil.java(密码加盐)关键区别:如果你在
controller/LoginServlet.java里看到Connection conn = DriverManager.getConnection(...)或PreparedStatement ps = conn.prepareStatement("UPDATE users SET ..."),这就是典型的 MVC 失败——DAO 层被绕过了。正确的流程是:Servlet → Service → DAO → DBUtil。
2.4 最小可运行链路:只启动登录功能,屏蔽其他模块干扰
先删掉所有无关文件,只保留:
web/login.jsp(登录表单,action="/login")src/controller/LoginServlet.java(处理/login请求)src/service/UserService.java和src/service/impl/UserServiceImpl.javasrc/dao/UserDao.java和src/dao/impl/UserDaoImpl.javasrc/entity/User.javasrc/util/DBUtil.java
然后在LoginServlet.java的doPost()里打个断点,启动 Tomcat,用 curl 测试:
curl -X POST "http://localhost:8080/bookstore/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=admin" -d "password=123"观察控制台输出:是否进入doPost()?request.getParameter("username")是否拿到值?userService.login()返回 true 还是 false?只有这一步通了,再加“查图书列表”和“下单”功能才有意义。别一上来就跑整个系统——那是生产环境思维,不是调试思维。
3. MVC 三层落地细节:JSP 不写 Java 代码、Servlet 不拼 SQL、DAO 不管业务逻辑
3.1 JSP 层:EL 表达式和 JSTL 是底线,scriptlet(<% %>)是红线
index.jsp里如果出现:
<% List<Book> books = (List<Book>) request.getAttribute("bookList"); for(Book b : books) { %> <div><%= b.getTitle() %></div> <% } %>这就是灾难。正确写法必须用 EL + JSTL:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!-- 引入 JSTL 核心标签库 --> <c:forEach items="${bookList}" var="book"> <div>${book.title}</div> <div>¥${book.price}</div> </c:forEach>为什么必须这样?
- EL
${book.title}会自动调用getTile(),且对 null 安全(不会抛 NullPointerException);而<%= b.getTitle() %>在 b 为 null 时直接 500 错误。- JSTL
<c:forEach>是编译期检查,IDEA 能提示bookList是否存在;scriptlet 是运行时执行,错语法只能等页面报错。- 更重要的是:JSP 一旦混入 Java 代码,就彻底违背了“表现层只负责展示”的 MVC 原则——你改个价格显示格式,就得动 Java 逻辑,耦合度爆炸。
3.2 Servlet 层:请求分发是核心,业务校验是边界
LoginServlet.java的doPost()必须长这样:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取参数(职责:解析 HTTP 请求) String username = request.getParameter("username"); String password = request.getParameter("password"); // 2. 基础校验(职责:参数合法性,非业务逻辑!) if (username == null || username.trim().isEmpty() || password == null || password.length() < 6) { request.setAttribute("errorMsg", "用户名或密码格式错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; // 必须 return!否则继续执行 } // 3. 交给 Service 处理(职责:仅分发,不碰业务规则) UserService userService = new UserServiceImpl(); User user = userService.login(username, password); // 这里才校验密码是否正确、账号是否禁用 if (user != null) { // 登录成功:存 session,重定向到首页 request.getSession().setAttribute("currentUser", user); response.sendRedirect(request.getContextPath() + "/index.jsp"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }血泪经验:
return语句不能少!没有它,forward 之后还会执行后续代码,导致IllegalStateException: Cannot forward after response has been committed。response.sendRedirect()用于登录成功(避免刷新重复提交),request.getRequestDispatcher().forward()用于登录失败(保持 URL 不变,方便用户重填)。request.getContextPath()是动态获取应用上下文路径(如/bookstore),硬编码/index.jsp在部署到非根路径时必挂。
3.3 DAO 层:JDBC 模板化封装,SQL 与 Java 严格分离
BookDaoImpl.java不能写:
public List<Book> findAll() throws SQLException { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<Book> list = new ArrayList<>(); try { conn = DBUtil.getConnection(); ps = conn.prepareStatement("SELECT * FROM books WHERE status=1 ORDER BY create_time DESC"); rs = ps.executeQuery(); while(rs.next()) { Book b = new Book(); b.setId(rs.getInt("id")); b.setTitle(rs.getString("title")); // ... 20 行 setXXX() list.add(b); } } finally { // 必须关闭资源!顺序:rs → ps → conn if(rs != null) rs.close(); if(ps != null) ps.close(); if(conn != null) conn.close(); } return list; }正确做法是抽象模板方法:
// src/dao/BaseDao.java public abstract class BaseDao<T> { public List<T> query(String sql, RowMapper<T> rowMapper, Object... args) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<T> list = new ArrayList<>(); try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql); // 参数绑定,避免 SQL 注入 for(int i = 0; i < args.length; i++) { ps.setObject(i + 1, args[i]); } rs = ps.executeQuery(); while(rs.next()) { list.add(rowMapper.mapRow(rs)); // 由子类实现映射逻辑 } } catch (SQLException e) { throw new RuntimeException(e); } finally { DBUtil.close(rs, ps, conn); // 封装统一关闭逻辑 } return list; } } // src/dao/impl/BookDaoImpl.java public class BookDaoImpl extends BaseDao<Book> implements BookDao { @Override public List<Book> findAll() { String sql = "SELECT * FROM books WHERE status = ? ORDER BY create_time DESC"; return query(sql, new BookRowMapper(), 1); // status=1 表示上架 } // 内部类实现映射,彻底解耦 SQL 和 Java 对象 private static class BookRowMapper implements RowMapper<Book> { @Override public Book mapRow(ResultSet rs) throws SQLException { Book b = new Book(); b.setId(rs.getInt("id")); b.setTitle(rs.getString("title")); b.setAuthor(rs.getString("author")); b.setPrice(rs.getBigDecimal("price")); b.setStock(rs.getInt("stock")); return b; } } }参数说明:
RowMapper<T>是函数式接口,让 DAO 只关注“怎么取数据”,不关心“取什么数据”——BookRowMapper专属于 Book,UserRowMapper专属于 User。query(sql, rowMapper, args...)中的args...是可变参数,对应?占位符,天然防 SQL 注入。DBUtil.close()必须按rs → ps → conn顺序关闭,反序会导致SQLException: Operation not allowed after ResultSet closed。
4. 避坑指南:那些让“满分项目”变成“0 分答辩”的 5 个致命细节
4.1 现象:登录成功后,点击“我的订单”报 404,URL 显示http://localhost:8080/bookstore/order/list
原因:Servlet 映射路径写错,或web.xml中<servlet-mapping>的<url-pattern>与前端 form action 不一致。常见错误是OrderListServlet映射为/order/list,但 JSP 表单写action="order/list"(缺前导/),导致相对路径解析成http://localhost:8080/bookstore/order/order/list。
解决:统一用绝对路径。Servlet 映射保持/order/list,JSP 中写action="${pageContext.request.contextPath}/order/list"。永远不要信浏览器地址栏的当前路径。
4.2 现象:添加购物车后,数据库cart_items表里book_id是 0,quantity是 1,但商品实际存在
原因:前端传参时,<input type="hidden" name="bookId" value="<%=book.getId()%>">中book.getId()返回 null(因为book对象没正确 set 进 request),JSP 渲染成<input name="bookId" value="">,Servlet 用Integer.parseInt(request.getParameter("bookId"))得到NumberFormatException,捕获异常后默认赋值 0。
解决:在 Servlet 获取参数后立即校验:
String bookIdStr = request.getParameter("bookId"); if (bookIdStr == null || bookIdStr.trim().isEmpty()) { request.setAttribute("errorMsg", "商品ID缺失"); request.getRequestDispatcher("/book/detail.jsp?id=" + id).forward(request, response); return; } int bookId = Integer.parseInt(bookIdStr); // 此时才 parse4.3 现象:MySQL 中users.password字段存的是明文123,不是 BCrypt 加密字符串
原因:注册时调用了new BCryptPasswordEncoder().encode("123"),但 DAO 层插入 SQL 写成了"INSERT INTO users (username, password) VALUES (?, '123')"——硬编码了'123',没用?占位符。
解决:所有 SQL 必须用PreparedStatement,密码字段必须是ps.setString(2, encodedPassword)。检查UserDaoImpl.register()方法,确认password参数是第几个?。
4.4 现象:Tomcat 控制台狂刷WARNING: An illegal reflective access operation has occurred,但功能正常
原因:JDK 9+ 的模块化机制限制了反射访问,而mysql-connector-java-5.1.47.jar内部用了sun.misc.Unsafe。这不是错误,是警告,不影响运行。
解决:升级驱动到mysql-connector-java-8.0.33.jar(需同步升级 MySQL 到 8.0+ 并配置useSSL=false&serverTimezone=Asia/Shanghai),或忽略警告(JavaWeb 期末作业允许忽略此警告)。
4.5 现象:在 IDEA 中 Debug,断点打在LoginServlet.doPost()里,但请求发过去却不命中
原因:IDEA 的 Tomcat 配置里,“On ‘Update’ action” 和 “On frame deactivation” 选项选了 “Update classes and resources”,但项目没勾选 “Build project before run”。
解决:
File → Project Structure → Project:确认 Project SDK 是 JDK 1.8,Project language level 是 8。Run → Edit Configurations → Tomcat Server → Deployment:确保bookstore:war exploded已添加,Application context 是/bookstore。Run → Edit Configurations → Tomcat Server → Server:勾选 “Build project before run”。- 右键项目 →
Reload project,再 Clean Tomcat work directory(/tomcat/work/Catalina/localhost/bookstore)。
5. 进阶验证:用 Postman + 日志 + 数据库快照,三步锁定真实瓶颈
5.1 用 Postman 替代浏览器,排除前端干扰
浏览器会自动携带 Cookie、缓存 GET 请求、重定向跳转,掩盖真实问题。必须用 Postman 发原始请求:
POST http://localhost:8080/bookstore/login
Body → x-www-form-urlencoded:username=admin,password=123- 查看 Response Headers:确认
Set-Cookie: JSESSIONID=xxx是否存在,Location是否指向/index.jsp。 - 查看 Response Body:如果是 JSP 页面 HTML,说明转发成功;如果是空白或 500,说明 Servlet 逻辑异常。
技巧:在 Postman 的 Tests 标签页写 JS 断言:
// 验证登录成功后返回 302 重定向 pm.test("Status code is 302", function () { pm.response.to.have.status(302); }); // 验证重定向 URL 包含 index.jsp pm.test("Redirect to index.jsp", function () { pm.expect(pm.response.headers.get("Location")).to.include("index.jsp"); });5.2 在关键节点加日志,比 Debug 更高效
log4j.properties配置太重,期末作业用最简System.out.println:
// 在 LoginServlet.doPost() 开头 System.out.println("[LoginServlet] Start login process, username=" + username); // 在 UserServiceImpl.login() 返回前 System.out.println("[UserServiceImpl] login result for " + username + ": " + (user != null)); // 在 UserDaoImpl.findByUsername() 执行后 System.out.println("[UserDaoImpl] Found user: " + user);为什么比 Debug 快?
- Debug 需要启停、断点、单步,一次登录要 2 分钟;日志一行打印,1 秒定位。
- 日志能暴露异步问题:比如
System.out.println("Before update")和System.out.println("After update")之间隔了 5 秒,说明数据库 update 卡住了,立刻去查 MySQLSHOW PROCESSLIST。
5.3 数据库快照对比:确认 DAO 层是否真的执行了 SQL
登录成功后,立刻在 MySQL 命令行执行:
-- 查看 users 表最后登录时间是否更新 SELECT username, last_login_time FROM users WHERE username='admin'; -- 查看 cart_items 表是否有新记录(测试加购) SELECT * FROM cart_items WHERE user_id = (SELECT id FROM users WHERE username='admin') ORDER BY create_time DESC LIMIT 5; -- 查看订单表,确认下单事务是否完整 SELECT o.id, o.total_amount, o.status, GROUP_CONCAT(oi.book_title) as books FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id WHERE o.user_id = (SELECT id FROM users WHERE username='admin') GROUP BY o.id;关键技巧:把每次操作前后的 SQL 结果截图存档。比如“点击下单前”和“点击下单后”的
orders表对比,能一眼看出是INSERT INTO orders没执行,还是INSERT INTO order_items失败导致事务回滚——这比看 Java 异常堆栈快 10 倍。
5.4 一个真实教训:我曾花 3 小时找 bug,最后发现是web.xml里<welcome-file-list>写成了<welcome-file>index.html</welcome-file>,而项目只有index.jsp
那天我反复检查LoginServlet的映射、web.xml的 servlet 配置、甚至重装了 Tomcat,直到灵光一闪:http://localhost:8080/bookstore/访问时,Tomcat 默认去找index.html,404 后才轮到index.jsp。我把<welcome-file>改成<welcome-file>index.jsp</welcome-file>,问题消失。
所以现在我养成了习惯:任何“页面打不开”的问题,第一件事就是 curl-I看响应头:
curl -I http://localhost:8080/bookstore/ # 如果返回 HTTP/1.1 404 Not Found,说明 welcome-file 配置错了 # 如果返回 HTTP/1.1 200 OK,说明页面找到了,问题在 JSP 渲染或 Servlet 逻辑希望帮到你。
本文还有配套的精品资源,点击获取