
简介基于SpringBoot的校园二手交易平台是一套可直接运行的毕业设计项目面向计算机相关专业学生、老师及入门开发者。平台覆盖商品发布、商品浏览、购物车等核心交易流程支持一键启动后端以Java和SpringBoot实现前端包含HTML、CSS、JavaScript页面并配有一份完整设计报告便于理解需求分析、模块划分与数据库设计。资源共416个文件以Java源码、JPG截图、XML配置、HTML/CSS/JS页面为主另含SQL数据库脚本与项目说明文档。其中152张JPG截图可用于界面参考106个Java类覆盖控制层、服务层与数据层69个zbak备份文件便于对照学习压缩包整体约29.83MB结构紧凑适合直接导入开发工具运行调试。目前已有51人学习下载可用作毕业设计、课程设计或项目演示原型。代码经过实测运行成功答辩平均分96分具备较好的参考价值在阅读README并遵守版权规定的前提下可基于现有功能进行二次修改和扩展。1. 为什么校园二手交易平台要特别关注“一键启动”“一键启动”对 Spring Boot 项目不是加分项而是交付底线。校园二手交易平台表面上看只是商品发布、浏览、下单实际跑起来会碰到 MySQL、Redis、图片目录、消息通知这些外部依赖。光是在验收集群里装好数据库、把端口改对就能劝退一批人。我拿到这类系统习惯先看启动脚本和配置文件而不是先数 Controller 里有多少行代码。源代码能编译环境也要能复现这才叫完整交付。下面按领域建模、容器化编排、交易链路、报告验收的顺序展开目标是让这套平台从“在 IDE 里能跑”变成“在全新电脑上一条命令能起”。2. Spring Boot 四层架构与核心领域模型先把骨架钉死再写业务代码2.1 Controller-Service-Repository 四层先分清“收数据”和“管业务”很多课程设计项目只有两层Controller 里写 SQL页面模板里写业务。这样的代码在功能演示时没区别一旦要加登录校验、事务回滚、单元测试就完全动不了。常见做法是严格分四层Controller 负责参数接收和响应封装Service 负责业务规则Repository 负责数据读写Entity 负责表映射。一个处理商品发布的 Controller 可以写成这样RestController RequestMapping(/api/items) public class ItemController { private final ItemService itemService; public ItemController(ItemService itemService) { this.itemService itemService; } PostMapping public ResultLong publish(RequestBody Valid ItemCreateRequest request, RequestAttribute(currentUserId) Long userId) { return Result.ok(itemService.publish(userId, request)); } }对应的 Service 实现Service RequiredArgsConstructor public class ItemServiceImpl implements ItemService { private final ItemRepository itemRepository; Override Transactional public Long publish(Long userId, ItemCreateRequest request) { if (request.getPrice() null || request.getPrice().compareTo(BigDecimal.ZERO) 0) { throw new BizException(价格必须大于 0); } Item item Item.builder() .sellerId(userId) .title(request.getTitle()) .description(request.getDescription()) .price(request.getPrice()) .imageUrl(request.getImageUrl()) .status(ItemStatus.ON_SALE) .build(); return itemRepository.save(item).getId(); } }RequestAttribute(currentUserId)里的 userId 是拦截器从登录态塞进去的不是前端传过来的防止用户伪造他人身份发布。价格校验放在 Service 而不是 Controller是为了让这个规则能直接被单元测试覆盖而不需要起一个 HTTP 服务。Transactional的意义在于后续如果还要扣减积分、记录操作日志它们会和商品插入同时成功或同时失败。四层架构不要求每个模块都新建四个包而是要求依赖方向从外向内Controller 依赖 ServiceService 依赖 Repository理论部分和业务规则都在 Service 层收口。如果将来把 Spring Data JPA 换成 MyBatis只需要动 Repository 层Controller 和 Service 的接口保持不变。2.2 数据库字段设计文件夹命名连续交易表不与逻辑我见过不少项目在写代码前不设计表写一个功能加一张表最后表之间字段对不上。校园二手交易平台的核心表并不复杂重点是把字段选对避免在报告答辩时被问住。商品表如果按 Spring Boot MySQL 的常规方案设计字段参考这样字段类型含义备注idBIGINT主键自增不用 int 防量级溢出seller_idBIGINT卖家用户 ID与用户表逻辑关联titleVARCHAR(80)商品标题列表页展示加索引前缀descriptionTEXT商品描述允许长文本priceDECIMAL(10,2)价格不采 float/double避免金额失真image_urlVARCHAR(512)首图地址可以不保存扩展名statusVARCHAR(20)商品状态ON_SALE / SOLD / OFF_SHELFcreated_atDATETIME发布时间默认 CURRENT_TIMESTAMP建表 SQL 这样写CREATE TABLE item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, seller_id BIGINT NOT NULL, title VARCHAR(80) NOT NULL, description TEXT, price DECIMAL(10, 2) NOT NULL, image_url VARCHAR(512), status VARCHAR(20) NOT NULL DEFAULT ON_SALE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_seller_status (seller_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;KEY idx_seller_status (seller_id, status)是为了支撑“我的在售商品”这个高频查询。如果缺少这个联合索引MySQL 会先按 seller_id 过滤再排序数据量稍大就会慢。image_url存相对路径而不是完整 URL是因为一键启动时 IP、端口都可能变化相对路径配静态资源映射更灵活。订单表还需要单独处理。一个商品在同一段时间只能有一个有效订单所以要对 item_id 建唯一索引CREATE TABLE order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, item_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, buyer_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL COMMENT CREATED/PAID/COMPLETED/CANCELLED, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_item (item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的UNIQUE KEY uk_item (item_id)能在数据库层面拦截重复下单。用户快速点击两次“立即购买”即使前端的按钮被禁用晚了一拍第二次 insert 也会因为唯一索引报错从而避免脏数据。2.3 JPA Repository 还是 MyBatis按统计复杂度和入门门槛选校园类项目里这两种框架都很常见没有绝对对错。spring-boot-starter-data-jpa 的好处是 CRUD 代码量少简单查询几乎不用写 SQL缺点是一旦涉及分组统计、动态条件拼接代码反而绕。MyBatis 的 SQL 写在 XML 里排查问题时一条语句对应一段查询思路清楚缺点是每个实体都要维护 mapper 接口和 XML。如果选择 JPARepository 可以这样定义public interface OrderRepository extends JpaRepositoryOrder, Long { OptionalOrder findByItemIdAndStatusIn(Long itemId, ListOrderStatus statuses); }findByItemIdAndStatusIn会由 Spring Data 自动解析成一条查询条件是item_id ?1 AND status IN ?2。写查询方法命名时要和实体字段名严格对应itemId是 Order 的属性名status是枚举字段括号里的ListOrderStatus对应 SQL 的 IN。第一次跑起来如果报“找不到属性 xxx”多半是实体字段名和这里的方法名不一致。我一般建议业务简单但报表需求量大的部分用 MyBatis核心 CRUD 用 JPA。两种框架在同一个 Spring Boot 项目中可以共存不会冲突。设计报告里写清楚为什么选 JPA 而不选 MyBatis比一笔带过更有说服力。3. 支持一键启动的 Docker Compose 编排把 MySQL、Redis、应用一起拉起来3.1 Dockerfile 多阶段构建把源码和运行环境分开传统部署是开发机装 JDK、MySQL、Redis然后双击 jar 包。换成 Docker 以后整个依赖链被固化在镜像和 compose 文件里。Spring Boot 的 Dockerfile 常见写法如下# 第一阶段编译 FROM maven:3.8-openjdk-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段运行 FROM openjdk:17-jdk-alpine WORKDIR /app COPY --frombuilder /build/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app.jar]mvn dependency:go-offline的作用是在复制源码之前提前把 pom 里的依赖拉下来这样后续改代码重新构建时这一层会直接命中缓存不用反复下载。-DskipTests是跳过测试如果设计报告里有 Test 类这里也不需要跑到。最后阶段故意不复制源码只复制 jar镜像体积会小很多也避免把源代码带到运行环境里。如果你的项目是 Spring Boot 2.x 且使用 JDK 8把 openjdk 版本改成maven:3.8-openjdk-8和openjdk:8-jdk-alpine即可。JDK 版本不要混用否则打包出来的 class 文件可能在运行时因版本不一致报错。3.2 docker-compose.yml三个容器之间的依赖顺序怎么控制一键启动的核心是 Docker Compose。在项目根目录放一个 docker-compose.yml包含 mysql、redis、app 三个 serviceservices: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: campus_trade ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, localhost] interval: 5s retries: 10 redis: image: redis:7-alpine ports: - 6379:6379 app: build: . ports: - 8080:8080 environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/campus_trade?useSSLfalseserverTimezoneAsia/Shanghai SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root SPRING_REDIS_HOST: redis depends_on: mysql: condition: service_healthy redis: condition: service_started volumes: mysql-data:这里有三个关键参数需要解释。depends_on在 Compose 里默认只控制容器启动顺序不代表服务已经就绪。Spring Boot 启动时如果 MySQL 还没有初始化完成会直接报“Communications link failure”。所以给 MySQL 加了healthcheck并且用condition: service_healthy来等待它通过健康检查。mysqladmin ping是 MySQL 镜像自带的小工具返回正常说明 MySQL 已经接受连接。volumes: mysql-data:/var/lib/mysql是把数据库文件持久化到命名卷里。没有这个配置每次docker compose down -v后数据都会被清空验收演示时手动创建的测试账号、商品数据全部丢失。SPRING_DATASOURCE_URL里写的是mysql:3306不是localhost:3306。这表示应用容器内部通过 Compose 网络访问 MySQL 服务名而不是宿主机的 127.0.0.1。只有你在开发环境直接跑 jar 包时才需要改成 localhost。3.3 一键启动脚本 start.sh 和 Spring Boot 配置的环境变量占位Compose 文件写好之后一键启动脚本其实只有三行#!/usr/bin/env bash mvn -DskipTests clean package docker compose down --remove-orphans docker compose up -d --buildmvn clean package是为了把最新的源代码打进 jar避免 docker build 时还在用上一次的 target 目录。docker compose down --remove-orphans会移除旧的容器防止端口被占用--remove-orphans是清理不在 Compose 文件中定义但仍在同一网络里的容器。up -d --build表示后台启动并且强制重新构建镜像确保代码变更生效。Spring Boot 的 application.yml 要配合 Compose 做环境变量注入不是把密码直接写死在代码里server: port: 8080 spring: datasource: url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/campus_trade} username: ${SPRING_DATASOURCE_USERNAME:root} password: ${SPRING_DATASOURCE_PASSWORD:root} sql: init: mode: always${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/campus_trade}的含义是优先读取名为 SPRING_DATASOURCE_URL 的环境变量如果没有读到就使用冒号后面的默认值。这样在 Docker 容器里走 Compose 注入在本地 IDE 里跑又不会强制要求装 Docker。spring.sql.init.modealways会在应用启动时自动执行 classpath 下的 schema.sql 或 data.sql。对一键启动很有用数据库是空的也能自动建表配合 Docker 的干净环境正好可以演示完整的初始化过程。这里要注意如果同时使用 JPA建议把spring.jpa.hibernate.ddl-auto设为none避免 JPA 自动改表结构和手写 SQL 冲突。注意Compose 文件不写version字段新版 Docker Compose V2 会忽略它写了反而不兼容。4. 核心交易链路的三个硬骨头图片上传、订单状态机、防重复下单4.1 图片上传从 MultipartFile 到静态资源访问商品不带图就很难吸引人。图片上传在 Spring Boot 里的实现一直不算复杂但容易在路径和文件类型上踩坑。先看接收文件的接口RestController RequestMapping(/api/upload) public class FileUploadController { Value(${app.upload-dir:uploads}) private String uploadDir; PostMapping(/image) public ResultString upload(RequestParam(file) MultipartFile file) throws IOException { if (file.isEmpty() || file.getSize() 5 * 1024 * 1024) { throw new BizException(图片大小不能超过 5MB); } String original StringUtils.cleanPath(file.getOriginalFilename()); String ext original.substring(original.lastIndexOf(.)); String filename UUID.randomUUID().toString().replace(-, ) ext; Path path Path.of(uploadDir, filename); Files.createDirectories(path.getParent()); file.transferTo(path); return Result.ok(/uploads/ filename); } }StringUtils.cleanPath用来去掉文件名里的..和/\防止用户传一个../../etc/passwd之类的名字造成路径穿越。文件名改成 UUID 是为了避免中文文件名在部分系统上出现编码问题同时杜绝两个用户上传相同文件名互相覆盖的情况。Files.createDirectories会在目录不存在时自动创建不然transferTo会抛 NoSuchFileException。如果只写了上传接口访问图片时 Spring Boot 默认找不到/uploads/这个路径需要配置静态资源映射app: upload-dir: ${UPLOAD_DIR:uploads} spring: web: resources: static-locations: file:${UPLOAD_DIR:uploads}/,classpath:/static/static-locations后面有多个路径时Spring Boot 会按照顺序查找。file:uploads/表示本机磁盘目录classpath:/static/表示项目自带的静态资源。上传目录里的图片在页面里可以直接用img src/uploads/xxx.jpg引用不需要额外写一个 byte[] 下载接口。4.2 订单状态机把 if/else 收拢成一张可校验的表订单处理如果直接写 if 嵌套最典型的问题是状态改着改着就不知道该从哪个状态到哪个状态。更好的做法是先定义一张状态迁移表代码再照着表实现。当前状态触发事件下一状态ON_SALE买家下单订单 CREATED商品改 SOLDCREATED买家支付成功PAIDPAID双方线下核销COMPLETEDCREATED超时或取消CANCELLEDPAID申请取消CANCELLED下单这一步涉及两条数据变更订单插入和商品状态更新。把它们放在同一个事务方法里Transactional public Long createOrder(Long buyerId, Long itemId) { int changed itemRepository.markSold(itemId); if (changed 0) { throw new BizException(商品已下架或已卖出); } Item item itemRepository.findById(itemId).orElseThrow(); Order order Order.builder() .itemId(itemId) .sellerId(item.getSellerId()) .buyerId(buyerId) .status(OrderStatus.CREATED) .build(); return orderRepository.save(order).getId(); }markSold使用条件更新只有数据库中 status 还是ON_SALE时才把状态改成SOLD并返回受影响行数。因为是单条 UPDATE 语句MySQL 会锁定这一行两个线程同时下单时第二个线程会等到第一个提交后再执行 UPDATE此时 status 已经变了受影响行数为 0进入异常分支。这样就不需要先select再update把竞态窗口关掉了。生产者消费者场景下“买家支付成功”在校园二手平台里通常是一个模拟支付接口不能真的接第三方支付。可以让前端调一个/api/orders/{id}/pay接口后端只校验订单状态是否为 CREATED然后改成 PAID记录支付时间即可。答辩时可以说这是为了模拟完整流程避免真实资金接口的资质要求。4.3 防重复下单并发控制条件更新优先于先查后改上一节的markSold是一种乐观锁思想的简化版。如果使用 JPA 的ModifyingQuery写起来更清晰Modifying Query(update Item i set i.status SOLD where i.id :id and i.status ON_SALE) int markSold(Param(id) Long id);这里的执行结果是 int 型被影响的行数。如果返回 1说明当前线程拿到了这个商品如果返回 0说明商品已经被其他人买走直接抛出业务异常。相比select ... for update这种条件更新在商品状态枚举有限的情况下是最轻量的并发控制手段。对于真正需要防超卖的库存类字段可以在实体里加Version private Long version;每次更新时 JPA 会自动在 UPDATE 里带上where version ?。但在校园二手交易这个场景一个商品只有一件用状态当乐观锁已经足够不需要额外版本号。方式锁范围并发表现适用场景悲观锁 select for update行级排他锁并发下排队库存金额强一致冲突多条件更新行级写锁但只在写瞬间写冲突概率低时更佳商品状态流转version 乐观锁无锁提交时校验冲突重试复杂字段频繁修改的通用业务如果不用条件更新而是用OptionalItem item itemRepository.findById(itemId);再判断 status两个请求可以同时读到ON_SALE然后都执行成功这就在应用层出现了超卖。数据库唯一索引uk_item是最后一道防线即使应用层有 bug第二条订单也插不进去。5. 把“完整设计报告”变成可验证的交付物5.1 设计报告里的四类图每一张都要和代码对应拿到源代码之后设计报告往往被当成附赠文档。实际上报告里最重要的不是几百字功能描述而是四类图实体关系图、用例图、时序图、部署图。ER 图要能看到用户、商品、订单、收藏几个表的主外键和一对一关系用例图需要画出学生、管理员两类角色覆盖发布商品、浏览查询、下单支付、留言举报时序图重点画下单流程中 Controller、Service、Repository 三者之间的调用顺序部署图标清楚 8080、3306、6379 三个端口。图表只要有两三张能自洽答辩就能少很多无意义的追问。5.2 用 Spring Boot Actuator 验证一键启动是否真的健康既然支持一键启动就要有一种方式能快速判断整个系统是否就绪。Spring Boot Actuator 能在不写业务代码的情况下暴露健康检查接口。在 pom.xml 里添加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency在 application.yml 里只暴露两个端点management: endpoints: web: exposure: include: health,info endpoint: health: show-details: always执行curl http://localhost:8080/actuator/health如果返回{status:UP}说明应用本身、数据源、Redis 都正常。一旦 MySQL 连不上status 会变成 DOWN并且在 details 里标注数据源的错误信息。这里的要点是只暴露 health 和 info不要像网上很多文章那样把env、beans、heapdump全开否则会把环境变量、内网 IP、堆内存快照都暴露出来正是之前常见的“actuator 未授权访问”问题。5.3 交付前最后检查的三个点第一个检查点是 MySQL 时区。如果连接的 URL 里没有serverTimezoneAsia/ShanghaiMySQL 8 默认使用 UTC本地查询出来的 created_at 会和实际时间相差 8 个小时。这个问题在代码 review 时不容易发现但验收时一旦被演示发现整个人都紧崩。把serverTimezoneAsia/Shanghai直接写在 application.yml 的数据源 URL 里一劳永逸。第二个检查点依赖 Jakarta 命名空间。Spring Boot 3.x 已经把javax.persistence换成jakarta.persistence如果你拿到的源代码还是javax.*要么项目是 2.x要么需要批量替换。启动时如果看到 NoClassDefFoundError先检查 import 语句别急着改业务代码。第三个检查点必须验证本地刚拉下来的代码和打包进容器的 jar 一致。经常出现站点代码已经改成RequestMapping(/api/orders)但容器里跑的还是上一个旧包导致请求 404。最简单的做法是 start.sh 里每次都执行mvn clean package同时把docker compose up --build后面的-d改成前台输出观察启动日志里有没有出现 “No active profile” 或数据源连接失败的警告。curl 到/actuator/health返回 UP 之后再开始演示。本文还有配套的精品资源点击获取