简介:这是一套基于 Java+MySQL 实现校园二手交易市场的 Web 课程设计项目,主要面向计算机专业学生,适用于课程设计、毕业设计或 Java Web 综合实训。项目已完整实现用户注册登录、管理员对用户信息与买卖记录的管理、卖家发布下架修改商品、买家搜索排序分类加购下单,以及个人中心查看买卖记录与购物车等核心功能,并扩展了密码加密、邮箱通知和三段式交易确认流程,业务链路完整,模块划分清晰。资源包共 224 个文件,包含 37 个 Java 源码文件、24 个 HTML 页面、21 个 XML 配置、1 个 SQL 脚本,以及配套的 CSS/JS 前端样式、图片素材和 docx 设计文档,整包约 9.89MB,便于导入开发环境直接查看和运行。目前已有 120 人学习,适合需要参考完整前后端实现、数据库表结构设计及购物车交易逻辑的学习者,对照源码即可快速理解业务流程,也可基于此做二次功能扩展。
1. 校园二手交易市场这个Web项目,为什么值得用Java+MySQL做
毕业季的宿舍楼下最热闹,书、台灯、小电扇堆在一起等人认领。事实上校园内部对二手商品流通的需求很大,但想“建一个自己的平台”却大多卡在选择什么技术栈上。如果要把用户、商品、订单持久化下来,MySQL是关系型数据库里最稳妥的选择;要处理登录、发布、购买这些请求,Java生态里有充足又成熟的Web库。基于Java+MySQL实现Web版校园二手交易市场,本质上是在做一个B/S结构的C2C交易平台,业务范围精确控制在校园内,常见功能包括注册、登录、发布闲置、浏览商品、模拟下单和后台管理。这个项目往往出现在Java Web课程设计、毕业设计和Java初级岗位面试前的自测项目里。它不要求高并发和分布式,却能完整覆盖Servlet生命周期、JDBC操作、事务处理、会话跟踪和分页查询这些基本功,适合想用接近企业级套路做点实在东西的人。接下来要做的不是套用现成脚手架,而是亲手把表结构、接口和状态变更串起来。
2. 自下而上设计“校园二手交易市场”的MySQL表结构与Java对接
先用数据库做题,而不是先写代码。关键在于尽量贴近二手交易的真实形状,让每张表都能回答一个业务问题。很多初学者把商品和订单揉在一张表里,导致下单时大量更新冗余字段,这就是设计阶段埋下的坑。这里把用户、商品、订单、关注四个对象分开,并补上必要的外键关系,Java代码后续会简单很多。
2.1 从四个业务对象推出:用户表、商品表、订单表和关注表的结构
用户表存储在校生基本信息,只保留必要的登录字段。password字段建议存SHA-256哈希值,而不是明文;user_role用于区分普通用户和管理员,我一般用0表示普通学生,1表示管理员,2表示被禁用的用户。
商品表主要字段是发布者、标题、描述、图片路径、价格、状态和浏览量。status是整个二手市场最核心的状态位:0在架、1已拍下、2已卖出、3下架。订单表把买家、卖家和商品关联起来,冗余了price_snapshot,避免商品改价后历史订单金额跟着变。关注表是一个纯粹的中间表,记录用户对商品的收藏关系,不要用逗号分隔存入用户表。
四张表的分工见下表:
| 表名 | 负责的业务问题 | 核心字段 |
|---|---|---|
| store_user | 谁在买卖 | username, password, student_no, user_role |
| product | 有什么闲置商品 | seller_id, title, price, status, view_count |
| product_order | 一次交易如何流转 | product_id, buyer_id, seller_id, order_status |
| attention | 用户和商品的关注关系 | user_id, product_id |
下面是完整的建表SQL,适用于MySQL 5.7及以上版本:
CREATE DATABASE IF NOT EXISTS campus_market DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE campus_market; CREATE TABLE store_user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT '登录名', password CHAR(64) NOT NULL COMMENT 'SHA256后的64位哈希', nickname VARCHAR(32) NOT NULL, student_no VARCHAR(20) NOT NULL COMMENT '学号', user_role TINYINT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_student_no (student_no) ) ENGINE=InnoDB; CREATE TABLE product ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, seller_id INT UNSIGNED NOT NULL, title VARCHAR(80) NOT NULL, description_text TEXT NOT NULL, image_path VARCHAR(200) DEFAULT NULL, price DECIMAL(10,2) NOT NULL COMMENT '精确到分', status TINYINT NOT NULL DEFAULT 0 COMMENT '0在架 1已拍下 2已卖出 3下架', view_count INT UNSIGNED NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_seller (seller_id), KEY idx_status (status), CONSTRAINT fk_product_seller FOREIGN KEY (seller_id) REFERENCES store_user (id) ) ENGINE=InnoDB; CREATE TABLE product_order ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, product_id INT UNSIGNED NOT NULL, buyer_id INT UNSIGNED NOT NULL, seller_id INT UNSIGNED NOT NULL, price_snapshot DECIMAL(10,2) NOT NULL, order_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2已完成 3已取消', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_product (product_id), KEY idx_buyer (buyer_id), KEY idx_seller_snap (seller_id), CONSTRAINT fk_order_product FOREIGN KEY (product_id) REFERENCES product (id), CONSTRAINT fk_order_buyer FOREIGN KEY (buyer_id) REFERENCES store_user (id), CONSTRAINT fk_order_seller FOREIGN KEY (seller_id) REFERENCES store_user (id) ) ENGINE=InnoDB; CREATE TABLE attention ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, product_id INT UNSIGNED NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_product (user_id, product_id), CONSTRAINT fk_att_user FOREIGN KEY (user_id) REFERENCES store_user (id), CONSTRAINT fk_att_product FOREIGN KEY (product_id) REFERENCES product (id) ) ENGINE=InnoDB;建表时有四个细节值得展开。价格用DECIMAL(10,2),不推荐float或double,因为二进制浮点数处理0.29这种金额时会出现累加误差,二手交易就是小额多次,精确到分是最低要求。订单表里的price_snapshot是冗余字段,看起来违反了单一数据源原则,但它承担交易快照作用:商品即使后来改价,订单历史金额也不能变,结算时靠这张快照。product_order.product_id加了唯一键,限制同一商品只能有一条有效订单,这是业务上对“一物一单”的强制约束,否则同一买家重复下单会出现库存超卖。
除此之外,product表自身保存商品状态,状态判定要交给Java逻辑,数据库只提供约束。商品状态与订单状态必须同步变化,具体怎么做,第4章的状态机会专门讲。
2.2 索引与外键如何取舍:本项目为什么不设计级联删除
上面的建表语句中已经加了普通索引和外键。外键在互联网高并发场景常被嫌弃,但对课程设计和校园内部系统,外键能保证数据一致,优于在Java层手动检查。如果后续要拆微服务,再把这几个外键用触发器或应用逻辑替换。
我刻意没有写ON DELETE CASCADE。因为用户如果被删,商品和订单会变成无主数据。常见的做法是把用户禁用,user_role改成2,保留所有历史记录;商品下架也是把status改成3,而不是物理删除。这样attention和product_order不会引用到不存在的主数据,后台统计也始终有完整历史。很多答辩老师会问这个地方,回答了其实就是加分项。
索引数量上,只给外键列和状态列建了普通索引。0C在百万级数据以内,过多索引反而拖慢写入。UNIQUE KEY既是约束也是索引,uk_user_product直接覆盖了用户关注查询的联合索引需求,不需要再加冗余单列索引。
2.3 Java端连MySQL:驱动、连接参数和HikariCP初始配置
做Java Web项目时,不一定要立刻引入Spring Boot。用JDBC加一个轻量连接池就能把工程跑起来,而且对理解底层有好处。首先在pom.xml中加入依赖:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.4.0</version> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.1.0</version> </dependency>HikariCP是连接池,不是ORM。用它主要是避免每次请求都新建物理连接,否则并发数翻上去,三次握手和连接释放的开销就会拖垮Tomcat。我习惯写一个很小的数据库工具类,只暴露DataSource:
package com.campus.market.util; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; public class DbUtil { private static DataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/campus_market?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(20); config.setMinimumIdle(5); dataSource = new HikariDataSource(config); } public static DataSource getDataSource() { return dataSource; } }JDBC URL中的serverTimezone不能省略。MySQL 8的驱动对时区识别很严格,不写会报“The server time zone value ... is unrecognized”一类的错误。useSSL=false是因为校园局域网内传数据不需要TLS加密;allowPublicKeyRetrieval=true解决MySQL 8使用caching_sha2_password时首次连接拿不到RSA公钥的问题。characterEncoding=utf8负责通知驱动以UTF-8编码发送SQL和参数,与表级的utf8mb4不冲突,前者管传输,后者管存储。
连接池参数里,minimumIdle=5表示常驻连接保留5条,maximumPoolSize=20是上限。通常这两个值不需要调太大,HikariCP的设计是宁可等待获得连接,也不维护大量空闲连接。这样对Tomcat和MySQL的压力都比较友好。
3. 跑通校园二手交易市场的最小Web工程:从商品列表页开始接线
有数据库还不够,要让浏览器真正看到商品卡片,才算“Web版”。这里先用一个最小但完整的Maven工程把商品列表跑起来。先不追求复杂业务,把Controller、Service、DAO和JSP这条链路接通。
3.1 分层分包:在Java Web工程里把Servlet和JDBC分开
我一般把工程拆成Controller、Service、DAO三层,实体类和工具类单独建包。代码目录结构如下:
campus-web/ ├── pom.xml └── src/main/ ├── java/com/campus/market/ │ ├── controller/ # Servlet类 │ ├── service/ # 业务逻辑,比如发布和下单 │ ├── dao/ # ProductDao, UserDao │ ├── entity/ # Product, User, Order │ ├── filter/ # 登录过滤器 │ └── util/ # DbUtil, StringUtil └── webapp/ ├── WEB-INF/ │ └── web.xml ├── jsp/ │ ├── product_list.jsp │ └── login.jsp └── static/ # css/js/images各层职责如下表:
| 层 | 目录 | 主要职责 |
|---|---|---|
| Controller | controller | 接收HTTP请求,转发参数,跳转页面 |
| Service | service | 处理业务规则,管理事务边界 |
| DAO | dao | 执行SQL,把ResultSet映射成实体 |
| Entity | entity | 定义与表对应的POJO |
Controller不写SQL,Service不出现HTML标签,DAO不处理业务判断。这个拆法不是给新手增加负担,而是后续加功能时不至于在JSP里找Java代码。许多二手系统到后期改需求频繁,如果代码都堆在一个Servlet里,加一个字段就要翻好久。
3.2 使用Servlet注解暴露商品列表接口
Servlet 3.0以后web.xml可以非常精简,甚至不写。用@WebServlet注解把路径和类绑定起来。pom.xml需要引入Servlet API和JSTL依赖:
<dependencies> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>5.0.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>jakarta.servlet.jsp.jstl</groupId> <artifactId>jakarta.servlet.jsp.jstl-api</artifactId> <version>2.0.0</version> </dependency> </dependencies>这里用到的是Tomcat 10,所以Servlet包名是jakarta.servlet。如果是Tomcat 9,要把依赖换成javax.servlet-api 4.0.1。
商品列表是项目首页,最核心的Servlet如下:
@WebServlet(name = "ProductListServlet", urlPatterns = {"/product/list"}) public class ProductListServlet extends HttpServlet { private ProductService productService = new ProductService(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int page = 1; int size = 12; String pageParam = request.getParameter("page"); if (pageParam != null) { page = Integer.parseInt(pageParam); } String keyword = request.getParameter("keyword"); PageResult<Product> result = productService.queryOnSale(page, size, keyword); request.setAttribute("pageResult", result); request.getRequestDispatcher("/jsp/product_list.jsp").forward(request, response); } }这个Servlet把请求参数page和keyword转换成service入参,不关心数据库如何查询。page默认值是1,size固定12,校园二手市场一个学院页面在移动端一屏放12张卡片已经够用。注意Integer.parseInt如果遇到非法参数会抛NumberFormatException,我通常会加一个安全转换函数,解析失败就回落默认值。这个小细节是区分“能跑的示例”和“能上线的工程”的地方。
3.3 JSP用EL和JSTL渲染商品列表,分页参数如何传
product_list.jsp的职责是循环输出商品数据。下面的片段保留了核心结构,省略了大部分CSS:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>校园二手交易市场</title> </head> <body> <h1>正在出售</h1> <div class="product-grid"> <c:forEach items="${pageResult.records}" var="p"> <div class="card"> <img src="${p.imagePath}" alt="${p.title}"> <div class="title"><a href="/product/detail?id=${p.id}">${p.title}</a></div> <div class="price">¥${p.price}</div> <div class="seller">${p.nickname}</div> </div> </c:forEach> </div> <div class="pagination"> <c:if test="${pageResult.currentPage > 1}"> <a href="/product/list?page=${pageResult.currentPage - 1}">上一页</a> </c:if> <span>第 ${pageResult.currentPage} / ${pageResult.totalPage} 页</span> <c:if test="${pageResult.currentPage < pageResult.totalPage}"> <a href="/product/list?page=${pageResult.currentPage + 1}">下一页</a> </c:if> </div> </body> </html>PageResult里至少要有records、currentPage、totalPage、totalCount四个字段。Servlet把PageResult放进request作用域后,JSP通过EL表达式读取属性。这里的循环用JSTL而不是<%%>,是为了保持模板干净,也避免脚本变量污染。
商品图片image_path在表里存的是相对路径,比如/upload/2024/10/01/xxx.jpg。直接把这个路径写给img src后,Tomcat默认不会暴露磁盘任意目录,所以需要把图片上传到webapp/upload目录或额外配置静态资源映射。若是独立磁盘目录,要写一个Servlet或一个ResourceHandler把/upload/*指向绝对路径。
完成这一步后,访问http://localhost:8080/product/list,Tomcat会依据注解定位ProductListServlet,Servlet查询数据库后forward到JSP,JSP渲染成HTML响应到浏览器。这条链路就是整个Web项目的骨架。
4. 校园二手交易市场核心功能:登录校验、商品发布与订单状态机的细节实现
只有列表页还不算交易市场。以下是这个项目真正的难点,也是面试常问的点:登录后的会话保持、发布商品时的脏数据控制、订单状态如何不发生逻辑错乱。
4.1 登录后的会话保持:使用HttpSession还是Cookie
在Java Web里保存登录状态最常用的手段是HttpSession。登录成功时,把用户对象放入session:
User user = userService.login(username, password); if (user != null) { HttpSession session = request.getSession(); session.setAttribute("currentUser", user); response.sendRedirect(request.getContextPath() + "/product/list"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/jsp/login.jsp").forward(request, response); }后续请求都能通过request.getSession(false)取到用户。session超时时间可以在web.xml配置:
<session-config> <session-timeout>30</session-timeout> </session-config>单位是分钟。校园项目不需要做SSO,直接用HttpSession最简单。session-timeout不要设成30天,因为二手交易涉及财物交接,电脑忘在图书馆时,别人可能继续操作你的卖家账号。我一般设成15到30分钟。代价是发布商品时如果用户写描述时间太长,提交时可能session过期,处理办法是表达复杂内容时前端定时向服务器发一个轻量心跳请求,把session续期。
只用Session还不够,所有需要登录的路径都要做校验,重复代码积少成多。写一个LoginFilter统一拦截/admin路径:
@WebFilter(urlPatterns = {"/user/*", "/admin/*"}) public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); User user = (session == null) ? null : (User) session.getAttribute("currentUser"); if (user == null) { String currentUrl = req.getRequestURL().toString(); resp.sendRedirect(req.getContextPath() + "/login?redirect=" + URLEncoder.encode(currentUrl, "UTF-8")); return; } chain.doFilter(request, response); } }这个过滤器把未登录用户重定向到登录页,并携带redirect参数。登录成功后Servlet读取redirect参数,再把请求送回原页面。这样每个Servlet内部就不用重复判断登录状态,权限边界也很清晰。
4.2 发布商品接口的防脏数据:转义、字段校验和令牌防重
发布商品是数据最脏的入口。用户可能从Word里复制内容,带出各种特殊符号,甚至包含