ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

校园二手交易平台毕设:Java全栈实战与避坑指南

校园二手交易平台毕设:Java全栈实战与避坑指南

简介:这是一套面向高校计算机相关专业毕业设计场景的校园二手交易平台完整源码,采用Java技术栈开发,适合正在准备毕设或需要Java Web实战项目的学生参考。资源包共68个文件,以55个Java源文件为核心,配合yml与properties配置文件、xml映射文件、sql数据库脚本以及Maven构建相关文件,整体约125KB,结构紧凑、便于快速导入运行。项目围绕校园二手物品发布、浏览与交易等典型业务展开,涵盖后端接口、数据持久化与基础配置,能帮助读者理解一个完整Java项目的分层组织方式与数据库设计思路。目前已有583人学习下载,可作为毕设选题参考、代码复现与二次开发的起点,也方便对照自身项目查漏补缺。

1. 校园二手交易平台毕设:一个被低估的 Java 全栈练兵场

每年毕业季,总有一批同学在选题时盯着"校园二手交易平台"这几个字犯嘀咕——听起来太普通了,会不会被导师嫌弃没技术含量?我见过太多人因为这个判断失误,要么硬着头皮去做自己根本不熟的方向,要么随便糊弄一个最后答辩被问得哑口无言。实际情况恰恰相反:校园二手交易平台是一个被严重低估的 Java 全栈练兵场,它天然覆盖用户体系、商品发布、订单流转、站内消息、文件上传、权限控制这些企业级开发的标配模块,而且业务逻辑贴近真实场景,答辩时你能讲的东西远比一个"图书管理系统"要多得多。

这篇笔记面向的是拿到"基于 Java 的校园二手交易平台"这个选题、但不确定从哪下手的人。不管你是打算自己从零写,还是拿到一份参考源码想跑通再改,我都会把技术选型、数据库设计、核心接口实现、部署调试和常见翻车点拆开讲清楚。读完你应该能判断:这套东西值不值得投入时间做深,以及怎么在有限的时间里做出一个答辩能打、自己也能讲明白的系统。

2. 技术选型与工程骨架:为什么是 Spring Boot 加 MyBatis 而不是别的

2.1 选型逻辑:校园场景下什么组合最稳

做毕设和做生产项目最大的区别在于:你的时间有限,调试环境有限,答辩老师关心的不是你用了多新的框架,而是你能不能把一条完整的业务链路讲通。所以选型的第一原则是"生态成熟、资料多、出问题能搜到答案"。

后端我一般推荐 Spring Boot 2.7.x 加 MyBatis-Plus 的组合。Spring Boot 的好处是自动装配帮你省掉了大量 XML 配置,内嵌 Tomcat 让部署变成一条java -jar命令;MyBatis-Plus 则在 MyBatis 基础上提供了通用 CRUD、条件构造器和分页插件,对于二手交易平台这种以单表增删改查为主、偶尔联表查询的场景,开发效率比手写 SQL 映射高出一大截。

前端有两种路线:如果你们专业对前端要求不高,用 Thymeleaf 做服务端渲染最省事,页面直接由后端 Controller 返回;如果想让项目看起来更"现代",用 Vue 3 加 Element Plus 做前后端分离,后端只提供 REST 接口。两条路我都走过,前者适合两周内出成品,后者适合有一个月以上时间、想顺便练一下接口设计的情况。

数据库选 MySQL 8.0,缓存用 Redis 做会话管理和热点商品缓存,文件存储本地磁盘或者对象存储都行——毕设环境用本地磁盘就够了,别为了"显得高级"去接云存储,答辩时网络一抖你就尴尬了。

2.2 工程目录结构:按职责分层而不是按文件类型堆

很多人拿到源码后第一反应是打开src看有多少个包,然后就被绕晕了。一个清晰的 Spring Boot 项目应该按职责分层,我通常用这样的结构:

campus-trade/ ├── src/main/java/com/campus/trade/ │ ├── config/ # 配置类:跨域、拦截器、Redis、Swagger │ ├── controller/ # 接口层:接收请求、参数校验、返回结果 │ ├── service/ # 业务层:核心逻辑、事务控制 │ │ └── impl/ # 业务实现 │ ├── mapper/ # 数据访问层:MyBatis 接口 │ ├── entity/ # 数据库实体:与表一一对应 │ ├── dto/ # 数据传输对象:接收前端参数 │ ├── vo/ # 视图对象:返回给前端的结构 │ └── common/ # 通用类:统一返回、异常、常量 ├── src/main/resources/ │ ├── mapper/ # MyBatis XML(复杂查询才写) │ ├── application.yml # 主配置 │ └── static/ # 静态资源(Thymeleaf 路线用) └── pom.xml

这个结构的关键在于dto和vo的分离。新手最容易犯的错是直接用entity接收前端参数、也直接用entity返回数据,结果就是数据库字段暴露、密码字段被返回、前端传个role=admin就能提权。多写两个类,能省掉后面一堆安全排查的时间。

2.3 依赖配置:pom.xml 里必须锁定的几个版本

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <!-- Web 层:REST 接口和参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 参数校验:@NotNull @Size 等注解生效 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MyBatis-Plus:通用 CRUD 和分页 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <!-- MySQL 驱动:8.x 必须用新驱动类名 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <!-- Redis:会话和缓存 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- JWT:无状态登录令牌 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> </dependencies>

这里有几个版本坑要提前说:Spring Boot 2.7 和 3.x 的差别很大,3.x 要求 JDK 17 起步、javax包全部换成jakarta,如果你拿到的源码是 2.7 的,别手贱去升 3.x,改包名能改到你怀疑人生。MySQL 驱动从 8.0 开始类名是com.mysql.cj.jdbc.Driver,老教程里的com.mysql.jdbc.Driver会报废弃警告甚至连接失败。MyBatis-Plus 3.5.x 和 3.4.x 的分页插件配置方式不同,3.5 之后推荐用MybatisPlusInterceptor而不是老式的PaginationInterceptor。

2.4 主配置文件:连接池和 JWT 参数怎么设

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campus_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password hikari: maximum-pool-size: 10 # 毕设并发低,10 足够 minimum-idle: 2 connection-timeout: 30000 redis: host: localhost port: 6379 database: 0 timeout: 5000 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # user_name -> userName log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期打印 SQL jwt: secret: campus-trade-secret-key-2024-please-change-in-production expire: 604800 # 7 天,单位秒

map-underscore-to-camel-case这个配置一定要开,否则数据库的user_name字段映射不到 Java 的userName属性,查出来全是 null,新手能在这卡半天。log-impl在调试期打开,能看到实际执行的 SQL 和参数,排查问题时比猜快十倍,上线前记得关掉。

3. 数据库设计:六张核心表撑起整个交易链路

3.1 表结构规划:从业务动作反推字段

设计数据库不要一上来就想"我要建几张表",而是把用户能做的动作列出来:注册登录、发布商品、浏览搜索、下单、确认收货、评价、发消息。每个动作对应一到两张表,梳理完你会发现核心表就那么几张。

表名作用关键字段预估数据量
user用户信息id, username, password, nickname, avatar, credit_score千级
product商品信息id, seller_id, title, price, category_id, status, cover万级
category商品分类id, name, sort_order十级
order订单记录id, order_no, buyer_id, product_id, amount, status万级
message站内消息id, from_user, to_user, content, is_read万级
comment评价留言id, order_id, user_id, content, rating万级

product表的status字段是整个系统的状态机核心,我一般定义这几个值:0 待审核、1 在售、2 已锁定(有人下单未付款)、3 已售出、4 已下架。订单创建时把商品从 1 改成 2,付款后改成 3,取消订单改回 1。这个状态流转如果设计不清楚,后面会出现"同一件商品被两个人同时买走"的经典超卖问题。

3.2 建表 SQL:索引和约束一个都不能少

CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 加密后', `nickname` VARCHAR(50) DEFAULT NULL, `avatar` VARCHAR(255) DEFAULT NULL, `credit_score` INT DEFAULT 100 COMMENT '信用分,初始 100', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `seller_id` BIGINT NOT NULL, `title` VARCHAR(100) NOT NULL, `description` TEXT, `price` DECIMAL(10,2) NOT NULL, `category_id` INT NOT NULL, `cover` VARCHAR(255) DEFAULT NULL, `status` TINYINT DEFAULT 1 COMMENT '0待审 1在售 2锁定 3售出 4下架', `view_count` INT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_seller` (`seller_id`), KEY `idx_category_status` (`category_id`, `status`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `buyer_id` BIGINT NOT NULL, `seller_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0待付款 1已付款 2已完成 3已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_buyer` (`buyer_id`), KEY `idx_product` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

product表上的idx_category_status联合索引是给"按分类查在售商品"这个最高频查询准备的,顺序不能反——MySQL 联合索引遵循最左前缀原则,category_id在前才能命中。order表的order_no用唯一索引而不是主键,是因为业务订单号需要对外展示且不能重复,主键自增 ID 暴露出去容易被遍历。

3.3 实体类映射:MyBatis-Plus 注解怎么写

@Data @TableName("product") public class Product { @TableId(type = IdType.AUTO) private Long id; private Long sellerId; private String title; private String description; private BigDecimal price; private Integer categoryId; private String cover; private Integer status; private Integer viewCount; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; // 非数据库字段,联表查询时填充卖家昵称 @TableField(exist = false) private String sellerNickname; }

@TableField(exist = false)这个注解很关键,它告诉 MyBatis-Plus 这个字段在数据库里不存在,生成 SQL 时不要带上它。列表页需要显示卖家昵称,但product表里只有seller_id,这时候要么写联表查询,要么在 Service 层查完商品再批量查用户信息填充。前者 SQL 复杂但一次查询搞定,后者代码清晰但多一次数据库往返。数据量不大时我倾向后者,可读性更好。

4. 核心接口实现:登录、发布、下单三条主链路

4.1 登录鉴权:JWT 加拦截器的完整闭环

登录接口是整个系统的入口,也是最容易出安全问题的地方。密码绝对不能明文存,用 BCrypt 加密;登录成功后签发 JWT,后续请求通过拦截器校验令牌。

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @PostMapping("/login") public Result<LoginVO> login(@RequestBody @Valid LoginDTO dto) { // 1. 按用户名查用户,查不到统一报"用户名或密码错误" User user = userService.getByUsername(dto.getUsername()); if (user == null) { return Result.fail("用户名或密码错误"); } // 2. BCrypt 校验密码,不要用 equals 直接比 if (!BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.fail("用户名或密码错误"); } // 3. 签发 JWT,载荷只放 userId 和 username String token = JwtUtil.createToken(user.getId(), user.getUsername()); LoginVO vo = new LoginVO(); vo.setToken(token); vo.setNickname(user.getNickname()); vo.setAvatar(user.getAvatar()); return Result.ok(vo); } }

这里有两个细节值得说。第一,用户名不存在和密码错误返回同样的提示,防止攻击者通过错误信息枚举出哪些用户名已注册。第二,JWT 载荷里不要放敏感信息,JWT 只是 Base64 编码不是加密,任何人拿到令牌都能解出载荷内容,所以只放 userId 这种无意义的标识就够了。

拦截器负责校验令牌并把用户信息塞进 ThreadLocal,这样 Service 层随时能拿到当前登录用户:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求,否则跨域会失败 if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } try { Claims claims = JwtUtil.parse(token.substring(7)); UserContext.set(claims.get("userId", Long.class)); return true; } catch (Exception e) { response.setStatus(401); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 必须清理,否则线程池复用时会串用户 UserContext.remove(); } }

afterCompletion里的UserContext.remove()是血泪经验。Tomcat 的线程是复用的,如果不清 ThreadLocal,下一个请求可能拿到上一个用户的 ID,出现"张三登录看到李四的订单"这种玄学 bug,而且极难复现。

4.2 商品发布:文件上传和参数校验

发布商品涉及图片上传和表单提交,图片先传到服务器拿到 URL,再和商品信息一起入库。

@PostMapping("/publish") public Result<Long> publish(@RequestBody @Valid ProductDTO dto) { Long userId = UserContext.get(); Product product = new Product(); BeanUtils.copyProperties(dto, product); product.setSellerId(userId); product.setStatus(1); // 直接上架,也可设为 0 走审核 product.setViewCount(0); productService.save(product); return Result.ok(product.getId()); } @PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { // 1. 校验大小,超过 5MB 直接拒绝 if (file.getSize() > 5 * 1024 * 1024) { return Result.fail("图片不能超过 5MB"); } // 2. 校验后缀,白名单而不是黑名单 String original = file.getOriginalFilename(); String ext = original.substring(original.lastIndexOf(".") + 1).toLowerCase(); if (!Arrays.asList("jpg", "jpeg", "png", "webp").contains(ext)) { return Result.fail("只支持 jpg/png/webp 格式"); } // 3. 用 UUID 重命名,防止中文名和路径穿越 String filename = UUID.randomUUID() + "." + ext; Path target = Paths.get(uploadDir, filename); file.transferTo(target.toFile()); return Result.ok("/upload/" + filename); }

文件上传的坑集中在三点:后缀校验要用白名单,黑名单永远列不全;文件名必须重命名,否则用户传个../../etc/passwd就出事了;大小限制要在应用层和 Nginx 层都设,只设一层容易被绕过。

4.3 下单流程:用乐观锁解决超卖

下单是并发问题最集中的地方。两个人同时点"立即购买",如果不做控制,商品会被卖两次。

@Transactional(rollbackFor = Exception.class) public Long createOrder(Long buyerId, Long productId) { // 1. 查询商品,校验状态 Product product = productMapper.selectById(productId); if (product == null || product.getStatus() != 1) { throw new BizException("商品已下架或已售出"); } if (product.getSellerId().equals(buyerId)) { throw new BizException("不能购买自己发布的商品"); } // 2. 乐观锁更新:只有 status 还是 1 才能改成 2 int rows = productMapper.updateStatus(productId, 1, 2); if (rows == 0) { throw new BizException("手慢了,商品已被抢走"); } // 3. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setBuyerId(buyerId); order.setSellerId(product.getSellerId()); order.setProductId(productId); order.setAmount(product.getPrice()); order.setStatus(0); orderMapper.insert(order); return order.getId(); }

对应的 Mapper 方法用一条带条件的 UPDATE 实现乐观锁:

<update id="updateStatus"> UPDATE product SET status = #{newStatus} WHERE id = #{id} AND status = #{expectStatus} </update>

这条 SQL 的妙处在于:数据库层面保证了"检查状态"和"更新状态"是一个原子操作。两个并发请求同时执行,只有一个能更新成功(返回 1),另一个返回 0 直接抛异常。这比先查再改的写法安全得多,也比加分布式锁轻量。毕设场景下这种方案完全够用,不需要引入 Redis 锁或者消息队列。

5. 避坑与排查:那些让毕设卡壳的常见问题

5.1 跨域请求被拦截,前端一直报 CORS 错误

现象是前端调接口浏览器控制台报Access-Control-Allow-Origin相关错误,后端日志却显示请求根本没进来。原因是浏览器的同源策略拦截了响应,而不是请求没发出。解决方式是在配置类里统一处理跨域,注意allowedOriginPatterns和allowedOrigins的区别——前者支持通配符,后者在携带 Cookie 时不能用*。

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

如果用了 Spring Security,还要在 Security 配置里额外开启 CORS,否则拦截器会先于跨域配置生效。

5.2 上传的图片能存进去但访问 404

现象是上传接口返回成功,数据库里也有路径,但前端<img>标签加载不出来。原因是上传目录不在 Spring Boot 的静态资源映射范围内。默认情况下static、public、resources这几个目录才会被映射,你自己建的upload目录需要手动配置:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }

注意file:前缀和结尾的斜杠都不能少,路径用绝对路径而不是相对路径,否则打包成 jar 后工作目录变化会找不到文件。

5.3 分页查询总数不对,每页都显示全部数据

MyBatis-Plus 的分页必须配置拦截器,不配的话Page对象不会真正分页,SQL 里不会拼LIMIT。3.5.x 版本的配置方式:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

如果配了还是不对,检查是不是自定义的 XML 查询没有把Page作为第一个参数传入,MyBatis-Plus 靠这个参数识别需要分页的查询。

5.4 事务不生效,下单失败后商品状态没回滚

现象是下单过程中抛了异常,订单没创建,但商品状态已经被改成"锁定"了。原因是@Transactional注解失效,常见有三种情况:方法不是 public、同类内部方法直接调用、异常类型不在回滚范围内。默认只回滚RuntimeException和Error,如果抛的是受检异常,要显式写@Transactional(rollbackFor = Exception.class)。另外,如果你在 Service 里自己try-catch把异常吞了,事务也不会回滚,该往外抛就得往外抛。

5.5 部署到服务器后中文乱码

本地跑得好好的,一上服务器商品标题全变问号。排查顺序是:数据库连接串有没有加characterEncoding=utf8、数据库和表的字符集是不是utf8mb4、Tomcat 的server.xml里URIEncoding是不是 UTF-8、Nginx 有没有配charset utf-8。四个地方任何一个漏了都会乱码,而且表现可能不一样——有的是存进去就乱,有的是存对了读出来乱。我一般从数据库连接串开始查,这是最高频的出错点。

6. 让毕设从"能跑"到"能打":三个加分技巧

6.1 用 Redis 缓存热门商品列表

首页的商品列表是访问最频繁的接口,每次都查数据库没必要。用 Redis 缓存第一页数据,设置 60 秒过期,能明显降低数据库压力,答辩时也是一个可以展开讲的点。

public List<ProductVO> getHotList() { String key = "hot:products:page1"; // 先查缓存 List<ProductVO> cached = (List<ProductVO>) redisTemplate.opsForValue().get(key); if (cached != null) { return cached; } // 缓存未命中,查数据库 List<ProductVO> list = productMapper.selectHotList(10); redisTemplate.opsForValue().set(key, list, 60, TimeUnit.SECONDS); return list; }

要注意缓存和数据库的一致性问题:商品状态变更时(比如被买走),要主动删除对应的缓存 key,而不是等它自然过期。毕设场景下用"删除缓存"而不是"更新缓存"更简单也更不容易出错。

6.2 加一个简单的信用分机制

校园二手交易的核心痛点是信任。给用户加一个credit_score字段,初始 100 分,完成一笔订单加 2 分,被投诉扣 10 分,发布商品时如果信用分低于 60 就限制发布。这个机制实现起来不到 50 行代码,但答辩时能讲出"业务闭环"和"风控意识",比单纯堆功能要有说服力。

6.3 接口文档和统一返回格式

用 Swagger 或者 Knife4j 自动生成接口文档,答辩演示时直接打开文档页面调接口,比 Postman 截图专业得多。统一返回格式用Result<T>包装,包含code、message、data三个字段,前端处理起来也统一。这两件事花不了多少时间,但能让整个项目看起来"像个正经系统"而不是"学生作业"。

我做了这么多年项目,最大的教训就是:毕设的价值不在于功能多,而在于你能不能把一条链路从需求到实现到排错完整讲清楚。校园二手交易平台这个选题,只要你把登录鉴权、商品状态机、订单并发这三块吃透,答辩时老师问什么你都能接得住。别贪多去堆那些花哨的功能,把核心链路做扎实,比什么都强。希望帮到你。

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

RELATED READING

延伸阅读

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