校园二手图书交易系统JavaWeb课设:数据库设计与事务控制要点
简介面向 Java 课程设计的校园二手图书交易系统项目定位为期末大作业全流程参考方案。资源含项目源码与数据库图书发布、检索、下单交易、订单管理等模块均可运行适合需要完成 JavaWeb 课设或快速搭建演示环境的高校学生。包体为 zip 压缩文件共 485 个文件、3.18MB。其中 Java 源文件 92 个、编译后的 class 文件 112 个、XML 配置与映射文件 230 个另有 SQL 数据库脚本、Vue/JS 前端资源、properties 配置与 PNG 图标等文件结构清晰便于按模块查阅和二次改造。已有 192 人学习下载。源码经过本地编译验证评审得分 95 分以上数据库脚本可直接导入使用。通过对照代码与配置文件可快速理解 JavaWeb 分层结构、前后端交互及数据库表设计思路省去从零配置和踩坑时间是课设起步和答辩准备的高性价比资料。1. 校园二手图书交易系统为什么它是JavaWeb课设里的高质量题目课设季的 JavaWeb 选题里十个里头有八个是各类“XX管理系统”校园二手图书交易系统是少有的能把用户、商品、订单、状态流转四个考点装进一个闭环的题目。它的名字听起来不复杂但真正能把“95分以上高分大作业”落地的人往往不是在 Controller 里写得多花哨而是把数据库设计和事务控制做对了。这个标题适合正在做 JSP/Servlet 课设的在校生也适合想找一个完整 JavaWeb 案例把整条链路跑通的初级开发者。下面按数据库设计、核心后端、前端交互、部署避坑、加分细节五个层次展开每一段都按“能直接照着做”的标准来写。2. 数据库设计与分包架构先建表还是先写代码课设答辩现场老师最经常问的第一句话是“你这个系统有哪些角色图书从上架到卖出去经历了哪些状态”这句话回答得流畅数据库设计和后端的骨架就基本过关了。反过来如果支支吾吾半天说不清状态流转后面写得再花哨也救不回来。所以拿到源码包之后第一件事不是打开 IDE 跑起来而是先看 SQL 文件和数据字典。2.1 业务角色与状态流转三张角色一张流程图校园二手图书交易系统里至少要有三类角色学生买家兼卖家、管理员、游客。游客只能浏览图书列表学生可以注册、登录、发布图书、下单购买、管理自己的订单管理员负责图书上下架审核和用户管理。注意学生同时也是卖家所以 user 表和 book 表的关系是一对多而去下单时又要把买家 id 和卖家 id 都落进订单表。图书状态是整个业务闭环的核心。我强烈建议用一个 TINYINT 字段控制状态不要用字符串描述。定义 0 为待审核、1 为在售、2 为已预订、3 为已售出、4 为已下架。首页和列表页默认只查 status1 的图书下单时校验状态是不是 1成功之后改成 2等卖家确认或者买家确认收货再改成 3。这样一套数字状态流转代码里只需要写 if 判断不需要做模糊匹配。在这个基础上注册登录、图书发布、订单管理就是三个标准 CRUD 模块分别对应三张核心表和一张关联表。数据库增删改查的基本功在这个项目里全都用得上这也是老师认可这个题目的原因。角色主要功能对应表游客浏览图书、搜索book、category学生注册、登录、发布图书、下单、管理订单user、book、orders、favorite管理员审核、上架/下架、用户管理user、book2.2 五张表撑起闭环建表 SQL 与字段选型理由学生、图书、分类、订单、收藏这五张表就把校园二手交易的核心闭环撑起来了。下面是这套系统最常见的建表方式字段名和类型都是课设评分点不要随便用 float 存价格更不要把图片字节存进数据库。-- 用户表 CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 加盐MD5后的密码, real_name VARCHAR(20) DEFAULT NULL COMMENT 真实姓名, phone VARCHAR(20) DEFAULT NULL, email VARCHAR(50) DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像相对路径, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;-- 图书表 CREATE TABLE book ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL COMMENT 卖家ID, category_id INT DEFAULT NULL COMMENT 分类ID, title VARCHAR(100) NOT NULL COMMENT 书名, author VARCHAR(50) DEFAULT NULL, publisher VARCHAR(50) DEFAULT NULL, price DECIMAL(10,2) NOT NULL COMMENT 售价, original_price DECIMAL(10,2) DEFAULT NULL COMMENT 原价, description TEXT COMMENT 图书描述, cover_image VARCHAR(255) DEFAULT NULL COMMENT 封面相对路径, status TINYINT DEFAULT 1 COMMENT 0审核 1在售 2已预订 3已售 4下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_category_id (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书表;-- 订单表 CREATE TABLE orders ( id INT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, book_id INT NOT NULL, buyer_id INT NOT NULL COMMENT 买家ID, seller_id INT NOT NULL COMMENT 卖家ID, price DECIMAL(10,2) NOT NULL COMMENT 成交价, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已完成 3已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_buyer_id (buyer_id), KEY idx_seller_id (seller_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;-- 分类表 CREATE TABLE category ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(30) NOT NULL, sort_order INT DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图书分类表; -- 收藏表 CREATE TABLE favorite ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_book (user_id, book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收藏表;为什么价格用 DECIMAL(10,2) 而不是 float因为 float 是近似值两本书 9.9 和 9.89 做减法可能出现 0.009999999 这种结果答辩时老师很爱拿这个点试探。订单号为什么要单独一个 order_no 字段因为订单号需要在业务代码里生成比如时间戳加随机数不能只靠自增主键否则用户能看到系统一共成交了多少单逼格一下就掉了。外键在这个项目里建议用逻辑外键也就是只保留 user_id、book_id 这些字段不写 FOREIGN KEY 约束。原因很现实课设里经常要手动清表、重置数据物理外键会在 DELETE 时把级联关系弄得很难受。当然如果老师明确要求 ER 图里有外键你可以把建表语句里加上 FOREIGN KEY跑通后再注释掉数据库设计文档里保留外键展示。2.3 实体类与 DAO 层下划线转驼峰是第一个翻车点Java 类的字段命名习惯是驼峰数据库列名习惯是下划线。比如数据库里 username 没有歧义但 create_time 在实体类里就该写成 createTime。很多新手在这里翻车JDBC 查询出来以后调 setCreateTime 发现找不到这个 setter报错信息还把完整字段名列出来排查半天才发现是列名映射的问题。黑马 JavaWeb 笔记里也专门把这个点单独拿出来讲可见它有多高频。public class Book { private Integer id; private Integer userId; private Integer categoryId; private String title; private String author; private String publisher; private BigDecimal price; private String description; private String coverImage; private Integer status; private Date createTime; // getter/setter 省略IDE 自动生成 }// 手工写映射时最容易错的一行 Book book new Book(); book.setUserId(rs.getInt(user_id)); book.setCategoryId(rs.getInt(category_id)); book.setCreateTime(rs.getTimestamp(create_time));如果你用的是 MyBatis-Plus整个映射可以靠配置项自动完成甚至能根据实体类注解反向生成建表 SQL但课设场景我不建议完全依赖这个。老师会问一句“你的表结构和实体类是怎么对应的”你要能回答出下划线转驼峰的手工映射过程再打开框架开关演示一次自动映射这个知识点就立体了。纯 JDBC 项目虽然写起来啰嗦但每一行都是面试和答辩的素材。2.4 分包与 MVC 架构在老师问“分层了吗”之前先整好38;源码包拿到手先看它的包结构。一个规范的 JavaWeb 课设应该长这样controller 包放 Servletservice 包放业务接口和 impl 实现类dao 包放数据库访问层entity 包放实体类util 包放工具类filter 包放过滤器。网页放在 webapp 下的 WEB-INF/jsp 目录里静态资源放 webapp 的 static 目录下。分层的理由是业务逻辑和页面展示分离。举个例子下单时候要同时更新图书状态和插入订单记录如果在 Servlet 里直接写 JDBC代码会越写越长后面加一个“发送通知”功能就得改 Servlet。放到 Service 层以后Servlet 只管接收请求和返回结果Service 管事务和业务规则DAO 管 SQL三个人各干各的活。层职责项目内对应controller 层参数接收、请求转发、返回 JSONServletservice 层业务规则、事务边界UserService / BookService / OrderServicedao 层SQL 执行、结果集封装BookDao 等entity 层和表结构对应的 JavaBeanUser、Book、Orders、Category、Favorite课设的量级不需要 Spring但 MVC 分层结构一定要有。我见过不少源码号称高分结果 Servlet 里又是写 SQL 又是拼 HTML 的那种代码再往下改两轮就没人愿意维护了。你把这个分层提到目录层面答辩老师一眼就看到你在学校阶段就具备了工程意识印象分起点就高。3. 核心后端实现登录鉴权、图书发布与订单事务的代码关键点后端是整个项目能不能跑通的核心。源码包只能给你一个能运行的结果真正答辩时你能不能说清楚“登录密码怎么存的”“图片传上去之后放在哪里”“下单时事务怎么控制的”这决定是 85 分还是 95 分。这一章把这三个最关键的点拆开讲每个点都会给出可直接抄的代码骨架。3.1 JDBC 连接连接串参数缺一不可JavaWeb 连接 MySQL 数据库是第一个拦路虎。很多看起来复杂的报错最后都指向连接串上的一个参数。一个能稳定运行的 JDBC 工具类长这样public class JdbcUtil { private static final String URL jdbc:mysql://localhost:3306/book_db ?useUnicodetruecharacterEncodingUTF-8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USERNAME root; private static final String PASSWORD 123456; 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, USERNAME, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 三个参数逐个判空关闭先开的后关 } }逻辑说明加载驱动类放在静态块里保证类被引用时只加载一次。getConnection 每次调用都新建连接课设规模够用如果你愿意用连接池可以把 DriverManager 换成 com.alibaba.druid.pool.DruidDataSource但配置起来要多写几行。参数方面useSSLfalse 是为了关掉 MySQL 8 的 SSL 警告serverTimezoneAsia/Shanghai 是解决中国时区导致的日期差 8 小时问题characterEncodingUTF-8 是解决中文乱码的前提。这三个参数少一个后面都会以很难看的方式暴露出来。如果连接报 ClassNotFoundException先检查驱动 jar 是不是真的在 WEB-INF/lib 目录下而不是只在编译期引入了。如果报 SSL 警告但不影响运行说明 useSSL 没加。如果时间字段查出来总是比实际值少 8 小时serverTimezone 大概率漏了。3.2 登录鉴权加盐 MD5 与 Session 生命周期登录是老师的必问点。最基础的错误是明文存密码答辩时一句“为什么不存明文”就能问倒一片。正确做法是加盐 MD5。虽然 MD5 本身在真实安全场景里不够强但课设里做到加盐已经能解释出“不能被反查”这个逻辑。public class Md5Util { private static final String SALT book_2024_salt; public static String encrypt(String input) { try { String str input SALT; MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(str.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { String hex Integer.toHexString(b 0xff); if (hex.length() 1) { sb.append(0); } sb.append(hex); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }登录 Servlet 的流程是这样的前端 POST 提交 username 和 passwordServlet 把 password 做一次 Md5Util.encrypt 得到密文再调用 UserDao.findByUsername 查用户比对密文是否一致。这里有一个细节不要先查出所有用户再循环比对SQL 里直接 WHERE username ? 只查一条性能和对数据库的理解都会显示在代码里。protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password Md5Util.encrypt(req.getParameter(password)); User user userDao.findByUsername(username); MapString, Object result new HashMap(); if (user ! null user.getPassword().equals(password)) { HttpSession session req.getSession(); session.setAttribute(loginUser, user); result.put(code, 0); result.put(msg, 登录成功); } else { result.put(code, 1); result.put(msg, 用户名或密码错误); } resp.setContentType(application/json;charsetUTF-8); resp.getWriter().print(new Gson().toJson(result)); }逻辑说明为什么密码比对不在 Java 代码里做第二次查询因为数据库只存密文查询用用户名做条件命中后再比对密文是效率和安全兼得的做法。Session 的作用是让浏览器在后续请求里带着登录状态如果登录成功却没写 session那登录模块只完成了一半。Session 默认存活时间是 30 分钟在 web.xml 里可以用 session-config 调整课设保持默认就好。需要注意setContentType 里的 charsetUTF-8 不能是 UTF8 或 utf-8Tomcat 9 以下对这种别名支持有问题统一写完整的 UTF-8 最稳。3.3 图书发布与图片上传路径设计决定后面不哭校园二手图书发布页面不只是文本表单还要传封面图因此表单的 enctype 必须设置成 multipart/form-data然后 Servlet 上用 MultipartConfig 注解限制大小和接收文件。WebServlet(/book/add) MultipartConfig(maxFileSize 1024 * 1024 * 5, maxRequestSize 1024 * 1024 * 10) public class BookAddServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String uploadDir getServletContext().getRealPath(/upload); File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } Part filePart req.getPart(cover); String fileName UUID.randomUUID().toString().replace(-, ) getSuffix(filePart.getSubmittedFileName()); filePart.write(uploadDir File.separator fileName); // 数据库只存相对路径 String coverPath upload/ fileName; // 继续解析其他表单字段并写入 book 表 } private String getSuffix(String fileName) { int dot fileName.lastIndexOf(.); return dot 0 ? fileName.substring(dot) : .jpg; } }这里最关键的认知是数据库永远只存相对路径也就是 upload/文件名而不是 C 盘绝对路径。原因有两层第一服务器磁盘路径一旦变了库里存的死路径全部失效第二浏览器请求图片时用的是 URL不是磁盘路径URL 应该由项目上下文加相对路径拼出来。这条设计在答辩时被问到的概率极高你要能说出“为什么存相对路径而不存文件流”。还有一个小坑getRealPath(/) 在 IDEA 里指向的是项目 target 或者 out 目录如果你在源码包里没有把图片目录准备好第一次上传时 system.out 会告诉你目录不存在所以要先 mkdirs。文件大小限制 5MB 是人像和手机照片的常见阈值图书封面通常不会更大。如果上传超过大小Tomcat 会抛 IllegalStateException前端需要拦截异常提示“文件过大”不然用户会以为系统卡了。3.4 下单事务扣库存和插订单必须同时成功下单这个动作在二手交易系统里分两步第一步把 book 表的 status 从 1 改成 2已预订第二步往 orders 表插入一条订单记录。这两步必须同时成功否则会出现“订单建好了书还是可售状态”或者“书被订了但查不到订单”的脏数据。事务就是干这个的。public boolean createOrder(Order order, Integer bookId) { Connection conn null; try { conn JdbcUtil.getConnection(); conn.setAutoCommit(false); // 第一步检查并锁定图书状态 String checkSql SELECT status FROM book WHERE id? FOR UPDATE; // 第二步更新图书为已预订 String updateSql UPDATE book SET status2 WHERE id? AND status1; // 第三步插入订单 String insertSql INSERT INTO orders(order_no,book_id,buyer_id,seller_id,price,status) VALUES(?,?,?,?,?,?); conn.commit(); return true; } catch (Exception e) { try { conn.rollback(); } catch (SQLException ex) { } log.error(create order error, e); return false; } finally { try { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) { } } }逻辑说明setAutoCommit(false) 关闭自动提交后后面的 SQL 都在一个事务里。update 语句里带 AND status1 是乐观锁思想防止两个人同时抢同一本书谁先执行成功谁把状态改成 2后执行的人更新行数为 0就能判断下单失败给前端返回“已被预订”。commit 成功后所有数据一起生效rollback 后数据回到下单前状态这就是事务最直接的价值。参数和边界上注意 UPDATE 的返回值尽量别忽略。JDBC 的 executeUpdate 会返回受影响行数下单逻辑必须判断这个数字等于 1 说明抢到了等于 0 说明状态不对直接抛业务异常。另外使用 MySQL 默认的隔离级别 REPEATABLE_READ 就能满足课设不用刻意调整。真正的高并发系统会用锁机制但课设里能把事务和受影响行数都说明白已经远超平均分了。4. JSP 前端与 Ajax 请求把页面和 Servlet 的数据接起来后端接口写得再好前端页面如果是一大堆 Java 脚本拼接的 HTML老师点开页面源码就会皱眉。JSP 前端的关键是“页面里少写 Java 代码数据通过 EL 表达式和 JSTL 渲染交互通过 Ajax 调 Servlet 返回 JSON”。这一章就是把“页面怎么把数据接起来”讲透。4.1 JSP 页面结构EL 表达式和 JSTL 替代 Java 脚本很多课设源码里能看到 JSP 顶部写着一堆 % ... %循环图书列表还要用 Java 的 for 循环这种做法不能说不能跑只能说看着非常难受。推荐用 EL 表达式 JSTL 处理展示逻辑代码量少一半老师看起来也舒服。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body div classbook-grid c:forEach items${bookPage.rows} varbook div classbook-card img src${pageContext.request.contextPath}/${book.coverImage} alt封面 h3${book.title}/h3 p作者${book.author} | 分类${book.categoryName}/p p售价span${book.price}/span 元/p button onclickbuyBook(${book.id})立即购买/button /div /c:forEach /div jsp:include pagepager.jsp/ /body /html逻辑说明${} 表达式会自动调用对应 getter所以实体类里必须把 getter 写全这就是为什么前面强调 getter/setter 不要漏。pageContext.request.contextPath 动态拼出项目根路径这样即使部署的项目名改了图片和链接不会全挂。JSTL 的 c:forEach 本质上是循环标签items 指定集合var 指定循环变量名。这里要提醒一个坑很多 Tomcat 版本默认没带 JSTL 的 jar 包。如果页面报错说找不到 taglib去 WEB-INF/lib 里看一下是不是少了 jstl-1.2.jar 和 standard.jar。Tomcat 9 以下用 javax 前缀的 JSTLTomcat 10 以后改成了 jakarta 前缀源码包里依赖哪个版本要跟你的 Tomcat 版本匹配不然永远报 JSTL 标签找不到。4.2 Ajax 搜索与加入订单JSON 互传的中文编码搜索框和“加入订单”按钮需要在不跳转页面的情况下更新数据。主流做法是前端 fetch 接口后端返回 JSON。这一步最常见的问题是后端返回的中文变成乱码或者后端收到前端传的中文变成问号。根因只有一个响应头没有设置 charset。async function searchBooks() { const keyword document.getElementById(keyword).value.trim(); const url book-search?keyword encodeURIComponent(keyword) page1; const resp await fetch(url, { headers: { X-Requested-With: XMLHttpRequest } }); const data await resp.json(); renderBookList(data.rows); renderPager(data.totalPage); }protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String keyword req.getParameter(keyword); // 调用 service 查询 MapString, Object result new HashMap(); result.put(rows, bookList); result.put(totalPage, totalPage); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().print(new Gson().toJson(result)); }逻辑说明前端 encodeURIComponent 对中文关键字做 URL 编码防止地址栏里出现裸中文后端 resp.setContentType 里的 charsetUTF-8 负责让浏览器正确解码响应体。这两个都是老生常谈但每届课设都会有人漏。提交订单建议用 POST并且带 CSRF 意识。不要让订单创建接口做 GET 请求否则网页里某个恶意图片就能触发订单下单这是安全课里最基本的例子。前端这样写async function buyBook(bookId) { const form new FormData(); form.append(bookId, bookId); const resp await fetch(order/create, { method: POST, body: form }); const data await resp.json(); if (data.code 0) { alert(下单成功等待卖家确认); } else { alert(data.msg || 下单失败); } }FormData 构造时不用手动设置 Content-Type浏览器会自动带上 multipart 边界比手动拼 JSON 少踩一个坑。后端 Servlet 里用 req.getParameter(bookId) 就能拿到值配合前面的事务代码整个下单闭环就通了。4.3 分页参数设计LIMIT 偏移与总页数计算图书列表页不可能一次全查出来分页是 JavaWeb 课设里被问频率第二高的问题。分页的核心参数只有两个page 表示当前页rows 表示每页多少条衍生的 offset 是 SQL 里的偏移量。分了页还能顺带向老师展示你懂 LIMIT 和索引性价比很高。参数含义示例值page当前页码从 1 开始1rows每页条数8offsetLIMIT 偏移量(page-1)*rows0totalPage总页数5SELECT id, title, author, price, cover_image, status FROM book WHERE status 1 ORDER BY create_time DESC LIMIT ?, ?;int page Integer.parseInt(req.getParameter(page)); int rows 8; int offset (page - 1) * rows; // 先查总数 int total bookDao.countByStatus(1); int totalPage (total rows - 1) / rows; // 再查当前页数据 ListBook bookList bookDao.findPage(1, offset, rows);这里 (total rows - 1) / rows 是求大于等于除法结果的最小整数等价于向上取整。如果你用 if 判断 total % rows 0 再分支当然也对但这一行算出来更简洁也说明你做过边界思考。前端渲染页码时要处理 page 小于 1 和大于 totalPage 的情况后端最好也做一次钳制比如 page 1 时强行赋值为 1。不要相信前端自己不会传错参数。分页还有一个容易被忽略的细节首页列表默认只显示 status1 的图书数量通常不多SQL 里的 ORDER BY create_time DESC 保证了新发布的书排在前面。同时给 status 和 create_time 设计好索引虽然课设数据量不大看不出差距但你能回答出“为什么这样建索引”就是加分项。5. 部署与排查避坑数据库连接、中文乱码和图片路径的五个高频翻车点源码包能跑起来的条件比你想象的苛刻JDK、Tomcat、MySQL、连接串、驱动 jar、上传目录、数据库账号任何一个不一致都可能当场翻车。下面五条是我见过的课设里最高频的踩坑现场每一条都按“现象 → 原因 → 解决”展开建议拿到源码先对照排查一遍。5.1 数据库连接失败驱动版本、连接串和 MySQL 8 认证现象通常是 Tomcat 启动后访问任意功能后台报 ClassNotFoundException: com.mysql.jdbc.Driver或者报 Communications link failure。还有一种更隐蔽在自己的电脑上跑得好好的换到老师电脑上就报 Access denied for user。原因基本有三个一是 MySQL 驱动 jar 放错位置或者版本过老com.mysql.jdbc.Driver 这个类名在 MySQL 5.7 以后已经改名成 com.mysql.cj.jdbc.Driver二是连接串缺少 serverTimezone导致 MySQL 8 拒绝连接三是 MySQL 8 默认用户认证插件是 caching_sha2_password旧的驱动不认识。解决方法是把驱动换成 mysql-connector-java 8.x 以上版本并放进 WEB-INF/lib 目录而不是只在编译期引入。连接串写成 jdbc:mysql://localhost:3306/book_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingUTF-8。如果老师机器上的 MySQL 用户是老的可以用 ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 密码 改认证方式。这三步做完连接问题基本清零。5.2 中文乱码JSP、请求、数据库三层统一现象分两类页面里的中文变成 ??以及表单提交的中文存进数据库后变成 ??。前一类是显示问题后一类是存储问题。原因要从三个方向找JSP 页面本身的编码后端接收请求时的解码MySQL 表字段的 collation 是不是 UTF-8。解决方法是三层统一。JSP 用 % page contentTypetext/html;charsetUTF-8 % 声明Servlet 在 doGet/doPost 开头加 request.setCharacterEncoding(UTF-8)或者统一在 web.xml 里配置一个 CharacterEncodingFilter建表语句必须带 DEFAULT CHARSETutf8mb4不能只建表不指定字符集。连接串里的 characterEncodingUTF-8 也不能少否则即使表和页面都是 UTF-8JDBC 连接也会在传输层把编码搞乱。这三板斧下去乱码概率直降九成。5.3 图片上传后不显示磁盘路径和 URL 路径是两码事现象是上传封面成功后页面上的图片是裂的F12 看网络请求返回 404。原因很典型代码把图片写到磁盘某个目录但浏览器请求的 URL 映射不到这个目录。比如你写在 C:/workspace/upload/abc.jpg浏览器访问的是项目名/upload/abc.jpgTomcat 不知道这个磁盘目录要映射成哪个 URL。解决思路是“数据库存相对路径页面用项目上下文拼接 URL”。上传目录选在 getServletContext().getRealPath(/upload) 这个真实路径也就是项目发布目录下的 upload 文件夹Tomcat 天然会把它映射成请求路径。页面里写成 src${pageContext.request.contextPath}/upload/abc.jpg。如果项目部署在 IDEA 内置 Tomcat 里getRealPath 指向的是 target 或 out 目录而不是源码目录所以源码包里那个 upload 目录只是占位首次运行时看日志确认实际路径即可。5.4 IDEA 里能跑复制给老师就崩部署方式不同导致资源找不到这也是血泪经验。现象是自己在 IDEA 里点运行一切正常把整个项目文件夹压缩发给同学对方导入后报各种 ClassNotFound、找不到配置文件或者页面 404。原因在于 IDEA 内置 Tomcat 运行时很多路径绑定在本地配置上比如 jdbc.properties 放在 src 下虽然能编译进 classes但换一台机器重新导入项目时如果输出目录没重建配置文件就不会出现在 classes 根目录。解决方法是交付时用 war 包而不是直接把 out 目录或源码压缩包丢给别人。在 IDEA 里 Build Artifacts 生成 war 文件然后放进对方 Tomcat 的 webapps 目录启动后自动解压。同时把数据库导出成 book_db.sql 文件对方执行 source 命令就能还原数据。还有一个细节项目名最好只用英文和数字不要带中文和空格否则 Tomcat 解压后的上下文路径会出现莫名其妙的 404。5.5 Tomcat 启动慢或端口被占用资源和环境差异现象是双击 startup.bat 后很快报 Address already in use: JVM_Bind 8080或者在老师电脑上启动特别慢。原因一般是两个 Tomcat 或微信开发者工具占用了 8080 端口或者机器内存太小 JVM 堆内存不够。解决方法是打开 conf/server.xml把 Connector 的 port 改成 8081 或 9090访问时带上新端口。启动慢的问题可以在 catalina.bat 里加一行 JAVA_OPTS-Xms256m -Xmx512m把堆内存上下限设置成固定值避免 JVM 反复扩容。这些环境相关的配置建议在课设说明文档里写清楚答辩时老师照着文档能复现你就不用现场表演修 Bug。提示交付前一定要在“干净环境”里复现一遍。最常见的做法是换成老师同版本的 JDK/Tomcat 再跑一次。能跑通就把运行版本写进 README属性文件里注明 MySQL 账号密码这些细节往往比代码本身更能影响评分。6. 加分细节验收演示顺序和一个值得落地的登录拦截改动课设答辩不是让你写代码是让你在五分钟内讲清楚“这个系统解决了什么问题、技术点在哪里”。我自己的演示固定顺序是先注册新用户讲密码加盐再发布一本带封面的图书讲上传路径和相对路径设计然后换个账号登录搜索刚才的书讲分页和 Ajax最后下单讲事务和图书状态流转结尾去个人中心展示订单状态变化。这条路径跑完正好覆盖数据库增删改查、事务、Session、文件上传四个核心考点。加分项里有一步极其实用登录拦截过滤器。你的系统现在很可能存在一个问题游客直接手动输入 book-manage 等 URL 就能打开管理页面。写一个最基本的 Filter在 session 里没有 loginUser 时把请求重定向到登录页只放行 login、register、book-search 和静态资源。这个改动只有二十行代码但老师看到你能主动控制未登录访问权限会直接认定你具备安全意识。拦截器实现后建议再顺手加一个操作日志在订单创建、图书上下架等核心接口里用 SLF4J 输出一行结构化日志包含操作人、操作类型、目标 id、时间戳。这个动作既方便自己调试答辩时打开控制台给老师看“哪个用户几点下的单”比任何讲解都有说服力。高分不是代码写得多花哨而是每个环节你都答得上、演示时不出错。希望这些亲历过的坑能帮你把课设做成真正拿得出手的作品而不是能跑就行的黑匣子。本文还有配套的精品资源点击获取