☰
Java+JSP校园二手交易平台毕设:从数据库设计到下单事务的完整实现
2026/10/5 1:22:30 网站建设 项目流程

简介:这是一套面向计算机相关专业学生的校园二手物品交易平台完整项目,采用Java语言与JSP技术开发,遵循MVC设计模式,适合作为毕业设计、期末大作业或项目实战练习的参考方案。系统涵盖用户注册登录、商品发布与浏览、交易沟通、订单管理、在线支付、评价反馈等核心模块,数据库设计完整,代码注重可读性与安全性,对SQL注入、跨站脚本等风险做了防护处理。资源包共1305个文件,约22.74MB,包含128个java源文件、119个jsp页面、364个js脚本、146个css样式及大量png、gif、jpg图片素材,另有sql建库脚本、doc开发文档、pptx演示文稿与properties配置文件,源码经本地编译调试可直接运行。目前已有39人学习下载。随包附带的开发文档与数据文档详细说明了设计思路、开发流程与数据库结构,便于快速上手并深入理解整个Web项目的实现脉络。

1. 校园二手物品交易平台:为什么用 Java + JSP 做毕设依然是最稳的选择

每年毕业季,校园里最真实的场景不是拍毕业照,而是宿舍楼下堆成山的二手书、自行车、小电器。信息不对称让这些东西要么被贱卖,要么直接扔掉。做一个校园二手物品交易平台,本质上是把「谁有闲置」和「谁需要」这两端用一套可信的账号体系和分类检索连起来。这个标题里的关键词是 Java、JSP、校园二手物品交易平台,它指向的不是一个玩具 demo,而是一套能跑通注册、发布、浏览、下单、留言的完整 Web 应用。适合谁?适合正在做课程设计或毕设的计算机专业学生,也适合想用 Java 技术栈练手一个真实业务闭环的入门开发者。JSP 虽然常被吐槽老,但它的请求响应模型足够直白,配合 Servlet 和 JDBC,能把「一个请求从浏览器到数据库再回来」的全过程看得清清楚楚,这对打基础比直接上框架更有价值。

2. 技术选型与数据库设计:JSP + Servlet + JDBC 的最小可用组合

2.1 为什么不用 Spring Boot 一步到位

很多同学一上来就想用 Spring Boot + MyBatis,觉得这样才「现代」。但毕设场景下,答辩老师更关心你是否理解 MVC 的流转,而不是你会不会配 starter。JSP + Servlet + JDBC 这套组合,每个环节都是显式的:浏览器发请求,Servlet 接住,调 DAO,DAO 用 JDBC 查 MySQL,结果塞进 request 域,转发给 JSP 渲染。没有自动装配的黑匣子,出了问题你能顺着调用栈一路查到 SQL。常见做法是分层:entity放实体类,dao放数据库操作,service放业务逻辑,servlet放控制层,util放数据库连接工具。这个结构不复杂,但足够让代码有边界。

选型上还有一个现实考虑:JSP 对表单提交和页面跳转的支持是原生的,request.getParameter()直接拿参数,response.sendRedirect()直接跳转,不需要额外学一套注解体系。对于「校园二手物品交易平台」这种以 CRUD 为主的应用,这套组合的开发效率并不低。

2.2 数据库表结构:五张表撑起核心业务

一个能跑通的二手平台,最少需要五张表:用户表、商品表、分类表、订单表、留言表。下面是我一般会用的建表语句,字段类型和约束都按实际业务来定。

-- 用户表:区分普通用户和管理员 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), phone VARCHAR(20), role TINYINT DEFAULT 0, -- 0普通用户 1管理员 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 分类表:二手物品的类别 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL, sort_order INT DEFAULT 0 ); -- 商品表:核心表,关联用户和分类 CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, image_url VARCHAR(255), category_id INT, seller_id INT, status TINYINT DEFAULT 0, -- 0在售 1已售 2下架 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES category(id), FOREIGN KEY (seller_id) REFERENCES user(id) ); -- 订单表:记录交易 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, goods_id INT NOT NULL, buyer_id INT NOT NULL, seller_id INT NOT NULL, amount DECIMAL(10,2), status TINYINT DEFAULT 0, -- 0待确认 1已完成 2已取消 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (goods_id) REFERENCES goods(id), FOREIGN KEY (buyer_id) REFERENCES user(id) ); -- 留言表:商品下的提问和回复 CREATE TABLE message ( id INT PRIMARY KEY AUTO_INCREMENT, goods_id INT NOT NULL, user_id INT NOT NULL, content VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (goods_id) REFERENCES goods(id), FOREIGN KEY (user_id) REFERENCES user(id) );

逻辑说明:goods表用status字段控制商品生命周期,而不是物理删除,这样订单表的外键不会断。orders表同时冗余了buyer_id和seller_id,查询「我买到的」和「我卖出的」都不用再连goods表绕一圈。参数上,price用DECIMAL(10,2)而不是FLOAT,避免金额计算出现0.1 + 0.2 = 0.30000000000000004这种经典翻车。create_time统一用DATETIME并给默认值,插入时不用手动传。

2.3 数据库连接工具类:一个 JDBC 工具类解决重复代码

每次查数据库都写DriverManager.getConnection是灾难。我一般会抽一个DBUtil,用静态代码块加载驱动,提供获取连接和关闭资源的方法。

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/campus_trade?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (ps != null) ps.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }

逻辑说明:Class.forName放在静态块里只执行一次,避免每次获取连接都反射加载驱动。close方法按ResultSet → PreparedStatement → Connection的顺序关闭,这是 JDBC 资源释放的标准顺序,反过来关可能报错。参数上,serverTimezone=Asia/Shanghai必须加,否则 MySQL 8 驱动会抛时区异常;characterEncoding=utf8保证中文不乱码。注意:连接池在生产环境是必须的,但毕设阶段用直连足够,先把业务跑通再考虑优化。

3. 核心功能实现:从发布商品到下单的完整链路

3.1 商品发布:文件上传与表单处理的三个关键点

发布商品是整个平台最核心的写操作,涉及文本字段和图片文件。JSP 表单必须用enctype="multipart/form-data",Servlet 端用@MultipartConfig注解接收文件。

@WebServlet("/goods/publish") @MultipartConfig(maxFileSize = 5 * 1024 * 1024, maxRequestSize = 10 * 1024 * 1024) public class GoodsPublishServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String title = req.getParameter("title"); String priceStr = req.getParameter("price"); String categoryIdStr = req.getParameter("categoryId"); String description = req.getParameter("description"); // 参数校验:空值和数字格式 if (title == null || title.trim().isEmpty() || priceStr == null) { req.setAttribute("msg", "标题和价格不能为空"); req.getRequestDispatcher("/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("msg", "价格必须是正数"); req.getRequestDispatcher("/publish.jsp").forward(req, resp); return; } // 处理图片上传 Part filePart = req.getPart("image"); String imageUrl = null; if (filePart != null && filePart.getSize() > 0) { String fileName = System.currentTimeMillis() + "_" + filePart.getSubmittedFileName(); String savePath = getServletContext().getRealPath("/uploads"); File dir = new File(savePath); if (!dir.exists()) dir.mkdirs(); filePart.write(savePath + File.separator + fileName); imageUrl = "uploads/" + fileName; } User user = (User) req.getSession().getAttribute("user"); Goods goods = new Goods(); goods.setTitle(title); goods.setPrice(price); goods.setDescription(description); goods.setCategoryId(Integer.parseInt(categoryIdStr)); goods.setSellerId(user.getId()); goods.setImageUrl(imageUrl); new GoodsDao().insert(goods); resp.sendRedirect(req.getContextPath() + "/goods/list"); } }

逻辑说明:@MultipartConfig的maxFileSize限制单文件 5MB,maxRequestSize限制整个请求 10MB,防止有人传超大文件把服务器磁盘打满。价格用BigDecimal接收并校验正数,避免负数价格这种逻辑漏洞。图片文件名用System.currentTimeMillis()加前缀,防止同名文件互相覆盖。getRealPath("/uploads")拿到的是 Web 应用部署目录下的绝对路径,注意这个目录在重新部署时可能被清空,生产环境应该存到应用外部路径。

参数上,req.setCharacterEncoding("UTF-8")必须在getParameter之前调用,否则中文标题会乱码。filePart.write()是 Servlet 3.0 提供的便捷方法,底层还是流拷贝,大文件场景要自己写缓冲流。

3.2 商品列表与分页:别让全表查询拖垮页面

商品列表页如果直接SELECT * FROM goods,数据量一上来页面就卡死。分页是必须的,核心是LIMIT offset, size。

public List<Goods> findByPage(int page, int size, Integer categoryId) { StringBuilder sql = new StringBuilder( "SELECT g.*, u.nickname AS sellerName, c.name AS categoryName " + "FROM goods g " + "LEFT JOIN user u ON g.seller_id = u.id " + "LEFT JOIN category c ON g.category_id = c.id " + "WHERE g.status = 0 "); List<Object> params = new ArrayList<>(); if (categoryId != null) { sql.append("AND g.category_id = ? "); params.add(categoryId); } sql.append("ORDER BY g.create_time DESC LIMIT ?, ?"); params.add((page - 1) * size); params.add(size); List<Goods> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Goods g = new Goods(); g.setId(rs.getInt("id")); g.setTitle(rs.getString("title")); g.setPrice(rs.getBigDecimal("price")); g.setImageUrl(rs.getString("image_url")); g.setSellerName(rs.getString("sellerName")); g.setCategoryName(rs.getString("categoryName")); list.add(g); } } } catch (SQLException e) { throw new RuntimeException("分页查询失败", e); } return list; }

逻辑说明:用StringBuilder拼 SQL 是因为分类筛选是可选条件,用WHERE 1=1再拼AND是常见做法,但这里直接判断categoryId是否为空更清晰。LEFT JOIN保证即使卖家被删或分类被删,商品记录依然能查出来。ORDER BY create_time DESC让最新发布的排前面,符合二手平台的浏览习惯。参数上,(page - 1) * size是偏移量计算,第 1 页偏移 0,第 2 页偏移 size。注意:LIMIT的偏移量在数据量很大时性能会下降,但校园平台的数据量级完全够用。

3.3 下单流程:用事务保证订单和商品状态一致

下单涉及两个写操作:插入订单记录、更新商品状态为已售。这两步必须在一个事务里,否则可能出现「订单插入了但商品还在售」的数据不一致。

public boolean createOrder(int goodsId, int buyerId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 检查商品是否在售,并锁定该行 String checkSql = "SELECT seller_id, price, status FROM goods WHERE id = ? FOR UPDATE"; int sellerId; BigDecimal price; try (PreparedStatement ps = conn.prepareStatement(checkSql)) { ps.setInt(1, goodsId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next() || rs.getInt("status") != 0) { conn.rollback(); return false; } sellerId = rs.getInt("seller_id"); price = rs.getBigDecimal("price"); } } // 2. 不能买自己的商品 if (sellerId == buyerId) { conn.rollback(); return false; } // 3. 插入订单 String orderSql = "INSERT INTO orders (goods_id, buyer_id, seller_id, amount, status) VALUES (?, ?, ?, ?, 0)"; try (PreparedStatement ps = conn.prepareStatement(orderSql)) { ps.setInt(1, goodsId); ps.setInt(2, buyerId); ps.setInt(3, sellerId); ps.setBigDecimal(4, price); ps.executeUpdate(); } // 4. 更新商品状态 String updateSql = "UPDATE goods SET status = 1 WHERE id = ? AND status = 0"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, goodsId); if (ps.executeUpdate() == 0) { conn.rollback(); return false; } } conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new RuntimeException("下单失败", e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } } }

逻辑说明:SELECT ... FOR UPDATE在事务中锁定商品行,防止两个用户同时下单同一件商品。UPDATE goods SET status = 1 WHERE id = ? AND status = 0这个条件更新是第二道保险,如果商品已经被别人抢先改成已售,executeUpdate()返回 0,直接回滚。参数上,setAutoCommit(false)开启事务,commit()提交,rollback()回滚,finally里恢复自动提交并关闭连接。注意:FOR UPDATE必须在事务里才有意义,如果autoCommit是 true,锁会立即释放。

4. 避坑与排查:JSP + JDBC 项目最容易翻车的五个地方

4.1 中文乱码:POST 和 GET 要分开处理

现象:表单提交的中文标题在数据库里变成???,或者页面显示乱码。原因:POST 请求的编码由request.setCharacterEncoding控制,GET 请求的编码由服务器server.xml的URIEncoding控制,两者不是一回事。解决:POST 在 Servlet 第一行写req.setCharacterEncoding("UTF-8");GET 在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8"。数据库连接 URL 里也要带characterEncoding=utf8。JSP 页面顶部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>。

4.2 数据库连接泄漏:连接关了但没完全关

现象:项目跑一段时间后报Too many connections。原因:Connection、PreparedStatement、ResultSet三个资源只要有一个没关,连接就不会真正归还。解决:用 try-with-resources 语法,把资源声明在 try 的括号里,Java 会自动关闭。如果手写close,必须放在finally块里,并且每个资源单独 try-catch,避免一个关闭失败导致后面的关不掉。

4.3 JSP 页面报 404:路径到底该怎么写

现象:表单提交后跳转 404,或者图片加载不出来。原因:JSP 里的相对路径是相对于当前页面 URL,不是相对于项目根目录。比如/goods/detail.jsp里写src="uploads/1.jpg",浏览器会请求/goods/uploads/1.jpg。解决:所有路径用request.getContextPath()拼绝对路径,比如<img src="${pageContext.request.contextPath}/uploads/${goods.imageUrl}">。表单的action同理,用${pageContext.request.contextPath}/goods/publish。

4.4 重复下单:并发场景下的经典翻车

现象:两个用户同时点「立即购买」,结果生成两条订单,商品状态却只改了一次。原因:检查和更新之间有时间窗口,两个线程都通过了检查。解决:用SELECT ... FOR UPDATE锁行,或者用条件更新UPDATE goods SET status = 1 WHERE id = ? AND status = 0判断影响行数。更稳妥的做法是给orders表的goods_id加唯一索引,数据库层面兜底。

4.5 图片上传后不显示:目录权限和路径映射

现象:上传成功但页面显示裂图。原因:getRealPath("/uploads")指向的是部署目录,如果 IDE 重新部署项目,这个目录会被清空;另外 Tomcat 对非 Web 目录的文件访问有限制。解决:把上传目录配到项目外部,比如D:/campus_uploads,然后在 Tomcat 的server.xml里配一个<Context docBase="D:/campus_uploads" path="/uploads"/>。或者用 Servlet 写一个图片读取接口,从外部路径读流写回响应。

5. 进阶技巧:用过滤器统一编码和登录校验

项目写到后面你会发现,每个 Servlet 都写一遍setCharacterEncoding和登录判断,代码重复且容易漏。我一般会加两个 Filter:一个EncodingFilter统一设 UTF-8,一个LoginFilter拦截未登录用户。

@WebFilter("/*") public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } } @WebFilter(urlPatterns = {"/goods/publish", "/order/*", "/user/profile"}) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; if (request.getSession().getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }

逻辑说明:EncodingFilter拦截/*,在所有请求进入 Servlet 之前设好编码,Servlet 里就不用再写。LoginFilter只拦截需要登录的路径,用urlPatterns精确控制,避免把登录页和商品列表页也拦了。参数上,@WebFilter是 Servlet 3.0 注解,不需要在web.xml里配。注意:多个 Filter 的执行顺序由类名首字母决定,EncodingFilter要排在LoginFilter前面,否则登录跳转的中文提示会乱码。

验证方法很简单:不登录直接访问/goods/publish,应该被弹回登录页;登录后发一个中文标题的商品,数据库里应该存的是正常中文。我自己的习惯是每加一个 Filter,就用浏览器无痕模式跑一遍完整流程,因为无痕模式没有缓存和旧 Session,最容易暴露问题。这套东西不复杂,但把编码、登录、事务、分页这四个点做扎实,一个校园二手物品交易平台就已经能拿得出手了。希望帮到你。

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

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

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

立即咨询