简介:基于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 Result<Long> 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 依赖 Service,Service 依赖 Repository,理论部分和业务规则都在 Service 层收口。如果将来把 Spring Data JPA 换成 MyBatis,只需要动 Repository 层,Controller 和 Service 的接口保持不变。
2.2 数据库字段设计:文件夹命名连续,交易表不与逻辑
我见过不少项目在写代码前不设计表,写一个功能加一张表,最后表之间字段对不上。校园二手交易平台的核心表并不复杂,重点是把字段选对,避免在报告答辩时被问住。
商品表如果按 Spring Boot + MySQL 的常规方案设计,字段参考这样:
| 字段 | 类型 | 含义 | 备注 |
|---|---|---|---|
| id | BIGINT | 主键 | 自增,不用 int 防量级溢出 |
| seller_id | BIGINT | 卖家用户 ID | 与用户表逻辑关联 |
| title | VARCHAR(80) | 商品标题 | 列表页展示,加索引前缀 |
| description | TEXT | 商品描述 | 允许长文本 |
| price | DECIMAL(10,2) | 价格 | 不采 float/double,避免金额失真 |
| image_url | VARCHAR(512) | 首图地址 | 可以不保存扩展名 |
| status | VARCHAR(20) | 商品状态 | ON_SALE / SOLD / OFF_SHELF |
| created_at | DATETIME | 发布时间 | 默认 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里的UNIQUE KEY uk_item (item_id)能在数据库层面拦截重复下单。用户快速点击两次“立即购买”,即使前端的按钮被禁用晚了一拍,第二次 insert 也会因为唯一索引报错,从而避免脏数据。
2.3 JPA Repository 还是 MyBatis:按统计复杂度和入门门槛选
校园类项目里这两种框架都很常见,没有绝对对错。spring-boot-starter-data-jpa 的好处是 CRUD 代码量少,简单查询几乎不用写 SQL,缺点是一旦涉及分组统计、动态条件拼接,代码反而绕。MyBatis 的 SQL 写在 XML 里,排查问题时一条语句对应一段查询,思路清楚,缺点是每个实体都要维护 mapper 接口和 XML。
如果选择 JPA,Repository 可以这样定义:
public interface OrderRepository extends JpaRepository<Order, Long> { Optional<Order> findByItemIdAndStatusIn(Long itemId, List<OrderStatus> statuses); }findByItemIdAndStatusIn会由 Spring Data 自动解析成一条查询,条件是item_id = ?1 AND status IN ?2。写查询方法命名时要和实体字段名严格对应,itemId是 Order 的属性名,status是枚举字段,括号里的List<OrderStatus>对应 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 --from=builder /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 三个 service:
services: 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?useSSL=false&serverTimezone=Asia/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.mode=always会在应用启动时自动执行 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 Result<String> 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,商品改 SOLD |
| CREATED | 买家支付成功 | PAID |
| PAID | 双方线下核销 | COMPLETED |
| CREATED | 超时或取消 | CANCELLED |
| PAID | 申请取消 | 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 的@Modifying+@Query,写起来更清晰:
@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 乐观锁 | 无锁,提交时校验 | 冲突重试复杂 | 字段频繁修改的通用业务 |
如果不用条件更新,而是用Optional<Item> 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 里没有serverTimezone=Asia/Shanghai,MySQL 8 默认使用 UTC,本地查询出来的 created_at 会和实际时间相差 8 个小时。这个问题在代码 review 时不容易发现,但验收时一旦被演示发现,整个人都紧崩。把serverTimezone=Asia/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 之后再开始演示。
本文还有配套的精品资源,点击获取