简介:这份资源是面向高校计算机相关专业学生的Java Web课程设计完整项目包,以校园二手图书交易系统为主题,适合正在准备课设、大作业或需要Java Web实战练手的学习者。项目源码经过本地编译与严格调试,可直接运行,评审得分在95分以上,难度适中,内容经助教老师审定,能满足学习与参考需求。压缩包共510个文件,约28.32MB,涵盖118个Java源文件、136个XML配置、9个SQL脚本、18个Vue组件及91张JPG图片等,分别对应后端业务逻辑、框架与依赖配置、数据库建表数据、前端页面组件和界面素材,结构完整。目前已有119人学习关注。通过该资源可掌握Spring Boot与Vue前后端分离的典型开发流程,理解用户、图书、订单等核心模块的Controller、Service与Model分层设计,并借助SQL脚本快速还原数据库环境,是课程设计答辩与Java Web入门进阶的实用参考。
1. 校园二手图书交易系统:从课程设计到能跑起来的 Javaweb 项目
每年学期末,图书馆门口、宿舍楼下都会冒出一堆摆摊卖旧书的人,而另一边,低年级学生正为几十块一本的专业教材发愁。校园二手图书交易系统要解决的就是这个信息不对称问题——让卖方能发布书籍信息,买方能按书名、分类、成色检索并下单。这个标题对应的是一套完整的 Javaweb 课程设计,包含源码和数据库,适合正在做 Java 课设、需要交大作业的学生,也适合想拿一个完整 CRUD 项目练手的 Java 初学者。它涉及的技术栈是典型的 Javaweb 组合:Servlet/JSP 或 Spring Boot、MySQL 数据库、前端页面,核心功能围绕用户管理、图书发布、订单交易展开。下面我会按「技术选型 → 数据库设计 → 核心功能实现 → 避坑 → 进阶」的顺序,把这类项目从零跑通的路径讲清楚。
2. 技术选型与项目骨架:Servlet/JSP 还是 Spring Boot
2.1 两种主流技术路线的取舍
校园二手图书交易系统在 Javaweb 课程设计里通常有两种实现路线。第一种是传统 Servlet + JSP + JDBC,第二种是 Spring Boot + MyBatis + Thymeleaf。选哪条路,取决于你的课设要求和时间预算。
传统 Servlet + JSP 路线的优势在于「透明」。你写的每一个请求都对应一个 Servlet 类,web.xml或注解里配置映射,JSP 负责渲染。老师检查代码时能清楚看到 HTTP 请求怎么进来、业务逻辑怎么处理、数据怎么落库。缺点是代码量大,一个图书列表功能可能要写 Servlet、DAO、JSP 三个文件,而且 JDBC 连接管理容易出问题。如果你的课设明确要求「不能用框架」,或者你想彻底搞懂 Javaweb 的底层流程,选这条路。
Spring Boot 路线的优势在于「快」。依赖注入、自动配置、内嵌 Tomcat,一个@RestController就能暴露接口,MyBatis 或 JPA 处理数据库映射。代码量能压缩到传统路线的三分之一左右。缺点是如果老师追问「Spring 的自动配置原理是什么」,你得能答上来,否则容易被质疑是抄的。另外,Spring Boot 项目在 IDEA 里的配置项比较多,新手容易在application.yml和依赖版本上翻车。
我一般建议:如果课设评分看重「功能完整度」和「代码规范」,选 Spring Boot;如果看重「对 Javaweb 原理的理解」,选 Servlet + JSP。两条路都能拿到 95 分以上,关键是把功能做完整、把异常处理写好。
2.2 用 Maven 搭出可运行的项目骨架
不管选哪条路,项目结构都要清晰。下面是一个传统 Servlet + JSP 项目的 Mavenpom.xml核心依赖配置:
<dependencies> <!-- Servlet API,scope 必须是 provided,因为 Tomcat 自带 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP 支持 --> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- MySQL 驱动,版本要和你的 MySQL 服务端匹配 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JSTL 标签库,JSP 页面里用 c:forEach 需要它 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- 数据库连接池,别直接用 DriverManager --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies>这段配置里有两个参数容易设错。第一,javax.servlet-api的scope必须是provided,因为 Tomcat 容器本身提供了 Servlet 实现,如果你打成compile,部署时会报ClassCastException或类冲突。第二,MySQL 驱动版本要和数据库服务端匹配,MySQL 5.7 用5.1.x驱动,MySQL 8.0 用8.0.x驱动,版本不匹配会报Unknown system variable 'query_cache_size'之类的错。
目录结构按 Maven 标准来:src/main/java放 Java 代码,src/main/webapp放 JSP 和静态资源,src/main/resources放db.properties等配置文件。包名建议按com.campus.book开头,下面分controller、service、dao、entity、util五个包。这个分层不是形式主义——controller只负责接收参数和返回视图,service写业务逻辑,dao只做数据库操作,entity对应数据库表。分层清晰的项目,老师一眼就能看出你懂 MVC。
2.3 IDEA 里配置 Tomcat 并跑通第一个页面
项目骨架搭好后,在 IDEA 里配置 Tomcat 运行环境。步骤是:Run → Edit Configurations → 点左上角+→ 选 Tomcat Server → Local → 在 Server 标签页指定 Tomcat 安装目录 → 在 Deployment 标签页点+→ 选 Artifact → 选xxx:war exploded。注意选war exploded而不是war,前者支持热部署,改 JSP 不用重启。
配置完成后,写一个最简单的IndexServlet测试环境:
@WebServlet("/index") public class IndexServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 从数据库查最新上架的 8 本书,传给 JSP 渲染 BookService bookService = new BookService(); List<Book> latestBooks = bookService.getLatestBooks(8); req.setAttribute("latestBooks", latestBooks); // 转发到首页 JSP,注意路径以 / 开头,相对于 webapp 根目录 req.getRequestDispatcher("/WEB-INF/views/index.jsp").forward(req, resp); } }这段代码里,@WebServlet("/index")是注解式映射,不需要再配web.xml。getLatestBooks(8)里的 8 是首页展示数量,你可以改成 10 或 12,但别设太大,否则首页加载慢。forward是服务器内部转发,浏览器地址栏不变;如果你用sendRedirect,地址栏会变成 JSP 路径,而且req.setAttribute的数据会丢失。JSP 放在WEB-INF/views下是为了防止用户直接通过 URL 访问 JSP 页面,必须经过 Servlet 转发才能看到,这是 Javaweb 项目的基本安全习惯。
3. 数据库设计:五张核心表与增删改查落地
3.1 用户表、图书表、订单表的关系建模
校园二手图书交易系统的数据库不需要太复杂,五张表就能覆盖核心业务:user(用户)、book(图书)、category(分类)、order(订单)、order_item(订单明细)。表之间的关系是:一个用户可以发布多本图书,一个用户可以下多个订单,一个订单包含多个订单明细,每本图书属于一个分类。
先看user表的设计:
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', `password` VARCHAR(64) NOT NULL COMMENT '密码,存MD5值', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `school` VARCHAR(100) DEFAULT NULL COMMENT '学校', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';password字段长度设 64 是因为 MD5 值是 32 位十六进制字符串,留一倍余量方便以后换加密算法。username加了UNIQUE约束,注册时如果插入重复用户名,数据库会直接报错,你在 Service 层捕获SQLIntegrityConstraintViolationException就能提示「用户名已存在」,不用先查再插,避免并发问题。create_time用DEFAULT CURRENT_TIMESTAMP,插入时不用手动传时间。
book表是核心,字段设计直接影响检索功能:
CREATE TABLE `book` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL COMMENT '书名', `author` VARCHAR(100) DEFAULT NULL COMMENT '作者', `isbn` VARCHAR(20) DEFAULT NULL COMMENT 'ISBN号', `category_id` INT NOT NULL COMMENT '分类ID', `price` DECIMAL(10,2) NOT NULL COMMENT '售价', `original_price` DECIMAL(10,2) DEFAULT NULL COMMENT '原价', `condition_level` TINYINT DEFAULT 3 COMMENT '成色1-5,5为全新', `description` TEXT COMMENT '描述', `cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图路径', `seller_id` INT NOT NULL COMMENT '卖家ID', `status` TINYINT DEFAULT 1 COMMENT '1在售 2已售 3下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`category_id`) REFERENCES `category`(`id`), FOREIGN KEY (`seller_id`) REFERENCES `user`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';price用DECIMAL(10,2)而不是FLOAT,因为浮点数做金额计算会出现0.1 + 0.2 = 0.30000000000000004的问题,DECIMAL是精确小数。condition_level用TINYINT存 1 到 5,前端渲染成「几成新」。status字段控制图书状态,下架的书不显示在列表里,但数据保留,方便卖家重新上架。外键约束建议加上,虽然有些互联网公司禁止用外键,但课设项目数据量小,外键能保证数据一致性,而且老师看到外键会认为你数据库学得扎实。
3.2 用 JDBC 工具类封装数据库连接
不管选 Servlet 还是 Spring Boot,数据库连接管理都是绕不开的。直接在每个 DAO 里写DriverManager.getConnection会导致连接无法复用,性能差且容易泄漏。用 Druid 连接池 + 工具类封装是标准做法。
先写db.properties:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/campus_book?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username=root password=your_password initialSize=5 maxActive=20url里的serverTimezone=Asia/Shanghai必须加,否则 MySQL 8.0 驱动会报时区错误。useSSL=false在本地开发时加上,避免 SSL 握手警告。maxActive=20是最大连接数,课设项目 20 足够,设太大反而浪费资源。
工具类JdbcUtils:
public class JdbcUtils { private static DataSource dataSource; static { try { Properties props = new Properties(); // 从类路径加载配置文件 props.load(JdbcUtils.class.getClassLoader() .getResourceAsStream("db.properties")); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError("数据库连接池初始化失败"); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 关闭顺序:ResultSet → Statement → Connection,反了会报错 try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (stmt != null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }静态代码块在类加载时执行一次,连接池只初始化一次。close方法的关闭顺序必须是ResultSet → Statement → Connection,反过来关会抛异常。注意这里的conn.close()不是真正关闭物理连接,而是把连接归还给连接池,Druid 内部会处理。
3.3 图书检索的 SQL 与分页实现
二手图书交易系统最核心的功能是检索。用户可能按书名模糊查、按分类筛选、按价格排序,这三个条件要能组合。下面是一个带分页的检索 SQL:
SELECT b.*, c.name AS category_name, u.nickname AS seller_name FROM book b LEFT JOIN category c ON b.category_id = c.id LEFT JOIN user u ON b.seller_id = u.id WHERE b.status = 1 AND (b.title LIKE CONCAT('%', #{keyword}, '%') OR #{keyword} IS NULL) AND (b.category_id = #{categoryId} OR #{categoryId} IS NULL) ORDER BY b.create_time DESC LIMIT #{offset}, #{pageSize}这个 SQL 用了LEFT JOIN关联分类和用户表,这样列表页能直接显示分类名和卖家昵称,不用再查一次。#{keyword} IS NULL这个条件让同一个 SQL 支持「有搜索词」和「无搜索词」两种情况,不用在 Java 里拼字符串。LIMIT #{offset}, #{pageSize}是 MySQL 的分页语法,offset = (pageNum - 1) * pageSize。
对应的 DAO 方法:
public List<Book> searchBooks(String keyword, Integer categoryId, int pageNum, int pageSize) { String sql = "SELECT b.*, c.name AS category_name, u.nickname AS seller_name " + "FROM book b LEFT JOIN category c ON b.category_id = c.id " + "LEFT JOIN user u ON b.seller_id = u.id " + "WHERE b.status = 1 " + "AND (b.title LIKE ? OR ? IS NULL) " + "AND (b.category_id = ? OR ? IS NULL) " + "ORDER BY b.create_time DESC LIMIT ?, ?"; Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<Book> list = new ArrayList<>(); try { conn = JdbcUtils.getConnection(); ps = conn.prepareStatement(sql); // 注意参数顺序:keyword 出现两次,categoryId 出现两次 ps.setString(1, keyword == null ? null : "%" + keyword + "%"); ps.setString(2, keyword); if (categoryId == null) { ps.setNull(3, Types.INTEGER); ps.setNull(4, Types.INTEGER); } else { ps.setInt(3, categoryId); ps.setInt(4, categoryId); } ps.setInt(5, (pageNum - 1) * pageSize); ps.setInt(6, pageSize); rs = ps.executeQuery(); while (rs.next()) { list.add(mapRowToBook(rs)); } } catch (SQLException e) { e.printStackTrace(); } finally { JdbcUtils.close(conn, ps, rs); } return list; }参数设置是这段代码最容易翻车的地方。keyword在 SQL 里出现了两次,第一次要加%通配符,第二次要传原始值用于IS NULL判断。categoryId同理。如果参数顺序设错,查询结果会完全不对。ps.setNull(3, Types.INTEGER)不能写成ps.setObject(3, null),后者在某些驱动版本下会报错。
4. 核心功能实现:登录、发布、下单的完整链路
4.1 用户登录与 Session 管理
登录功能看起来简单,但涉及密码加密、Session 管理和拦截器三个点。密码存 MD5 值,登录时把用户输入的密码也转成 MD5 再比对:
public User login(String username, String rawPassword) { // 先对明文密码做 MD5,再和数据库里的密文比对 String md5Password = DigestUtils.md5Hex(rawPassword); String sql = "SELECT * FROM user WHERE username = ? AND password = ?"; // ... 执行查询,返回 User 对象或 null }DigestUtils来自 Apache Commons Codec,比手写MessageDigest简洁。注意 MD5 已经不安全了,但课设项目用 MD5 足够,生产环境应该用 BCrypt。
登录成功后把用户信息存入 Session:
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); UserService userService = new UserService(); User user = userService.login(username, password); if (user == null) { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp); return; } // 登录成功,把 user 存入 session req.getSession().setAttribute("currentUser", user); resp.sendRedirect(req.getContextPath() + "/index"); } }req.getSession()会自动创建 Session 并生成 JSESSIONID,通过 Cookie 发给浏览器。setAttribute("currentUser", user)把用户对象存进去,后续页面用${sessionScope.currentUser.nickname}就能取到昵称。sendRedirect后面必须return,否则代码会继续执行到forward,导致IllegalStateException。
4.2 图书发布:文件上传与表单验证
发布图书涉及文件上传(封面图)和表单验证。文件上传用 Servlet 3.0 的@MultipartConfig注解:
@WebServlet("/book/publish") @MultipartConfig( maxFileSize = 5 * 1024 * 1024, // 单文件最大 5MB maxRequestSize = 10 * 1024 * 1024, // 整个请求最大 10MB fileSizeThreshold = 1024 * 1024 // 超过 1MB 写入临时文件 ) public class PublishBookServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 检查登录状态 User currentUser = (User) req.getSession().getAttribute("currentUser"); if (currentUser == null) { resp.sendRedirect(req.getContextPath() + "/login"); return; } // 获取普通表单字段 String title = req.getParameter("title"); String author = req.getParameter("author"); String priceStr = req.getParameter("price"); // 服务端验证:书名不能为空,价格必须是正数 if (title == null || title.trim().isEmpty()) { req.setAttribute("error", "书名不能为空"); req.getRequestDispatcher("/WEB-INF/views/publish.jsp").forward(req, resp); return; } BigDecimal price; try { price = new BigDecimal(priceStr); if (price.compareTo(BigDecimal.ZERO) <= 0) { throw new NumberFormatException(); } } catch (NumberFormatException e) { req.setAttribute("error", "价格必须是正数"); req.getRequestDispatcher("/WEB-INF/views/publish.jsp").forward(req, resp); return; } // 处理文件上传 Part filePart = req.getPart("coverImage"); String imagePath = null; if (filePart != null && filePart.getSize() > 0) { String fileName = UUID.randomUUID().toString() + getExtension(filePart.getSubmittedFileName()); // 保存到 webapp/uploads 目录 String uploadDir = getServletContext().getRealPath("/uploads"); File dir = new File(uploadDir); if (!dir.exists()) dir.mkdirs(); filePart.write(uploadDir + File.separator + fileName); imagePath = "uploads/" + fileName; } // 组装 Book 对象并入库 Book book = new Book(); book.setTitle(title); book.setAuthor(author); book.setPrice(price); book.setCoverImage(imagePath); book.setSellerId(currentUser.getId()); book.setStatus(1); new BookService().publish(book); resp.sendRedirect(req.getContextPath() + "/book/my"); } }@MultipartConfig的三个参数要理解:maxFileSize是单个文件上限,maxRequestSize是整个请求上限(包含所有文件和表单字段),fileSizeThreshold是内存缓冲区大小,超过这个值会写入临时文件。如果上传大文件报FileSizeLimitExceededException,就是这两个 size 设小了。
文件名用UUID重命名是为了防止用户上传同名文件覆盖,也防止文件名里的特殊字符导致路径问题。getServletContext().getRealPath("/uploads")获取的是 webapp 下的真实路径,但注意:如果项目打成 war 包部署,这个路径在容器重启后可能被清空,生产环境应该存到外部目录。课设项目用getRealPath没问题。
4.3 下单流程与事务控制
下单是最能体现业务逻辑复杂度的功能。用户点击「购买」后,系统要做三件事:创建订单、创建订单明细、把图书状态改为已售。这三步必须在一个事务里完成,否则可能出现「订单创建了但图书还在售」的数据不一致。
public boolean createOrder(int buyerId, int bookId) { Connection conn = null; try { conn = JdbcUtils.getConnection(); // 关闭自动提交,开启事务 conn.setAutoCommit(false); // 1. 查询图书信息,同时加行锁防止并发下单 String bookSql = "SELECT * FROM book WHERE id = ? AND status = 1 FOR UPDATE"; PreparedStatement bookPs = conn.prepareStatement(bookSql); bookPs.setInt(1, bookId); ResultSet rs = bookPs.executeQuery(); if (!rs.next()) { conn.rollback(); return false; // 图书不存在或已售 } BigDecimal price = rs.getBigDecimal("price"); int sellerId = rs.getInt("seller_id"); if (sellerId == buyerId) { conn.rollback(); return false; // 不能买自己的书 } // 2. 创建订单 String orderSql = "INSERT INTO `order` (buyer_id, seller_id, total_amount, status, create_time) " + "VALUES (?, ?, ?, 1, NOW())"; PreparedStatement orderPs = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); orderPs.setInt(1, buyerId); orderPs.setInt(2, sellerId); orderPs.setBigDecimal(3, price); orderPs.executeUpdate(); ResultSet keys = orderPs.getGeneratedKeys(); keys.next(); int orderId = keys.getInt(1); // 3. 创建订单明细 String itemSql = "INSERT INTO order_item (order_id, book_id, price) VALUES (?, ?, ?)"; PreparedStatement itemPs = conn.prepareStatement(itemSql); itemPs.setInt(1, orderId); itemPs.setInt(2, bookId); itemPs.setBigDecimal(3, price); itemPs.executeUpdate(); // 4. 更新图书状态为已售 String updateSql = "UPDATE book SET status = 2 WHERE id = ?"; PreparedStatement updatePs = conn.prepareStatement(updateSql); updatePs.setInt(1, bookId); updatePs.executeUpdate(); // 全部成功,提交事务 conn.commit(); return true; } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { try { if (conn != null) conn.setAutoCommit(true); } catch (SQLException e) { e.printStackTrace(); } JdbcUtils.close(conn, null, null); } }这段代码有几个关键点。第一,conn.setAutoCommit(false)开启事务,所有操作在commit()之前都不会真正写入数据库。第二,SELECT ... FOR UPDATE加了行锁,防止两个用户同时买同一本书——第一个事务锁住这行,第二个事务会等待,等第一个提交后第二个查到status = 2,就会返回失败。第三,Statement.RETURN_GENERATED_KEYS让 JDBC 返回自增主键,这样不用再查一次订单 ID。第四,finally里把autoCommit恢复为true再归还连接,否则连接池里的这个连接下次被复用时还是手动提交模式,会导致其他操作不生效。
5. 避坑与排查:课设项目最容易翻车的五个地方
5.1 中文乱码:从请求到响应全链路排查
现象:表单提交的中文书名在数据库里变成???,或者 JSP 页面显示乱码。
原因:乱码可能出现在三个环节——请求编码、数据库编码、响应编码。任何一个环节没设对都会乱码。
解决:请求端用request.setCharacterEncoding("UTF-8"),但注意这行必须在getParameter之前调用。如果用 Tomcat 8 以上,GET 请求默认是 UTF-8,但 POST 请求需要手动设。数据库连接 URL 加useUnicode=true&characterEncoding=utf8。数据库和表的字符集用utf8mb4。响应端设response.setContentType("text/html;charset=UTF-8")。JSP 页面头部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>。四个地方都设对,乱码基本不会出现。
5.2 连接池报错:连接泄漏与超时配置
现象:项目运行一段时间后报GetConnectionTimeoutException,或者 Tomcat 日志里出现Connection leak detected。
原因:DAO 方法里Connection没有在finally里关闭,或者close顺序写错导致异常,连接没归还给连接池。另一个原因是maxActive设太小,并发一高就不够用。
解决:每个 DAO 方法都用try-finally确保JdbcUtils.close被调用。Druid 可以开启removeAbandoned=true和removeAbandonedTimeout=300,自动回收超过 5 分钟没关闭的连接,但这只是兜底,根本还是要写对close。maxActive根据并发量调整,课设项目 20 够用,如果报超时可以先调到 50 试试。
5.3 文件上传失败:路径与权限问题
现象:上传封面图时报FileNotFoundException或AccessDeniedException。
原因:getRealPath("/uploads")返回的路径在 IDEA 里指向target目录下的临时部署路径,这个目录可能不存在,或者没有写权限。另外,如果@MultipartConfig没加,req.getPart会返回 null。
解决:先new File(uploadDir).mkdirs()确保目录存在。检查@MultipartConfig注解是否加在 Servlet 类上。如果还是失败,把上传目录改到项目外的绝对路径,比如D:/upload/,用FileOutputStream手动写入。IDEA 里运行 Tomcat 时,getRealPath的路径可以在启动日志里看到,打印出来确认一下。
5.4 事务不生效:自动提交没关或异常没回滚
现象:下单时订单创建了,但图书状态没改,或者图书状态改了但订单没创建。
原因:conn.setAutoCommit(false)没调用,或者调用了但异常发生时没有rollback。另一个常见原因是把Connection存在成员变量里,多个请求共用同一个连接,事务互相干扰。
解决:Connection必须是方法内的局部变量,不能是 Servlet 的成员变量。setAutoCommit(false)后,所有executeUpdate都在同一个事务里,任何一步抛异常都要rollback。finally里恢复autoCommit为true。如果用了 Spring 的@Transactional,注意方法必须是public,且同类内部调用不会触发事务。
5.5 JSP 页面报 404:路径与转发问题
现象:访问/book/list报 404,或者 JSP 页面里的 CSS/JS 加载不出来。
原因:@WebServlet的路径和访问 URL 不匹配,或者forward的 JSP 路径写错。静态资源 404 通常是因为用了相对路径,而页面 URL 有多层路径。
解决:@WebServlet("/book/list")对应的访问地址是http://localhost:8080/项目名/book/list,注意项目名不能漏。forward的路径以/开头,相对于 webapp 根目录,比如/WEB-INF/views/book-list.jsp。静态资源用${pageContext.request.contextPath}/css/style.css这种绝对路径,不要用../css/style.css。如果 JSP 放在WEB-INF下,必须通过 Servlet 转发访问,直接输 URL 会 404,这是正常的。
6. 从能跑到能拿高分:三个进阶技巧
6.1 用过滤器统一处理编码和登录校验
每个 Servlet 里都写setCharacterEncoding和登录检查太啰嗦,用 Filter 统一处理:
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 统一设置请求和响应编码 request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); String uri = request.getRequestURI(); // 放行登录、注册、静态资源 if (uri.contains("/login") || uri.contains("/register") || uri.contains("/css/") || uri.contains("/js/") || uri.contains("/uploads/")) { chain.doFilter(req, resp); return; } // 其他请求检查登录状态 if (request.getSession().getAttribute("currentUser") == null) { response.sendRedirect(request.getContextPath() + "/login"); return; } chain.doFilter(req, resp); } }@WebFilter("/*")拦截所有请求。放行列表里加上静态资源路径,否则 CSS 和图片也会被拦截导致页面样式丢失。这个 Filter 同时解决了编码和登录校验两个问题,代码量比在每个 Servlet 里写少得多。
6.2 用 AJAX 做无刷新检索和加入购物车
传统表单提交会刷新整个页面,用户体验差。用 AJAX 只更新图书列表区域:
function searchBooks() { const keyword = document.getElementById('keyword').value; const categoryId = document.getElementById('category').value; // 用 fetch 发 GET 请求,后端返回 JSON fetch(`${contextPath}/book/search?keyword=${encodeURIComponent(keyword)}&categoryId=${categoryId}`) .then(response => response.json()) .then(data => { const listDiv = document.getElementById('bookList'); listDiv.innerHTML = ''; // 清空旧数据 data.forEach(book => { // 动态创建图书卡片 const card = document.createElement('div'); card.className = 'book-card'; card.innerHTML = ` <img src="${contextPath}/${book.coverImage}" alt="封面"> <h3>${book.title}</h3> <p>${book.author}</p> <p class="price">¥${book.price}</p> <button onclick="buyBook(${book.id})">购买</button> `; listDiv.appendChild(card); }); }) .catch(error => console.error('检索失败:', error)); }后端对应的 Servlet 返回 JSON 而不是 JSP:
@WebServlet("/book/search") public class BookSearchServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword = req.getParameter("keyword"); String categoryIdStr = req.getParameter("categoryId"); Integer categoryId = (categoryIdStr == null || categoryIdStr.isEmpty()) ? null : Integer.parseInt(categoryIdStr); List<Book> books = new BookService().searchBooks(keyword, categoryId, 1, 20); // 用 Jackson 或 Gson 转 JSON resp.setContentType("application/json;charset=UTF-8"); ObjectMapper mapper = new ObjectMapper(); mapper.writeValue(resp.getWriter(), books); } }encodeURIComponent对关键词编码,防止特殊字符导致 URL 解析错误。后端返回 JSON 时contentType必须是application/json,否则前端response.json()会解析失败。用 Jackson 的ObjectMapper序列化List<Book>,注意Book类要有 getter 方法,否则序列化出来是空对象。
6.3 用 Git 管理代码并写一份能加分的 README
课设交作业时,老师不只看代码能不能跑,还看你的工程素养。用 Git 管理代码,提交记录清晰,README 写清楚项目介绍、技术栈、数据库配置步骤、运行截图,这些都能加分。
README 至少包含这几块:项目简介(一句话说清做什么)、技术栈(Java 8 + Servlet 4.0 + MySQL 8.0 + Tomcat 9)、功能列表(用户注册登录、图书发布、检索、下单、订单管理)、数据库配置(导入campus_book.sql,修改db.properties里的密码)、运行步骤(IDEA 导入 Maven 项目,配置 Tomcat,启动后访问/index)。截图放首页、图书详情页、订单页三张就够。
Git 提交信息写清楚每次改了什么,比如feat: 添加图书检索分页、fix: 修复下单事务未回滚问题。不要一次性提交所有代码,分多次提交能体现开发过程。如果老师检查提交记录,看到你按功能模块逐步提交,印象分会高很多。
我自己的习惯是:每完成一个功能就提交一次,提交前先git status确认没有多余文件,target目录和.idea目录加到.gitignore里。数据库脚本单独放一个sql目录,README 里写清楚导入顺序。这些细节不费多少时间,但能让你的课设从「能跑」变成「规范」,95 分以上的项目通常赢在这些地方。希望帮到你。
本文还有配套的精品资源,点击获取