ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于SpringBoot的校园二手交易平台全栈源码解析与毕设实战

基于SpringBoot的校园二手交易平台全栈源码解析与毕设实战

简介:这是一套基于SpringBoot的校园二手交易平台完整项目,面向计算机相关专业毕业设计学生,以及需要项目实战练习的Java学习者,也可用于课程设计或期末大作业。项目分为前端展示与后台管理两大模块:前端实现产品分类、产品明细、用户注册、产品公告、在线支付、个人中心等功能;后台涵盖用户管理、产品管理、订单管理、钱包管理和系统设置。压缩包共1081个文件,约5.89MB,以HTML页面、JavaScript脚本、CSS样式、Java源码及SQL数据库脚本为主,另有配置文件与项目说明文档,结构清晰便于直接导入运行。项目经严格调试确保可用,已吸引5508人学习下载。附带的数据库脚本和工具说明可帮助快速搭建环境,适合需要完整参考实现或在此基础上扩展功能的开发者。

1. 校园二手交易平台:一套能直接答辩的 SpringBoot 全栈源码

我见过太多毕业设计死在同一个地方:PPT 上架构图画得漂亮,一开电脑连数据库都连不上。这套基于 SpringBoot 的校园二手交易平台,好就好在它是按「能跑、能演示、能回答追问」的标准打包的——后端是 SpringBoot + MyBatis 的标准分层,前端是服务端渲染的 Thymeleaf 页面,另附完整的数据库脚本和部署说明。你拿到手之后,先把 SQL 导进 MySQL,改一行配置,mvn spring-boot:run起来,浏览器打开就能注册、发布商品、下单交易。

这套系统的核心链路是「发布 → 浏览 → 下单 → 收货」,覆盖了商品分类、搜索筛选、收藏夹、订单状态流转、个人中心这些二手平台的必备环节。如果你是准备毕设答辩的本科生,或者想快速搭一个课设 demo 练手,这套代码的注释和分层方式都值得直接复用。下面我从项目结构、数据库设计、运行步骤到避坑逐一拆开讲。

2. 项目结构与核心模块:先看清 SpringBoot 是怎么分层的

拿到压缩包先别急着跑。你要做的是先理解这套代码的组织方式,不然答辩时老师随便问一句「你这个 Controller 层和 Service 层怎么协作的」,你就露馅了。

2.1 标准的三层架构:从 Controller 到 Mapper 的调用链

解压后你会看到一个典型的 Maven 工程结构。src/main/java下面按包名划分层级,com.example.secondhand是根包。注意看这三个关键的包:

  • controller:接收前端请求,返回页面或 JSON 数据,不写业务逻辑
  • service:业务逻辑层,事务控制在这里,比如下单时要同时扣减库存和生成订单
  • mapper:数据访问层,对应 MyBatis 的接口,XML 里的 SQL 都写在resources/mapper目录下

我建议你从GoodsController开始读。它的代码很典型,处理的是二手商品的核心操作——发布、编辑、上下架、按条件查询,几乎每一步都是现在企业里仍在沿用的写法:

@Controller @RequestMapping("/goods") public class GoodsController { @Autowired private GoodsService goodsService; @GetMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "8") Integer pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) Integer categoryId, Model model) { // 分页查询,keyword 支持模糊匹配标题和描述 PageInfo<Goods> pageInfo = goodsService.findPage(pageNum, pageSize, keyword, categoryId); model.addAttribute("pageInfo", pageInfo); model.addAttribute("keyword", keyword); model.addAttribute("categoryId", categoryId); // 这里返回的是 Thymeleaf 模板名,渲染后在浏览器展示商品列表 return "goods/list"; } }

这段代码里有几个细节值得你在答辩时主动讲出来:@RequestParam(defaultValue = "1")处理了前端不传页码时的容错;keyword和categoryId是可选的查询条件,传了才拼接 SQL,不传就返回全量分页。PageInfo是 PageHelper 分页插件返回的对象,包含总页数、当前页、每页条数,前端模板直接遍历pageInfo.list就能渲染。

对应的 Service 实现要注意事务。发布商品涉及插入商品记录,同时可能要更新用户的发布数量,这类操作必须加@Transactional,否则中途报错会出现脏数据。这套代码里GoodsServiceImpl的写法就是标准示范。

2.2 前端 Thymeleaf 模板:服务端渲染的取舍

前端用的是 Thymeleaf 模板引擎,页面文件在src/main/resources/templates下。你可能会问:为什么不前后端分离?这个选择其实是合理的——毕设场景下,服务端渲染能少搭一套前端工程,部署时也不用担心跨域问题,老师现场演示时刷新页面就能看到数据变化,效果更直观。

看一下商品详情页的核心片段,goods/detail.html里这样展示商品状态:

<div class="goods-status"> <span th:if="${goods.status == 1}" class="badge badge-success">在售</span> <span th:if="${goods.status == 2}" class="badge badge-secondary">已售出</span> <span th:if="${goods.status == 3}" class="badge badge-dark">已下架</span> </div>

th:if是 Thymeleaf 的条件渲染指令,goods.status是从 Controller 的 Model 里取出的字段值。状态字段用整数表示,语义清晰,比存字符串更省空间,这也是数据库设计时的一个常见取舍。

2.3 登录与拦截器:Session 校验是怎么串起来的

二手平台必须有登录态控制,不然任何人都能乱下单。这套系统用的是 Session + 拦截器的方式,LoginInterceptor实现了HandlerInterceptor接口:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从 Session 里取用户,为空说明未登录 User user = (User) request.getSession().getAttribute(Constants.SESSION_USER); if (user == null) { // 未登录跳转到登录页,同时记录用户本来想访问的地址 response.sendRedirect("/login?redirect=" + request.getRequestURI()); return false; } // 已登录放行 return true; } }

拦截器在WebMvcConfig里注册,并配置了放行规则。/login、/register、/goods/list、/goods/detail这些页面是不需要登录就能看的,但发布商品、下单、进入个人中心这些操作必须拦截。你注意看preHandle里拼接了redirect参数,这样用户登录完成之后可以跳回原来的页面,体验上比登录后回首页要自然得多。这个小细节,答辩时提一下,老师会觉得你考虑过真实场景。

3. 数据库脚本拆解:五张核心表如何支撑交易闭环

压缩包里的sql目录放着建库脚本,文件名一般是secondhand.sql,用 Navicat 或命令行直接导入就能用。这套数据库设计不复杂,但完整覆盖了二手交易的关键路径。

3.1 核心表结构:用户、商品、订单、收藏、分类

整个业务围绕五张表展开。先看最核心的t_goods表,字段设计是这样的:

字段类型说明
idbigint主键,自增
titlevarchar(100)商品标题
descriptiontext商品描述
pricedecimal(10,2)售价
original_pricedecimal(10,2)原价,用来展示折扣
category_idint分类外键
seller_idbigint发布者外键
statustinyint1在售 2已售出 3下架
view_countint浏览数
created_atdatetime发布时间

price用 decimal 而不是 float,是为了避免浮点数精度问题。二手交易对价格精度敏感,用 float 计算可能出现 9.99 显示成 9.9900001 的情况。

t_order表需要额外说明。它设计为包含buyer_id和goods_id两个外键,一笔订单关联一个买家和一个商品。注意这里有一个精心考虑的点:订单表里冗余了goods_title和goods_image字段——为什么要冗余?因为商品可能被卖家删除或下架,如果订单只存了goods_id,商品没了订单页面就显示不出来了。冗余存储商品快照是电商系统里的常见做法,毕设里这样设计是加分项。

3.2 关键 SQL 脚本:外键约束与初始化数据

建表脚本里值得关注的是外键的设置。看一下简化后的核心语句:

CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` varchar(32) NOT NULL COMMENT '订单编号', `buyer_id` bigint NOT NULL COMMENT '买家ID', `seller_id` bigint NOT NULL COMMENT '卖家ID', `goods_id` bigint NOT NULL COMMENT '商品ID', `goods_title` varchar(100) DEFAULT NULL COMMENT '商品快照标题', `amount` decimal(10,2) NOT NULL COMMENT '成交金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待付款 1待发货 2待收货 3已完成', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_goods_id` (`goods_id`), KEY `idx_buyer_id` (`buyer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

注意我加了idx_buyer_id和idx_goods_id普通索引,而不是用外键约束。这是有意的取舍:MySQL 的外键约束在插入和更新时会有额外的锁开销,而且二手平台的商品和订单关系用应用层逻辑控制就够了。如果你在答辩时被问到「为什么不用 FOREIGN KEY」,可以说「线上交易系统为避免死锁和提升写入性能,通常只用逻辑外键加索引」。

初始化脚本末尾还有一段插入分类的 SQL。这是三手商品分类的种子数据:图书教材、数码电子、生活用品、运动器材、美妆服饰、其他。如果你要自定义分类,直接改这段INSERT就行,前端分类栏会自动读取。

3.3 订单状态机:数据库字段与应用逻辑的配合

订单状态字段status是整个系统里最有技术含量的设计点。它不是简单的增删改查,而是一个状态机。这套系统里订单走的是五步流转:

  • 0 待付款:买家下单后未支付
  • 1 待发货:买家付款后,卖家需要发货
  • 2 待收货:卖家发货后,买家确认收货
  • 3 已完成:买家确认收货,交易闭环
  • 4 已取消:买家或卖家主动取消

对应的状态流转校验写在 Service 层。比如买家点击「确认收货」时,代码会校验当前状态必须是 2,否则抛异常。这种「数据字段定义状态,代码逻辑控制流转」的方式比用定时任务扫描状态更可控,也更容易在答辩时讲清楚。

4. 从零跑通:环境准备、配置修改与启动全流程

源码拿到手,第一步永远是把它跑起来。这个环节翻车率最高,我按自己实操的习惯把流程拆成四步,每步都写了踩坑点。

4.1 环境版本对照:这些版本组合经过了验证

老规矩,先看环境。这套项目是基于JDK 1.8开发的,用的是Spring Boot 2.3.x版本。这里要特别提醒:你现在电脑上如果装的是 JDK 17 甚至 JDK 21,直接跑大概率会报错,因为 Spring Boot 2.3 底层依赖的某些库对高版本 JDK 的模块化支持不友好。

我验证过能稳定运行的组合是这样的:

组件推荐版本说明
JDK1.8兼容性最好,毕设环境首选
Maven3.6.x3.8+ 也能用,但可能因为中央仓库地址调整下载失败
MySQL5.7 或 8.0脚本兼容两种版本,但 8.0 需要改驱动配置
IDEIDEA 2020+高版本 IDEA 也能导入,关键是把 Project SDK 指到 JDK 1.8

如果你坚持用 JDK 17,也不是不行,但需要在pom.xml里升级 Spring Boot 到 2.7.x,并且确认所有依赖都有兼容版本。折腾成本高,我不建议新手这么干。

4.2 配置修改:application.yml 里必须改的三个位置

打开src/main/resources/application.yml,你会发现数据库连接信息是占位符,需要改成你自己的。这是启动之前最重要的配置修改:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/secondhand?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: "123456" thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.secondhand.entity configuration: map-underscore-to-camel-case: true

注意三处:url里的secondhand必须是你在 MySQL 里建的库名,确保数据库脚本导入时用的库名和这里一致;username和password改成你本地的账号密码;serverTimezone=Asia/Shanghai是必须要加的,否则 MySQL 8.x 连接时会因为时区问题报错。

map-underscore-to-camel-case: true是 MyBatis 的驼峰映射开关。数据库字段是created_at,Java 属性是createdAt,开启这个配置后 MyBatis 会自动完成映射,不用手写一大堆resultMap。这个配置一定要确认是 true,否则你查出来的对象全是 null。

4.3 导入数据库脚本:命令行导入的正确姿势

很多人用 Navicat 导入脚本,结果中途报错停了,原因是 Navicat 在导入时遇到一些 SQL 版本兼容问题会直接中断。我更推荐用命令行导入:

mysql -u root -p < /path/to/secondhand.sql

如果是 Docker 起的 MySQL,进容器再导入:

docker exec -i <容器名> mysql -u root -p < /path/to/secondhand.sql

导入完成后,验证一下表是否建全了:

mysql -u root -p -e "USE secondhand; SHOW TABLES;"

正常情况下应该看到t_user、t_goods、t_order、t_favorite、t_category这五张表。如果少了表,说明脚本执行中断了,最可能的原因是 MySQL 8.0 对utf8mb4的默认配置和 5.7 有差异,导致某段建表语句执行失败。这时候单独执行报错的 SQL 语句就行,不用整库重新导入。

4.4 启动项目与首次验证:从控制台日志判断成败

在 IDEA 里导入 Maven 项目,等依赖下载完成后,运行Application.java里main方法。启动过程盯着控制台,看到Started Application in x.xxx seconds就说明启动成功了。

打开浏览器访问http://localhost:8080,首页会出现商品列表。这时候你做什么验证?我一般这样测:

  1. 注册一个新账号,测试注册页的密码加密是否正常
  2. 登录后发布一件测试商品,标题写「测试商品勿拍」
  3. 到商品列表页搜索刚发布的商品标题,验证模糊搜索生效
  4. 换一个账号登录,对这件商品下单,验证订单创建逻辑
  5. 退出登录,尝试直接访问/goods/publish,观察是否被拦截器重定向到登录页

这套流程走完,系统的核心链路就算验证通过了。

5. 避坑排查:我拆这套系统时踩过的五个坑

每套毕设源码都有一些「作者知道但没写进文档」的坑,我把最常见的五条整理出来,按现象 → 原因 → 解决的顺序写。

5.1 启动报错:Access denied for user 'root'@'localhost'

现象:启动 Spring Boot 项目时控制台报数据库连接失败,错误信息是Access denied for user 'root'@'localhost' (using password: YES)。

原因:application.yml里的密码和本地 MySQL 实际密码不一致。还有一种可能是你用了 MySQL 8.0 的密码加密方式,而驱动版本不支持。

解决:确认password字段的值正确。如果你本地 MySQL 的 root 密码本来就为空,yml 里就写password: ""。如果用的是 MySQL 8.0,确认pom.xml里的mysql-connector-java版本是 8.0.x,并且在 url 里加了serverTimezone参数。

5.2 页面中文乱码:商品标题显示为问号

现象:发布商品后,列表页标题显示为???,但纯数字和英文正常。

原因:创建数据库时字符集不是 utf8mb4。MySQL 5.7 的默认字符集是 latin1,不支持中文存储。

解决:在 MySQL 里执行ALTER DATABASE secondhand CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;。同时确认连接 URL 里已经有characterEncoding=utf8。另外,IDEA 里打开application.yml时,确认文件编码是 UTF-8,不然配置里的中文注释会乱。

5.3 静态资源 404:CSS 样式加载不出来

现象:页面能打开,但所有样式图片全丢了,控制台报 404。

原因:Spring Boot 默认静态资源路径是classpath:/static/。如果你把页面放在templates目录下,但 CSS 和 JS 文件放在了别的位置,或者th:href写成了绝对路径,就会 404。

解决:检查src/main/resources/static目录是否存在,并且 CSS 文件确实在里面。页面模板里引用时用th:href="@{/css/style.css}"这种上下文相对路径,不要写死成/css/style.css这种根路径,否则部署到子路径时全挂。

5.4 MyBatis 查询结果全为 null

现象:页面能打开,列表能分页,但每行数据都是空的,字段一个都显示不出来。

原因:数据库字段是下划线命名(created_at),Java 属性是驼峰命名(createdAt),MyBatis 没有开启驼峰映射,导致自动映射失败。

解决:在application.yml的mybatis.configuration下加上map-underscore-to-camel-case: true。加完之后重启应用,不需要改任何 XML 文件。这是一个极其常见的配置遗漏,排查优先级排在所有问题前面。

5.5 端口被占用:8080 已被其他程序使用

现象:启动时控制台报Port 8080 was already in use。

原因:本地有别的项目占用了 8080,比如之前启动过的服务没关掉。

解决:两种办法,一种是改项目端口,在application.yml里把server.port改成8081;另一种是找到占用程序并杀掉。Windows 上先netstat -ano | findstr 8080查到 PID,然后taskkill /F /PID <pid>。macOS 和 Linux 用lsof -i :8080配合kill -9。

6. 进阶技巧:把订单状态机改成枚举,再给商品加图片上传

基础流程跑通之后,如果你想在答辩里展示一点超出课程设计水平的东西,我推荐做两件事:把订单状态从魔法数字改成枚举,以及补上商品图片上传功能。这两个改动成本不高,但足以让老师觉得你理解了工程化的写法。

6.1 用枚举替代魔法数字:让状态流转不再靠猜

原代码里订单状态用的是0、1、2、3、4这些魔法数字,Service 层写满了if (status == 1)。我建议你新建一个枚举类来封装:

public enum OrderStatus { PENDING_PAYMENT(0, "待付款"), PENDING_SHIPMENT(1, "待发货"), PENDING_RECEIPT(2, "待收货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } // 提供一个根据 code 获取枚举的静态方法 public static OrderStatus fromCode(int code) { for (OrderStatus status : OrderStatus.values()) { if (status.code == code) { return status; } } throw new IllegalArgumentException("未知订单状态: " + code); } }

核心价值在于fromCode方法。当数据库查出来的订单状态是2时,调用OrderStatus.fromCode(2)得到的是PENDING_RECEIPT,配合枚举里的desc字段,页面直接显示「待收货」。以后你再也不用在 Controller 里写if (status == 2) { text = "待收货"; }这种样板代码了。

6.2 补一个本地上传:图片存磁盘,URL 存数据库

原系统如果没做图片上传,你可以自己补一个极简版本。在application.yml里配置上传路径:

file: upload-dir: D:/upload/ access-path: /upload/**

然后写一个FileController:

@RestController @RequestMapping("/file") public class FileController { @Value("${file.upload-dir}") private String uploadDir; @PostMapping("/upload") public String upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return "上传失败,文件为空"; } // 生成唯一文件名,防止覆盖 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext; try { file.transferTo(new File(uploadDir + newName)); // 返回可访问的 URL 路径,存到数据库 goods.image 字段 return "/upload/" + newName; } catch (IOException e) { e.printStackTrace(); return "上传失败"; } } }

关于file.transferTo有两点要注意。第一,uploadDir必须是绝对路径,写相对路径的话,启动目录不同会导致文件存到意想不到的位置。第二,MultipartFile.transferTo在某些操作系统上对跨磁盘的文件操作有限制,如果你把临时目录和上传目录放在不同盘符,可能会抛FileSystemException。常见做法是改用Files.copy(file.getInputStream(), Paths.get(uploadDir, newName)),用 io 流拷贝更稳。

做完这两个改动,你的系统就不再是「能跑的课设」,而是有了工程化的雏形。这套源码里的注释、分层、数据库字段命名都相当规整,我拆的时候基本没遇到需要靠玄学才能解决的问题。

最后说点实在的——这套资源拿到手之后,先花半天把数据库脚本和项目跑通,再花半天根据我上面说的两个方向做小改造,你的毕设至少能到良以上。不要偷懒跳过第 5 章那些坑,我拆的时候每个都踩过一遍,提前知道能省至少两小时。希望你顺利过审,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进