简介:这是一套面向高校学生的Java SSM高校二手交易平台完整源码,适合作为毕业设计或课程设计选题,帮助解决校园内买卖双方信息对接与交易管理的实际问题。项目基于JDK1.8、Spring+SpringMVC+MyBatis框架与MySQL 5.7/8.0数据库构建,支持Eclipse与IDEA两种开发环境,并配套Navicat数据库管理方案,覆盖用户管理、商品发布、订单处理等典型业务模块。压缩包共1324个文件,约23.62MB,其中包含127个Java源文件、155个JSP页面、354个JavaScript脚本、137个CSS样式及大量图片与字体资源,另有SQL脚本与配置文件,结构完整、层次清晰。目前已有63人学习下载。读者可据此快速理解SSM分层架构与前后端交互流程,掌握数据库表设计与持久层映射方法,并在此基础上进行功能扩展或二次开发,是Java Web入门到实战的实用参考。
1. 从零到一:为什么 SSM 高校二手交易平台是毕设课设的“硬通货”
每年毕业季,计算机专业的群里总会上演同一幕:有人花三天拼凑出一份“学生管理系统”,查重时心惊胆战;有人则默默掏出一个带完整业务闭环的 Java SSM 高校二手交易平台,答辩时被老师追问“订单超卖怎么处理”也能对答如流。这两者的差距,不在代码行数,而在选题是否踩中了真实业务场景。高校二手交易平台天然具备用户、商品、订单、支付、评价五个核心域,麻雀虽小五脏俱全,正好覆盖 Spring + SpringMVC + MyBatis 的完整技术栈。对于正在找java 课程设计案例源码或纠结基于 java 的毕业设计选题的同学来说,这个题目既能体现工程能力,又不会因为业务过于冷门而找不到参考资料。我见过太多人把课设做成“增删改查大杂烩”,最后连自己都说不清事务边界在哪。这篇笔记就按我实际带学生做项目的路径,把 SSM 高校二手交易平台从环境搭建到订单并发处理拆一遍,新手能照着跑通,熟手能直接拿去改造成自己的毕设骨架。
2. 环境与骨架:把 SSM 高校二手交易平台的最小可运行版本跑起来
2.1 为什么 2025 年了还要用 SSM 而不是 Spring Boot
先泼一盆冷水:如果你的毕设要求里明确写了“SSM”或“Spring + SpringMVC + MyBatis”,那就老老实实按 XML 或注解配置走,别想着用 Spring Boot 一键起步然后告诉老师“底层还是 SSM”。答辩老师问一句“SpringMVC 的 DispatcherServlet 在哪配置的”就能让你原形毕露。SSM 的价值在于它把 Web 层的请求映射、业务层的依赖注入、持久层的 SQL 映射拆得清清楚楚,你调一个订单查询接口,能明确说出请求从 DispatcherServlet 到 HandlerMapping 再到 Service 和 Mapper 的完整链路。这种“看得见摸得着”的架构,恰恰是课程设计考察的重点。
我一般会让学生先建一个 Maven 项目,pom.xml 里锁定 Spring 5.3.x、MyBatis 3.5.x、MySQL 8.0 驱动和 Druid 连接池。版本不要追新,Spring 6 要求 JDK 17,很多学校机房还是 JDK 8 或 11,选 5.3.x 最稳。数据库用 MySQL 8.0,字符集统一 utf8mb4,否则用户昵称里的 emoji 存进去会变问号——这个坑每年都有人踩。
2.2 数据库表设计:五个核心域的最小字段集
高校二手交易平台的表不用多,但字段要想清楚。下面是我常用的最小表结构,直接给 SQL:
-- 用户表:区分学生和管理员,学号唯一 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `student_no` VARCHAR(20) UNIQUE NOT NULL COMMENT '学号,登录用', `nickname` VARCHAR(50) NOT NULL, `password` VARCHAR(64) NOT NULL COMMENT '存 MD5 加盐后的值', `role` TINYINT DEFAULT 0 COMMENT '0学生 1管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表:status 控制上下架,避免物理删除 CREATE TABLE `goods` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `seller_id` INT NOT NULL, `title` VARCHAR(100) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `category` VARCHAR(20) NOT NULL COMMENT '教材/电子/生活/其他', `status` TINYINT DEFAULT 1 COMMENT '0下架 1在售 2已售', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_seller (seller_id), INDEX idx_status_category (status, category) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:order_no 用时间戳+随机数,避免自增 ID 暴露单量 CREATE TABLE `orders` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) UNIQUE NOT NULL, `goods_id` INT NOT NULL, `buyer_id` INT NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0待付款 1已付款 2已完成 3已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_buyer (buyer_id), INDEX idx_goods (goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这三张表撑起了整个平台的核心流程。注意goods.status和orders.status的取值设计——很多同学喜欢用字符串存状态,查询时WHERE status = '已售'不仅慢,还容易因为空格或大小写导致查不到数据。用 TINYINT 加注释,前端传 0/1/2,后端枚举转换,这是最省心的做法。order_no用唯一索引而不是主键,是为了防止订单号重复导致插入失败,同时避免自增 ID 被爬虫遍历。
2.3 Spring + MyBatis 整合:三个必须配对的参数
整合 SSM 时,applicationContext.xml里最容易翻车的是数据源、SqlSessionFactory 和 MapperScannerConfigurer 这三者的配对。我见过有人把basePackage写成com.example.mapper但实际包名是com.example.dao,启动时报“No qualifying bean of type”,查半天以为是注解没加。下面是我常用的配置骨架:
<!-- 数据源:Druid 连接池,注意 url 里的时区和编码 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="url" value="jdbc:mysql://localhost:3306/second_hand?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <!-- SqlSessionFactory:扫描 mapper xml 的位置 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.entity"/> </bean> <!-- Mapper 接口扫描:basePackage 必须和实际接口包名一致 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.mapper"/> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean>serverTimezone=Asia/Shanghai这个参数不加,MySQL 8.0 驱动会报“The server time zone value is unrecognized”,这是血泪经验。maxActive设 20 对课设够用,设太大反而容易在答辩演示时因为连接泄漏把数据库拖垮。typeAliasesPackage让你在 XML 里写resultType="User"而不是全限定名,少打很多字。
2.4 用 Postman 验证第一个接口:商品列表分页
骨架搭好后,先写一个不涉及事务的查询接口验证链路。GoodsMapper.xml 里写:
<select id="selectByPage" resultType="Goods"> SELECT id, seller_id, title, price, category, status, create_time FROM goods WHERE status = 1 <if test="category != null and category != ''"> AND category = #{category} </if> ORDER BY create_time DESC LIMIT #{offset}, #{limit} </select>Service 层计算 offset:offset = (pageNum - 1) * pageSize。Controller 用@ResponseBody返回 JSON。启动 Tomcat 后,Postman 发GET /goods/list?pageNum=1&pageSize=10&category=教材,如果返回{"code":200,"data":[...]},说明 SpringMVC 的注解驱动、MyBatis 的映射、Druid 的连接全部通了。这一步跑通,后面写订单和并发才有底气。如果报 404,先看spring-mvc.xml里<mvc:annotation-driven/>和组件扫描的包名;如果报 500 且堆栈里有Invalid bound statement,九成是 mapperLocations 路径写错或 XML 的 namespace 和接口全限定名对不上。
3. 核心业务闭环:商品发布、下单与订单状态机
3.1 商品发布:图片上传与富文本的取舍
高校二手交易平台的商品发布,绕不开图片上传。课设里我建议用本地存储而不是对象存储,因为答辩环境不一定有外网。SpringMVC 的MultipartFile接收文件,存到项目外的/upload/目录,数据库只存相对路径。注意两个参数:maxUploadSize在spring-mvc.xml的CommonsMultipartResolver里设成 5MB,超过会抛MaxUploadSizeExceededException;文件名用UUID + 后缀重命名,否则两个学生同时上传IMG_001.jpg会互相覆盖。商品描述字段用TEXT类型,不要用VARCHAR(255),否则学生写两行配置说明就超了。
发布接口的 Service 方法要加@Transactional,因为插入商品后可能还要更新用户的发布计数(如果做了这个功能)。事务传播用默认的REQUIRED就行,别乱设REQUIRES_NEW,课设阶段没必要。
3.2 下单流程:从“点击购买”到订单落库的六步
下单是整个平台最容易出 bug 的地方。我把它拆成六步,每一步都有明确的输入输出:
- 前端传
goodsId和buyerId,后端先查商品是否存在且status = 1。 - 校验买家不能是自己(
seller_id != buyerId),否则提示“不能购买自己的商品”。 - 用
UPDATE goods SET status = 2 WHERE id = ? AND status = 1尝试锁定商品,返回影响行数。 - 如果影响行数为 0,说明商品已被别人抢先买走,抛自定义异常
GoodsSoldOutException。 - 影响行数为 1,生成订单号,插入
orders表,状态为 0(待付款)。 - 事务提交,返回订单号给前端跳转支付页。
第 3 步的AND status = 1是防超卖的关键。很多同学先SELECT查状态再UPDATE,两个请求并发时都查到 status=1,然后都去更新,结果同一件商品生成两笔订单。加上AND status = 1后,数据库的行锁会保证只有一个请求能更新成功。这个细节在答辩时被问到“并发怎么处理”,能直接加分。
3.3 订单状态机:用枚举替代魔法数字
订单状态从 0 到 3 的流转,如果代码里到处写if (status == 1),过两周自己都忘了 1 代表什么。我一般定义一个枚举:
public enum OrderStatus { UNPAID(0, "待付款"), PAID(1, "已付款"), FINISHED(2, "已完成"), CANCELLED(3, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public static OrderStatus of(int code) { for (OrderStatus s : values()) { if (s.code == code) return s; } throw new IllegalArgumentException("无效订单状态: " + code); } // getter 省略 }状态流转的校验放在 Service 里:待付款只能变已付款或已取消,已付款只能变已完成,已完成和已取消是终态。每次更新前用OrderStatus.of(currentStatus)拿到枚举,再判断目标状态是否合法。这样前端传错状态码时,后端能直接拦截并返回明确错误信息,而不是把脏数据写进数据库。
3.4 用 JUnit 验证下单并发:两个线程抢同一件商品
课设答辩时老师不一定让你现场写并发测试,但你自己得知道怎么验证。写一个简单的 JUnit 测试,用CountDownLatch让两个线程同时调用下单 Service:
@Test public void testConcurrentBuy() throws Exception { int goodsId = 1; // 确保这件商品 status=1 CountDownLatch latch = new CountDownLatch(1); AtomicInteger success = new AtomicInteger(0); AtomicInteger fail = new AtomicInteger(0); for (int i = 0; i < 2; i++) { final int buyerId = 100 + i; new Thread(() -> { try { latch.await(); // 两个线程同时起跑 orderService.createOrder(goodsId, buyerId); success.incrementAndGet(); } catch (GoodsSoldOutException e) { fail.incrementAndGet(); } catch (Exception e) { e.printStackTrace(); } }).start(); } latch.countDown(); Thread.sleep(2000); System.out.println("成功: " + success.get() + ", 失败: " + fail.get()); // 预期:成功 1,失败 1 }跑出来如果成功 2 失败 0,说明你的UPDATE ... AND status = 1没生效,或者事务隔离级别有问题。这个测试不用放进最终提交的代码里,但自己跑一遍,心里有底。注意测试完要把商品状态改回 1,否则后续演示时商品一直是已售。
4. 避坑与排查:SSM 高校二手交易平台最常见的五类翻车
4.1 中文乱码:从数据库到浏览器要过四道关
现象:商品标题在数据库里看是正常的,但页面上显示“æ•™æ��”。原因:字符集在四个地方可能不一致——数据库建库时用了 latin1、JDBC url 没加 characterEncoding、Tomcat 的 URIEncoding 没设、前端页面 meta 没声明 utf-8。解决:建库时CREATE DATABASE second_hand DEFAULT CHARSET utf8mb4;JDBC url 加characterEncoding=utf8mb4;Tomcat 的 server.xml 里 Connector 加URIEncoding="UTF-8";JSP 页面第一行<%@ page contentType="text/html;charset=UTF-8" %>。四道关全过,乱码基本绝迹。
4.2 事务不生效:@Transactional 加在 Controller 上
现象:下单时商品状态更新了,但订单插入失败,数据不一致。原因:@Transactional加在了 Controller 方法上,而 SpringMVC 的容器和 Spring 的容器是父子关系,Controller 里的事务注解默认不被扫描。解决:把@Transactional移到 Service 实现类的方法上,并确保spring-mvc.xml的组件扫描只扫 Controller,applicationContext.xml扫 Service 和 Mapper。两个容器的扫描包不要重叠,否则事务代理会失效。
4.3 MyBatis 返回 null:字段名和属性名没对上
现象:查询用户信息,nickname字段返回 null,但数据库里明明有值。原因:数据库字段是nickname,实体类属性是nickName(大写 N),MyBatis 默认不开启驼峰映射。解决:在applicationContext.xml的 SqlSessionFactory 里加<property name="configuration">配置mapUnderscoreToCamelCase=true,或者直接在 SQL 里写SELECT nickname AS nickName。我一般选前者,一劳永逸。
4.4 静态资源 404:DispatcherServlet 拦截了所有请求
现象:CSS 和图片加载不出来,浏览器控制台报 404。原因:web.xml里 DispatcherServlet 的 url-pattern 配了/,所有请求都走 SpringMVC,静态资源找不到对应的 Handler。解决:在spring-mvc.xml里加<mvc:default-servlet-handler/>和<mvc:resources mapping="/static/**" location="/static/"/>。前者把找不到的请求交回给 Tomcat 的默认 Servlet,后者显式映射静态资源目录。
4.5 订单号重复:用了时间戳但并发下毫秒相同
现象:极少数情况下插入订单报Duplicate entry for key 'order_no'。原因:订单号用System.currentTimeMillis()生成,两个请求在同一毫秒进来就撞了。解决:时间戳 + 用户 ID 后四位 + 三位随机数,或者直接用UUID.randomUUID().toString().replace("-", "")。课设数据量小,UUID 完全够用,别为了“看起来专业”去写雪花算法,引入额外复杂度。
5. 进阶技巧:用拦截器和 AOP 把重复代码抽干净
5.1 登录拦截器:三行配置省掉每个 Controller 的重复判断
平台里除了登录和商品列表,大部分接口都需要登录后才能访问。如果每个 Controller 方法开头都写if (session.getAttribute("user") == null) return error;,代码会臭得没法看。我一般写一个LoginInterceptor实现HandlerInterceptor,在preHandle里检查 session,放行登录页和静态资源。然后在spring-mvc.xml里配置:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/user/login"/> <mvc:exclude-mapping path="/user/register"/> <mvc:exclude-mapping path="/goods/list"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>exclude-mapping的顺序有讲究:先写具体路径再写通配,Spring 会按顺序匹配。如果/goods/list写在/static/**后面,虽然也能生效,但可读性差。拦截器里返回 false 之前,记得用response.setContentType("application/json;charset=utf-8")设置响应类型,否则前端拿到的是乱码的 302 跳转页。
5.2 AOP 记录操作日志:一个注解搞定
答辩时老师常问“有没有日志”,如果你说“System.out.println”,印象分直接打折。用 Spring AOP 写一个@Log注解,切面里获取当前登录用户、请求方法、参数和耗时,异步写入日志表。核心代码:
@Around("@annotation(com.example.annotation.Log)") public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; // 从 session 拿用户,从 request 拿 URI // 插入 log 表:user_id, method, params, cost, create_time return result; }注意pjp.proceed()只能调用一次,且必须返回其结果,否则 Controller 的返回值会变成 null。日志表不要和业务表放同一个事务,用@Transactional(propagation = Propagation.REQUIRES_NEW)或者直接异步线程池写入,避免日志插入失败导致下单回滚。
5.3 分页插件:PageHelper 的 startPage 必须紧贴查询
PageHelper 是 MyBatis 生态里最常用的分页插件,但用错顺序会翻车。正确写法:
PageHelper.startPage(pageNum, pageSize); List<Goods> list = goodsMapper.selectByPage(category); PageInfo<Goods> pageInfo = new PageInfo<>(list); return Result.success(pageInfo);startPage必须紧挨着查询方法,中间不能插入其他数据库操作,否则分页参数会被“消费”到错误的查询上。我见过有人在startPage和查询之间加了一行userMapper.selectById(),结果商品列表分页失效,查出来是全量数据。这个坑排查起来很费时间,记住“startPage 下一行就是查询”就行。
5.4 一个验证技巧:用 Chrome DevTools 看请求链路
答辩演示时如果接口报错,别慌。F12 打开 Network 面板,看请求的 Status Code 和 Response。如果是 500,点开 Response 看堆栈第一行;如果是 404,看 Request URL 是否拼错;如果是 200 但数据不对,看 Payload 里的参数是否和预期一致。这个习惯能让你在老师面前快速定位问题,而不是重启 Tomcat 碰运气。我带的几个学生里,凡是养成看 Network 习惯的,答辩时遇到突发 bug 都能在两分钟内找到原因。
最后说个我自己的教训:第一次做 SSM 项目时,我把所有配置都塞进applicationContext.xml,结果 SpringMVC 的 Controller 扫描和 Service 的事务扫描混在一起,事务死活不生效,查了两天才发现是父子容器的问题。后来我固定用两个配置文件——applicationContext.xml管 Service 和 Mapper,spring-mvc.xml只管 Controller 和视图解析,再也没在这上面翻过车。希望帮到你。
本文还有配套的精品资源,点击获取