简介:这份资源是大二小学期Java程序设计实践项目的完整源码包,主题为校园二手交易市场,面向正在完成Java课程设计、小学期实训或毕业设计起步阶段的学生,帮助解决从需求梳理到功能落地缺乏完整参考案例的问题。压缩包共223个文件,约9.97MB,以37个java源文件、24个html页面、21个xml配置、9个js与8个css样式文件为主,另含67张jpg与43张png界面素材、2份docx说明文档及md、properties等辅助文件,覆盖后端逻辑、前端页面、配置与静态资源等完整层次。内容预览可见商品详情、购物车、我的发布、上传、登录等模块样式与系统设计、使用须知文档,便于理解整体架构与页面组织。目前已有62人学习下载,适合作为课程设计参考、功能扩展基础或答辩演示素材,也可据此梳理二手交易平台的模块划分与实现思路。
1. 校园二手交易市场:大二小学期 Java 实践项目到底在练什么
大二小学期撞上 Java 程序设计实践,多数人第一反应是去搜「java 课程设计 管理系统 源码」,然后复制一份改改交差。但校园二手交易市场这个题目,恰恰是课程设计里最能拉开差距的一类:它同时踩中了用户体系、商品状态流转、并发下单、图片存储四个真实工程问题,而不是增删改查套壳。你如果只把它当成一次作业,做完只会写 Controller 调 Service;你如果把它当成一次小型电商系统的完整演练,做完能顺手把 java 基础、面向对象编程 java、java 容器、java 排序这些散点串成一条线。这篇笔记按「需求怎么拆 → 数据库怎么设计 → 后端怎么落地 → 坑在哪 → 怎么验证」的顺序讲,适合正在做大二小学期、想拿一个能写进简历的项目,或者想用 Spring Boot + MyBatis 把 Java Web 完整跑一遍的人。全程不依赖任何不存在的源码包,所有代码你都能自己敲出来。
2. 需求拆解与数据库设计:先把商品状态机想清楚
校园二手交易市场和普通电商最大的区别在于:它不需要复杂的营销体系,但必须处理「同一件二手商品只有一件」这个约束。这个约束直接决定了库存模型、订单模型和并发策略,是整个项目的地基。很多人一上来就建表写代码,做到一半发现「已售出的商品还能被下单」,回头改表结构,血泪经验。
2.1 从三个角色反推功能边界
先别急着画 ER 图,先把角色列出来。校园二手交易市场只有三类人:买家、卖家、管理员。买家要能浏览、搜索、下单、看订单;卖家要能发布、下架、处理订单;管理员要能审核商品、封禁用户、看统计。
把每个角色的动作写成动词短语,就是你的功能清单:
- 买家:注册登录、浏览商品列表、按分类/关键词搜索、查看商品详情、下单、查看我的订单、确认收货
- 卖家:发布商品(含图片)、编辑商品、下架商品、查看我卖出的订单、发货
- 管理员:登录后台、审核待审商品、下架违规商品、查看用户列表、禁用用户
这里有个容易忽略的点:二手商品的「发布」和「上架」不是一回事。发布后需要审核,审核通过才上架。这个状态区分是后面状态机设计的关键,也是很多同学答辩时被问倒的地方。
2.2 商品状态机:整个项目的核心
二手商品的生命周期比普通商品复杂,因为它有「唯一性」。我一般会把它设计成五个状态:
| 状态值 | 含义 | 可执行动作 |
|---|---|---|
| 0 | 待审核 | 管理员审核通过/驳回 |
| 1 | 在售 | 买家下单、卖家下架 |
| 2 | 已锁定 | 订单创建后锁定,等待支付/确认 |
| 3 | 已售出 | 订单完成 |
| 4 | 已下架 | 卖家主动下架或管理员强制下架 |
状态流转必须单向、可校验。比如从「在售」到「已锁定」只能由下单动作触发,从「已锁定」回到「在售」只能由订单取消触发。把这张表贴在显示器旁边写代码,能省掉大量返工。
2.3 建表 SQL 与字段说明
数据库用 MySQL 8.0,字符集 utf8mb4。核心五张表:用户表、商品表、商品分类表、订单表、订单明细表。二手交易其实可以简化掉订单明细,因为一件商品对应一个订单,但保留明细表方便以后扩展多件购买场景。
-- 用户表 CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL UNIQUE COMMENT '学号或昵称', `password` VARCHAR(64) NOT NULL COMMENT 'BCrypt 加密后存储', `nickname` VARCHAR(32) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0买家 1卖家 2管理员', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表 CREATE TABLE `product` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `seller_id` BIGINT NOT NULL COMMENT '卖家用户id', `category_id` INT NOT NULL, `title` VARCHAR(64) NOT NULL, `description` TEXT, `price` DECIMAL(10,2) NOT NULL, `cover_img` VARCHAR(255) COMMENT '封面图相对路径', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1在售 2锁定 3售出 4下架', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_seller (seller_id), INDEX idx_status_category (status, category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '业务订单号', `product_id` BIGINT NOT NULL, `buyer_id` BIGINT NOT NULL, `seller_id` BIGINT NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已完成 3已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_buyer (buyer_id), INDEX idx_product (product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段说明几个关键点。product.version是乐观锁版本号,用来解决并发下单问题,后面会展开。orders.order_no用业务订单号而不是自增 id 对外暴露,避免被爬虫遍历。price用 DECIMAL 不用 FLOAT,这是金融相关字段的铁律,FLOAT 的精度问题在金额计算上会翻车。
提示:建表时就把索引加上,尤其是
product表的(status, category_id)联合索引,商品列表页按状态和分类过滤是最高频查询,没有这个索引数据量一上来就慢。
3. Spring Boot + MyBatis 后端落地:从登录到下单的完整链路
技术栈选型上,大二小学期没必要上微服务,Spring Boot 3.x + MyBatis + MySQL + Redis(可选)足够。前端可以用 Thymeleaf 做服务端渲染,也可以用 Vue 前后端分离,看课程要求。这里讲后端核心链路,因为这才是 Java 程序设计实践真正考察的部分。
3.1 项目结构与依赖配置
用 Maven 建项目,pom.xml 核心依赖如下:
<dependencies> <!-- Web 基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 密码加密,别自己写 MD5 --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>spring-security-crypto这个依赖单独拎出来说:很多同学图省事用 MD5 存密码,答辩时被问「MD5 能不能反查」直接哑火。BCrypt 自带盐值且计算慢,抗彩虹表,是当前 Java 项目存密码的标准做法,引入成本极低。
application.yml 里配置数据源和 MyBatis:
spring: datasource: url: jdbc:mysql://localhost:3306/campus_market?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.campus.market.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case: true这一行能省掉大量resultMap手写映射,数据库的create_time自动映射到 Java 的createTime。serverTimezone必须配,否则 MySQL 8 连接会报时区错误,这是新手最常见的启动失败原因之一。
3.2 登录与 JWT 鉴权的最小实现
登录流程:前端传 username + password,后端查库比对 BCrypt 密文,通过后签发 JWT,后续请求带在 Header 里。不引入 Spring Security 全套,太重,用一个拦截器校验 JWT 就够。
@Service public class UserService { @Autowired private UserMapper userMapper; private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); public String login(String username, String rawPassword) { User user = userMapper.selectByUsername(username); if (user == null) { throw new BizException("用户不存在"); } if (user.getStatus() == 0) { throw new BizException("账号已被禁用"); } // BCrypt 比对,不要用 equals if (!encoder.matches(rawPassword, user.getPassword())) { throw new BizException("密码错误"); } // 签发 JWT,payload 放 userId 和 role return JwtUtil.sign(user.getId(), user.getRole()); } public void register(String username, String rawPassword, String nickname) { if (userMapper.selectByUsername(username) != null) { throw new BizException("用户名已存在"); } User user = new User(); user.setUsername(username); // 存加密后的密文 user.setPassword(encoder.encode(rawPassword)); user.setNickname(nickname); user.setRole(0); user.setStatus(1); userMapper.insert(user); } }逻辑说明:encoder.matches内部会从密文中解析出盐值再计算,所以同一个密码每次 encode 结果都不同,但 matches 都能通过。JWT 的 payload 只放 userId 和 role,不要放敏感信息,因为 JWT 只是 Base64 编码不是加密。参数上,rawPassword是前端传来的明文,user.getPassword()是库里的密文,顺序别写反。
拦截器校验 JWT:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录注册 String uri = request.getRequestURI(); if (uri.contains("/user/login") || uri.contains("/user/register")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !JwtUtil.verify(token)) { response.setStatus(401); return false; } // 把 userId 塞进 ThreadLocal,供后续业务取用 UserContext.set(JwtUtil.getUserId(token)); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler) { // 必须清理,否则线程池复用会串数据 UserContext.clear(); } }afterCompletion里的UserContext.clear()是必须的。Tomcat 线程池会复用线程,如果不清 ThreadLocal,下一个请求可能读到上一个用户的 userId,这是隐蔽性极强的 bug,本地单机测试根本发现不了。
3.3 商品发布与图片上传
商品发布涉及文件上传,这里用本地磁盘存储,路径按日期分目录,避免单目录文件过多。
@PostMapping("/product/publish") public Result publish(@Valid @RequestBody ProductPublishDTO dto) { Long sellerId = UserContext.getUserId(); Product product = new Product(); product.setSellerId(sellerId); product.setCategoryId(dto.getCategoryId()); product.setTitle(dto.getTitle()); product.setDescription(dto.getDescription()); product.setPrice(dto.getPrice()); product.setCoverImg(dto.getCoverImg()); // 新发布的商品进入待审核状态 product.setStatus(0); productMapper.insert(product); return Result.ok(product.getId()); } @PostMapping("/file/upload") public Result upload(@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()) { throw new BizException("文件为空"); } // 校验后缀,防止上传可执行文件 String original = file.getOriginalFilename(); String suffix = original.substring(original.lastIndexOf(".")).toLowerCase(); if (!Arrays.asList(".jpg", ".jpeg", ".png", ".webp").contains(suffix)) { throw new BizException("仅支持图片格式"); } // 按日期分目录 String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String dir = uploadRoot + "/" + datePath; File dirFile = new File(dir); if (!dirFile.exists()) { dirFile.mkdirs(); } // UUID 重命名,避免中文名和覆盖 String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; file.transferTo(new File(dir, fileName)); // 返回相对路径,前端拼接域名访问 return Result.ok("/upload/" + datePath + "/" + fileName); }参数说明:uploadRoot从配置文件读,不要硬编码绝对路径,换机器就挂。后缀校验用白名单不用黑名单,黑名单永远列不全。UUID 重命名解决两个问题:中文文件名在不同系统编码不一致,以及同名文件互相覆盖。返回相对路径而不是完整 URL,方便以后换域名或加 CDN。
3.4 下单接口与乐观锁防超卖
这是整个项目技术含量最高的地方。二手商品只有一件,两个买家同时点下单,如果不做并发控制,会生成两个订单,卖家发货时才发现货只有一件。
方案对比:悲观锁(select ... for update)会锁行,并发下容易死锁;分布式锁需要引入 Redis,对小学期项目偏重。乐观锁最合适,用版本号实现。
@Service public class OrderService { @Autowired private ProductMapper productMapper; @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public String createOrder(Long productId, Long buyerId) { Product product = productMapper.selectById(productId); if (product == null) { throw new BizException("商品不存在"); } if (product.getStatus() != 1) { throw new BizException("商品已售出或已下架"); } if (product.getSellerId().equals(buyerId)) { throw new BizException("不能购买自己的商品"); } // 乐观锁更新:状态从 1 改为 2,版本号加一 int rows = productMapper.lockProduct(productId, product.getVersion()); if (rows == 0) { // 更新失败说明被别人抢先了 throw new BizException("手慢了,商品已被抢走"); } // 创建订单 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setProductId(productId); order.setBuyerId(buyerId); order.setSellerId(product.getSellerId()); order.setAmount(product.getPrice()); order.setStatus(0); orderMapper.insert(order); return order.getOrderNo(); } private String generateOrderNo() { // 时间戳 + 随机数,保证唯一且不暴露自增 id return System.currentTimeMillis() + String.format("%04d", new Random().nextInt(10000)); } }对应的 Mapper XML:
<update id="lockProduct"> UPDATE product SET status = 2, version = version + 1 WHERE id = #{id} AND status = 1 AND version = #{version} </update>逻辑说明:lockProduct的 WHERE 条件同时校验status = 1和version = #{version}。如果两个线程同时读到 version=0,只有一个能更新成功(MySQL 行锁保证),另一个 rows 返回 0,直接抛异常。这样不需要显式加锁,靠数据库的原子更新就解决了并发。参数上,version必须是从查询结果里带过来的,不能写死。
注意:
@Transactional的rollbackFor = Exception.class必须加,否则抛出的是 RuntimeException 才回滚,自定义的 BizException 如果是受检异常就不回滚,订单创建了但商品没锁定,数据不一致。
4. 避坑与排查:那些答辩时被问倒的细节
这一章记录我在做这类项目时真实踩过的坑,每条按「现象 → 原因 → 解决」写,你照着排查能省下大量调试时间。
4.1 商品列表分页查询返回总数不对
现象:列表页显示「共 100 条」,但翻到第 3 页就没数据了。
原因:分页查询用了 MyBatis 的limit,但 count 查询和 list 查询的 WHERE 条件不一致。list 查询加了status = 1,count 查询忘了加,导致总数把待审核和下架的商品也算进去了。
解决:把 WHERE 条件抽成一个<sql>片段,count 和 list 都引用同一个片段。这是 MyBatis 分页的标准做法,别手写两遍。
<sql id="queryCondition"> <where> <if test="status != null">AND status = #{status}</if> <if test="categoryId != null">AND category_id = #{categoryId}</if> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> </where> </sql> <select id="countByCondition" resultType="long"> SELECT COUNT(*) FROM product <include refid="queryCondition"/> </select> <select id="listByCondition" resultType="Product"> SELECT * FROM product <include refid="queryCondition"/> ORDER BY create_time DESC LIMIT #{offset}, #{size} </select>4.2 图片上传后前端访问 404
现象:上传接口返回成功,路径也对,但前端<img src>加载不出来。
原因:Spring Boot 默认不会把本地磁盘目录映射成静态资源路径。文件确实存到了磁盘,但 HTTP 请求/upload/xxx.jpg找不到对应的资源处理器。
解决:加一个 WebMvcConfigurer 配置类,把上传目录映射出去。
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${upload.root}") private String uploadRoot; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 把 /upload/** 映射到磁盘目录 registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadRoot + "/"); } }注意file:前缀不能少,且路径结尾的斜杠要加,否则会拼错路径。
4.3 订单取消后商品状态没恢复
现象:买家取消订单,订单状态变成已取消,但商品还停留在「已锁定」,别人买不了。
原因:取消订单的逻辑只更新了 orders 表,忘了把 product 的 status 从 2 改回 1,version 也要加一。
解决:取消订单必须在一个事务里同时更新两张表,且商品状态恢复也要带乐观锁条件。
@Transactional(rollbackFor = Exception.class) public void cancelOrder(String orderNo, Long buyerId) { Orders order = orderMapper.selectByNo(orderNo); if (order == null || !order.getBuyerId().equals(buyerId)) { throw new BizException("订单不存在"); } if (order.getStatus() != 0) { throw new BizException("当前状态不可取消"); } orderMapper.updateStatus(orderNo, 3); // 商品从锁定恢复为在售,version 加一 productMapper.unlockProduct(order.getProductId()); }<update id="unlockProduct"> UPDATE product SET status = 1, version = version + 1 WHERE id = #{id} AND status = 2 </update>4.4 中文关键词搜索乱码
现象:搜索「教材」返回空,搜索「java」正常。
原因:数据库连接 URL 没配characterEncoding=utf8,或者表字符集是 latin1。中文在传输过程中被转成了问号。
解决:三处都要检查——数据库和表的字符集用utf8mb4,连接 URL 加characterEncoding=utf8,前端请求头Content-Type带charset=utf-8。三处缺一处都可能乱码。
4.5 启动报「Table 'xxx' doesn't exist」
现象:代码没问题,SQL 也执行了,启动就报表不存在。
原因:MyBatis 的map-underscore-to-camel-case只影响字段映射,不影响表名。如果实体类叫Product,默认找的表是product,但你的表可能叫products或者带前缀。
解决:在实体类上加@TableName("product")(MyBatis-Plus)或在 XML 里显式写表名。另外检查mybatis.mapper-locations路径对不对,XML 没被扫描到也会报这个错。
5. 验证与进阶:怎么证明你的项目真的能扛住并发
项目写完不算完,答辩时老师最爱问「你怎么证明它没问题」。这一章讲怎么用具体手段验证,以及几个能让你项目脱颖而出的进阶技巧。
5.1 用 JMeter 或并发脚本验证乐观锁
不用装 JMeter,写个简单的多线程测试就能验证超卖问题。用 Java 的CountDownLatch模拟 100 个线程同时下单同一件商品:
@Test public void testConcurrentOrder() throws InterruptedException { int threadCount = 100; CountDownLatch startGate = new CountDownLatch(1); CountDownLatch endGate = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); for (int i = 0; i < threadCount; i++) { final long buyerId = 1000 + i; new Thread(() -> { try { startGate.await(); // 所有线程在这里等,一起放行 orderService.createOrder(1L, buyerId); successCount.incrementAndGet(); } catch (Exception e) { // 抢不到的会抛异常,正常 } finally { endGate.countDown(); } }).start(); } startGate.countDown(); // 发令枪 endGate.await(); // 正确结果:successCount 必须是 1 System.out.println("成功下单数:" + successCount.get()); }跑完如果successCount是 1,说明乐观锁生效;如果是 2 或更多,说明你的 WHERE 条件写漏了。这个测试写进项目里,答辩时直接跑给老师看,比说一百句「我做了并发控制」都有用。
5.2 商品列表加 Redis 缓存
商品列表是读多写少,适合加缓存。引入spring-boot-starter-data-redis,在查询前先查缓存,查不到再查库并回写。注意缓存和数据库的一致性:商品状态变更时(下单、下架、审核)要删缓存,不是更新缓存。
public List<Product> listProducts(Integer categoryId, int page, int size) { String cacheKey = "product:list:" + categoryId + ":" + page + ":" + size; // 先查缓存 List<Product> cached = (List<Product>) redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } List<Product> list = productMapper.listByCondition(categoryId, (page - 1) * size, size); // 回写缓存,过期时间 5 分钟,防止数据长期不一致 redisTemplate.opsForValue().set(cacheKey, list, 5, TimeUnit.MINUTES); return list; }参数上,过期时间别设太长,二手商品状态变化频繁,5 分钟是读性能和一致性的折中。删除缓存的时机是商品状态变更之后,用redisTemplate.delete按前缀批量删,或者用keys命令(数据量大时慎用,会阻塞)。
5.3 一个容易被忽略的细节:订单号生成
前面用「时间戳 + 随机数」生成订单号,单机够用,但如果以后部署多台机器,同一毫秒可能撞号。更稳的做法是用「时间戳 + 机器标识 + 序列号」,或者直接用雪花算法。小学期项目用时间戳加随机数没问题,但你要知道它的边界在哪,答辩时能说出「单机够用,分布式需要换雪花算法」,这就是加分项。
5.4 我自己的习惯
我做这类项目有个固定习惯:每写完一个接口,先用 Postman 或 curl 单独测一遍,确认参数、返回值、异常分支都对,再写前端。很多同学前后端一起调,出了问题分不清是前端传错还是后端逻辑错,排查成本翻倍。另外,把每个接口的异常信息写清楚,比如「商品已售出」比「操作失败」有用得多,前端能直接弹给用户看。
这个校园二手交易市场项目,代码量不大,但把 Java Web 的核心链路走全了:分层架构、ORM 映射、事务、并发控制、文件上传、缓存。你把它做扎实,比做十个增删改查管理系统都值。希望帮到你。
本文还有配套的精品资源,点击获取