简介:这套Java二手车交易拍卖系统源码工程面向高校计算机相关专业的毕业设计场景,适合正在准备毕设、需要可运行项目作参考的本科生,也适合想借小型管理系统走完需求分析到测试全流程的初学者。压缩包约10.96MB,上游未细分文件清单,实际提供程序源代码一份与配套数据库一份,按配置环境说明操作即可正常部署运行。工程按软件工程思路组织:先通过需求分析确定系统主要功能,再完成功能设计、总体结构设计、数据结构设计与安全设计,随后落地数据库访问实现与各主要功能模块的具体编码,并保留关键代码说明,末尾附功能测试与结果分析。读者可据此理解二手车拍卖业务的建模方式,参考模块划分与数据表设计,对照关键代码做二次开发或答辩演示,也能借鉴测试用例的整理思路。已有2281人学习。
1. 二手车拍卖系统的难点不在页面,在出价那一秒
某开发同学拿着毕业设计题目来找我,说页面都画好了,结果一模拟两个人同时出价,价格就乱了。二手车拍卖和普通商品下单不一样,普通电商是库存减一,拍卖是价格往上顶,而且有截止时间。这个项目就是一个基于Java的二手车交易拍卖系统,Spring Boot 起服务,MySQL 存车辆、场次、出价和订单,Redis 处理竞价并发和场次倒计时缓存。它适合两类人:一类是拿它做毕业设计、需要完整业务流程和可演示后台的;另一类是想练手高并发出价、定时结算、状态机这些后端常见问题的。下面从目录结构一路拆到出价接口和二次开发,能跑起来的部分我给命令,能改的地方我标参数。
2. 技术选型与目录结构:一个 Spring Boot 单应用怎么装下拍卖流程
2.1 为什么这个场景用单应用而不是微服务
二手车拍卖的业务边界其实很清楚:用户、车辆、场次、出价、订单、后台。毕业设计或者中小型项目,我一般不建议上来就拆微服务。原因很实际:出价要校验场次状态、保证金、加价幅度、当前最高价,这些校验放在一个事务里最省心;拆成服务后分布式事务和跨服务查询会把简单问题复杂化。这个项目用 Spring Boot 单应用,按 controller / service / mapper 分层,MyBatis-Plus 做持久层,Redis 只承担两个职责:缓存热门场次的当前价和做竞价短锁。前端常见做法是 Thymeleaf 服务端渲染加少量 Ajax,也有用 Vue + Element UI 前后端分离的版本,目录里一般会多一个front或admin静态工程。
| 层次 | 选型 | 用途 |
|---|---|---|
| 后端框架 | Spring Boot 2.x | 统一配置、内嵌 Tomcat、快速启动 |
| 持久层 | MyBatis-Plus | 单表 CRUD、条件构造器、分页 |
| 数据库 | MySQL 8 | 车辆、场次、出价、订单、用户 |
| 缓存/并发 | Redis | 场次当前价缓存、出价短锁 |
| 模板/前端 | Thymeleaf + Bootstrap 或 Vue | 用户端和后台页面 |
| 定时任务 | Spring Task | 拍卖结束结算、流拍处理 |
| 文件存储 | 本地磁盘 + 静态映射 | 车辆图片、检测报告 |
提示:如果下载到的工程里没有 Redis 依赖,先看
pom.xml里有没有spring-boot-starter-data-redis,没有的话出价并发那块可能是用数据库乐观锁兜底的,跑之前要确认。
2.2 目录结构:从 controller 到 mapper 的一条线
单模块工程的典型目录长这样:
car-auction/ ├── src/main/java/com/example/auction/ │ ├── AuctionApplication.java # 启动类 │ ├── config/ # Web、Redis、MyBatis-Plus、拦截器配置 │ ├── controller/ # 用户端和后台接口 │ ├── service/ # 业务接口 │ │ └── impl/ # 拍卖、出价、订单实现 │ ├── mapper/ # MyBatis-Plus Mapper │ ├── entity/ # 数据库实体 │ ├── dto/ # 请求/响应对象 │ ├── vo/ # 页面展示对象 │ ├── task/ # 定时结算任务 │ ├── common/ # 统一返回、异常、常量 │ └── utils/ # 日期、金额、文件工具 ├── src/main/resources/ │ ├── application.yml # 数据源、Redis、上传路径 │ ├── mapper/ # XML(复杂查询才用) │ ├── static/ # css、js、图片 │ └── templates/ # Thymeleaf 页面 └── pom.xml这个结构不新鲜,但对拍卖系统够用。我一般会先看controller里有哪些入口,再看service/impl里哪个类最长,通常最长那个就是出价或者结算。车辆发布、审核、上架这些是普通 CRUD,真正容易翻车的是出价和关闸。后台管理一般单独一个AdminController,路径带/admin,登录拦截器会区分角色。
2.3 依赖和配置:跑起来之前先对这三处
pom.xml里关键依赖通常包括:
<dependencies> <!-- Web 和模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Redis,出价锁和缓存用 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>application.yml里要改的主要是数据源、Redis 和上传目录:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/car_auction?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted auction: upload-path: D:/car-auction/upload/ deposit-rate: 0.05 # 保证金比例 bid-step: 100 # 默认加价幅度 delay-seconds: 120 # 最后两分钟出价自动延时参数说明:serverTimezone必须写,不然时间字段容易差 8 小时;deposit-rate是保证金占起拍价比例,改这个会影响出价资格;delay-seconds是防狙击的延时窗口,很多拍卖系统都有,没写的话最后几秒出价会直接卡死。上传路径要提前建好目录,Windows 下注意结尾斜杠。
注意:MySQL 8 的驱动类名是
com.mysql.cj.jdbc.Driver,如果下载到的工程里写的是com.mysql.jdbc.Driver,启动会报弃用警告,改成前者即可。
配置对完后,按三步启动:建库、导 SQL、跑起来。命令贴在下面,执行前确认本地 MySQL 和 Redis 都起来了。
# 1. 建库 mysql -uroot -p -e "create database car_auction default charset utf8mb4;" # 2. 导入表结构和初始数据 mysql -uroot -p car_auction < sql/car_auction.sql # 3. 启动 mvn spring-boot:run启动后访问http://localhost:8080看首页,/admin看后台。如果首页空白,先看控制台有没有模板解析异常,再看templates下文件名和 controller 返回值是否一致。
3. 核心模块拆解:拍卖场次、出价、结算的三条链路
3.1 场次状态机:从草稿到流拍只有五步
拍卖场次是整条业务线的骨架。常见做法是用一个status字段控制:0 草稿、1 待审核、2 进行中、3 已结束、4 流拍、5 已成交。状态流转不是随便改的,要在 service 里收口。比如车辆审核通过后才能发布场次,场次开始时间到了才能出价,结束时间到了由定时任务关闸。下面是一段状态流转的常量定义和校验:
public class AuctionStatus { public static final int DRAFT = 0; // 草稿,仅卖家可见 public static final int PENDING = 1; // 待审核,后台可看 public static final int RUNNING = 2; // 进行中,可出价 public static final int FINISHED = 3; // 已结束,等待结算 public static final int FAILED = 4; // 流拍,无人出价或未达底价 public static final int DEAL = 5; // 已成交,生成订单 } // 出价前的状态校验,放在 service 里,不要只靠前端按钮置灰 public void checkBiddable(AuctionSession session) { if (session.getStatus() != AuctionStatus.RUNNING) { throw new BizException("当前场次不在竞价中"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(session.getStartTime())) { throw new BizException("拍卖尚未开始"); } if (now.isAfter(session.getEndTime())) { throw new BizException("拍卖已结束"); } }逻辑说明:checkBiddable做了三道校验,状态、开始时间、结束时间。参数session是从数据库查出来的场次,不要信前端传的状态。很多翻车现场是只在前端把按钮置灰,接口照样能调,最后出现结束后还能出价的数据。时间比较用LocalDateTime,和数据库datetime字段对应,不要用Date混着来。
3.2 出价接口:一次有效出价要过五道校验
出价是整个系统最不能松的接口。我一般会把校验顺序排成:登录和实名 → 场次状态 → 保证金 → 加价幅度 → 当前最高价。任何一步不满足都直接返回,不往下走。下面是一个简化版的 service 方法:
@Transactional(rollbackFor = Exception.class) public BidResult placeBid(Long sessionId, Long userId, BigDecimal bidPrice) { // 1. 场次校验:状态、时间窗口 AuctionSession session = sessionMapper.selectById(sessionId); checkBiddable(session); // 2. 保证金校验:没交保证金的用户不能出价 Deposit deposit = depositMapper.selectByUserAndSession(userId, sessionId); if (deposit == null || deposit.getStatus() != 1) { throw new BizException("请先缴纳保证金"); } // 3. 加价幅度校验:出价必须 >= 当前价 + 最小加价 BigDecimal current = session.getCurrentPrice() == null ? session.getStartPrice() : session.getCurrentPrice(); BigDecimal minNext = current.add(session.getBidStep()); if (bidPrice.compareTo(minNext) < 0) { throw new BizException("出价不能低于 " + minNext); } // 4. 防重复:同一用户连续出价,当前价已经是自己顶上去的,不允许自己抬自己 Bid lastBid = bidMapper.selectLastBid(sessionId); if (lastBid != null && lastBid.getUserId().equals(userId)) { throw new BizException("您已是当前最高出价人"); } // 5. 写出一条出价记录,并更新场次当前价 Bid bid = new Bid(); bid.setSessionId(sessionId); bid.setUserId(userId); bid.setBidPrice(bidPrice); bid.setBidTime(LocalDateTime.now()); bidMapper.insert(bid); // 这里用乐观锁更新,避免并发覆盖,下一章展开 int rows = sessionMapper.updateCurrentPrice(sessionId, bidPrice, session.getVersion()); if (rows == 0) { throw new BizException("出价冲突,请重试"); } // 6. 最后两分钟出价,顺延结束时间,防止最后一秒狙击 if (Duration.between(LocalDateTime.now(), session.getEndTime()).getSeconds() <= 120) { sessionMapper.delayEndTime(sessionId, 120); } return new BidResult(true, bidPrice, "出价成功"); }逻辑说明:这段代码把“能不能出价”和“出价后怎么落库”放在一个事务里。第 4 步的防重复不是必须的,有的拍卖允许同一用户多次加价,但毕业设计演示时经常要避免自己顶自己,所以加了这个限制。参数bidPrice是用户输入金额,session.getBidStep()是场次配置的最小加价幅度,version是乐观锁版本号。最后两分钟延时是常见拍卖规则,delayEndTime直接改数据库结束时间,前端倒计时下一轮刷新就能看到。
提示:金额字段统一用
BigDecimal,不要用double。数据库对应decimal(12,2),比较用compareTo,不要用equals,1.0和1.00在equals下不相等。
3.3 结算任务:到点关闸、生成订单、处理流拍
拍卖结束后不能靠用户手动点,常见做法是 Spring Task 每分钟扫一次到期的场次:
@Component public class AuctionSettleTask { @Resource private AuctionSessionMapper sessionMapper; @Resource private BidMapper bidMapper; @Resource private OrderService orderService; // 每分钟执行一次,扫描已到结束时间但还没结算的场次 @Scheduled(cron = "0 * * * * ?") public void settle() { List<AuctionSession> list = sessionMapper.selectExpiredRunning(); for (AuctionSession session : list) { // 先查最高出价,没有出价就是流拍 Bid topBid = bidMapper.selectTopBid(session.getId()); if (topBid == null) { sessionMapper.updateStatus(session.getId(), AuctionStatus.FAILED); continue; } // 有最高价就生成订单,状态置为已成交 orderService.createAuctionOrder(session, topBid); sessionMapper.updateStatus(session.getId(), AuctionStatus.DEAL); } } }逻辑说明:selectExpiredRunning的 SQL 条件一般是status = 2 and end_time <= now(),只查进行中且已到期的。selectTopBid按bid_price desc, bid_time asc排序取第一条,价格相同先出价的人赢。参数cron是每分钟第 0 秒执行,毕业设计演示够用;生产环境要考虑分布式锁,不然多实例会重复生成订单,第 5 章会讲这个坑。orderService.createAuctionOrder里要写订单号、买家、卖家、成交价、保证金抵扣,这些字段在订单表里都要有。
4. 数据库与并发控制:出价不打架靠的是这三张表和一个版本号
4.1 四张主表:车辆、场次、出价、订单
拍卖系统的表不用太多,核心就四张。车辆表存基本信息,场次表存拍卖规则和当前价,出价表存每一次出价流水,订单表存成交结果。下面是我一般会用的字段:
CREATE TABLE car ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seller_id BIGINT NOT NULL COMMENT '卖家用户ID', brand VARCHAR(50) NOT NULL COMMENT '品牌', model VARCHAR(80) NOT NULL COMMENT '车型', reg_date DATE COMMENT '上牌日期', mileage INT COMMENT '行驶里程,公里', guide_price DECIMAL(12,2) COMMENT '新车指导价', images VARCHAR(500) COMMENT '图片路径,逗号分隔', audit_status TINYINT DEFAULT 0 COMMENT '0待审 1通过 2驳回', deleted TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '车辆信息'; CREATE TABLE auction_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, car_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, start_price DECIMAL(12,2) NOT NULL COMMENT '起拍价', bid_step DECIMAL(12,2) NOT NULL DEFAULT 100 COMMENT '最小加价幅度', current_price DECIMAL(12,2) COMMENT '当前最高价', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT '0草稿 1待审 2进行中 3已结束 4流拍 5成交', version INT DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '拍卖场次'; CREATE TABLE bid ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, user_id BIGINT NOT NULL, bid_price DECIMAL(12,2) NOT NULL, bid_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_session_price (session_id, bid_price DESC) ) COMMENT '出价记录'; CREATE TABLE auction_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, session_id BIGINT NOT NULL, buyer_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, deal_price DECIMAL(12,2) NOT NULL, deposit_amount DECIMAL(12,2) COMMENT '抵扣的保证金', pay_status TINYINT DEFAULT 0 COMMENT '0待付 1已付 2已退', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '成交订单';字段说明:auction_session.version是乐观锁字段,出价更新当前价时必须带上;bid表的联合索引(session_id, bid_price DESC)是为了快速查最高价;current_price允许为空,表示还没人出价,起拍价单独存。订单表里deposit_amount用来记录保证金抵扣,退款逻辑要和支付状态一起看。
4.2 出价并发:乐观锁更新比锁表更适合这个场景
两个人同时出价,如果直接update auction_session set current_price = ?,后写的会覆盖先写的,出现“出价成功但价格变低”的玄学现象。常见做法有两种:数据库行锁select ... for update和乐观锁版本号。拍卖出价冲突概率不算特别高,我一般用乐观锁,代码轻,失败让用户重试就行:
// AuctionSessionMapper.xml <update id="updateCurrentPrice"> update auction_session set current_price = #{price}, version = version + 1 where id = #{sessionId} and version = #{version} and status = 2 </update>// service 里判断影响行数 int rows = sessionMapper.updateCurrentPrice(sessionId, bidPrice, session.getVersion()); if (rows == 0) { // 版本号被别的请求改了,说明有人抢先出价,让前端刷新后重试 throw new BizException("当前价格已更新,请刷新后重新出价"); }逻辑说明:where version = #{version}保证只有版本号没变才更新,version = version + 1每次加一。参数sessionId是场次主键,price是新出价,version是从上一次查询里带出来的。影响行数为 0 不代表系统坏了,只代表这一轮没抢到,返回提示让用户重试即可。如果业务要求必须成功,可以在 service 外层加两到三次重试,重新查版本号再更新。
注意:乐观锁要和事务一起用,
@Transactional加在 service 方法上。如果方法里先查再更新,中间不要做远程调用或者长耗时操作,不然版本号冲突会变多。
4.3 Redis 短锁和缓存:只做辅助,不替代数据库
有的版本会在出价前加 Redis 锁,key 用bid:lock:{sessionId},过期时间 3 到 5 秒,防止同一场次被大量请求同时打到数据库。我一般把 Redis 定位成“削峰”,不把它当唯一正确性保证:
public boolean tryLock(Long sessionId) { String key = "bid:lock:" + sessionId; // SET key value NX PX 3000,原子加锁,3秒自动过期 Boolean ok = redisTemplate.opsForValue() .setIfAbsent(key, "1", Duration.ofSeconds(3)); return Boolean.TRUE.equals(ok); } public void unlock(Long sessionId) { redisTemplate.delete("bid:lock:" + sessionId); }逻辑说明:setIfAbsent对应 Redis 的SET NX,只有 key 不存在才设置成功,Duration.ofSeconds(3)是过期时间,防止宕机后锁不释放。参数sessionId用来隔离不同场次,同一场次串行,不同场次互不影响。这个锁的粒度不能太大,不要用全局锁,不然所有拍卖场次都会排队。释放锁直接delete在毕业设计里够用,严格来说要用 Lua 脚本校验持有者,第 6 章会提。
缓存那块,常见做法是把场次当前价和结束时间放进 Redis,前端轮询读缓存,减少数据库压力。但写路径仍然以数据库为准,缓存只做展示。不要先改缓存再改数据库,不然数据库写失败会出现缓存和库不一致,排查起来很麻烦。
5. 避坑与排查:本地启动、出价异常和定时任务的五个现场记录
5.1 启动报数据源连接失败:先看库名和时区
现象:mvn spring-boot:run启动直接抛Communications link failure或者Unknown database 'car_auction'。
原因:application.yml里的库名和实际建的库不一致,或者 MySQL 没启动;还有一种常见情况是url里没写serverTimezone,MySQL 8 驱动连接时报时区错误。
解决:先用命令行确认库存在,再对配置:
mysql -uroot -p -e "show databases like 'car_auction';"spring: datasource: url: jdbc:mysql://localhost:3306/car_auction?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false如果库名对、MySQL 也启动,就看端口是不是 3306,有没有被本地另一个实例占用。密码里如果有特殊字符,&和#要转义,不然 YAML 解析会截断。
5.2 出价成功但页面价格不刷新:接口返回和前端轮询对不上
现象:接口返回“出价成功”,数据库里current_price也变了,但页面停在那不动,刷新后才正常。
原因:前端轮询间隔太长,或者轮询接口
本文还有配套的精品资源,点击获取