
简介这是基于Java技术设计与实现的二手车交易拍卖管理系统面向计算机相关专业的毕业设计场景。系统围绕车辆信息发布、拍卖竞价、订单管理及后台审核等核心业务模块展开功能边界清晰适合需要完成类似课设或毕设的在校学生参考借鉴。开发过程涵盖需求分析、总体设计、详细设计与功能测试等完整环节总体设计覆盖功能、结构、数据结构与安全设计详细设计则深入数据库访问实现、核心模块代码逻辑及关键代码说明可帮助读者理解从业务建模到编码落地的完整路径。资源以zip压缩包发布大小约10.96MB内含可运行的Java源代码与数据库脚本并随附配置环境说明部署后即可验证各模块功能是否符合预期。目前已有2281人浏览学习项目结构清晰、注释完整适合作为毕业答辩、课程设计及项目复现的参照模板。1. 二手车交易拍卖系统到底在做什么一个时序系统而不是 CRUD 堆砌如果你把“二手车交易拍卖系统”当成又一个增删改查的毕设题目那就低估它了。普通的商品管理系统核心是数据的写入和读取而拍卖系统多了一个“时序”维度——车辆在什么时间上架、用户什么时候出价、倒计时还剩几秒、成交后订单怎么闭环。多出来的这一层时序才是面试官在问“你这个项目有什么难点”时真正想听的东西。这个方向特别适合 Java 方向的毕业生、想补全项目经验的转行者以及需要在简历上写出“并发与状态流转”的求职者。整套系统里既有传统的 CRUD 模块又有高并发竞价、定时关拍、支付回调等真实业务场景做完之后你对 Spring Boot、Redis、状态机的理解会完全不一样。我见过太多人把毕设做成“用户表 商品表 订单表”的三张表堆砌答辩时老师问一句“同时出价怎么办”就卡住了。二手车拍卖系统恰好能把这些黑匣子打开出价要防超卖、倒计时要防作弊、成交后要应付毁单。下面这套方案是我基于 Spring Boot Redis MySQL 的落地做法新手能按步骤抄熟手可以跳过环境搭建直接看并发和补偿的部分。2. 技术选型和数据建模为什么 Spring Boot Redis WebSocket 能打穿一场拍卖2.1 技术栈选型毕设项目最稳的组合不是最新而是最不容易翻车二手车拍卖系统这类项目技术选型的核心矛盾是“答辩要有亮点”和“你一个人写得完”。我一般会把技术栈拆成四层各自承担不同的职责层级技术选型承担职责接口层Spring Boot 2.7 / 3.x MyBatis-Plus提供 REST API处理业务逻辑实时层Redis WebSocket存拍卖中的实时状态、广播最新出价存储层MySQL 8.x存用户、车辆、订单、成交记录等最终数据前端Vue 3 Element Plus管理后台 用户端竞价页这里有个经验不要把拍卖的“当前价”“剩余时间”放在 MySQL 里频繁更新。MySQL 的每一行更新都要走事务、锁、Redo Log几十个人同时出价时数据库的锁等待会让你在答辩现场表演卡死。我常用的做法是拍卖进行中把当前价、出价次数、结束时间放在 Redis 里等落锤或者流拍之后再一次性写回 MySQL。这个“热数据走缓存、冷数据落库”的思路既是毕业设计的加分项也是面试八股文里反复提到的 java 怎么保证数据一致性 问题的实际应用。另一个看起来不重要但实际很关键的选型是 JDK 版本。不要一上来就装 JDK 21很多学校的答辩环境还是 JDK 8而 Spring Boot 3.x 要求 JDK 17 起步。我建议要么用 JDK 8 Spring Boot 2.7要么用 JDK 17 Spring Boot 3.x别混搭。环境变量配置的问题通常不在 JDK 本身而在于你同时装了多个版本JAVA_HOME 指向了旧版。2.2 数据库表设计九张表还是十张表取决于你有没有设计状态机二手车拍卖系统的表结构比普通商城多一点但也不至于失控。我按自己的项目经验列一份最常用的清单一共九张表表名作用关键字段user用户买家、卖家、管理员id, username, password, role, balancecar二手车源id, seller_id, brand, model, year, price, statuscar_image车源图片id, car_id, url, sortauction拍卖场次id, car_id, start_price, current_price, start_time, end_time, statusbid_record出价记录id, auction_id, user_id, bid_price, bid_timeorder_成交订单id, auction_id, buyer_id, seller_id, deal_price, statuspayment支付流水id, order_id, user_id, amount, status, callback_timecar_inspection车检报告id, car_id, inspector, score, report_urlmessage_log站内消息/出价广播日志id, auction_id, content, create_time这些表的字段不需要做到生产级那么复杂毕设评审关注的是你有没有“状态”的概念。几乎所有表都应该有一个 status 字段而且 status 的取值要能用状态机描述清楚。不要在一张表里既存车辆信息又存拍卖信息那会让你的 SQL 越写越长最后自己都看不懂。我的经验是car 表只管车本身的信息和上架状态auction 表专门管拍卖过程order 和 payment 只管成交后的流程。2.3 拍卖状态机设计状态流转是整套系统的骨架拍卖系统最容易写乱的部分就是状态流转。一个车源从卖家发布到最终成交或流拍要经过下面这几步WAIT待开始 - RUNNING竞拍中 - TIME_OVER时间到未有成交 - FINISHED已成交/流拍竞拍中如果有人出价且倒计时结束进入成交确认如果倒计时结束且没人出价或者没人出到保留价进入流拍。这里有一个很容易被忽略的设计拍卖结束后不是立刻成交而是先进入人工确认或自动确认的中间态防止出现最后两秒一个高价单没有支付能力的情况。订单状态同样要独立CREATED已创建 - UNPAID待支付 - PAID已支付 - DEALED完成过户 - CLOSED超时关闭写代码时强烈建议用枚举类把这些状态定义出来而不是用散落的 int 常量。用 int 常量写到最后就是满屏的if (status 1)你在答辩时很难说清楚为什么 1 是待开始而不是已成交。用枚举类的好处是两个第一代码可读性高写if (auctionStatus AuctionStatus.RUNNING)谁都能看懂第二枚举可以携带状态机的流转边界比如只允许 WAIT - RUNNING 这个方向禁止跳变。public enum AuctionStatus { WAIT(0, 待开始), RUNNING(1, 竞拍中), TIME_OVER(2, 时间到), FINISHED(3, 已结束), FLOWED(4, 已流拍); private final int code; private final String desc; AuctionStatus(int code, String desc) { this.code code; this.desc desc; } // getter... }这个枚举类的代码本身不难难的是你要在 Service 层所有的业务方法里用同一套枚举去判断。发布车源时把拍卖状态初始化为 WAIT到了预设的开始时间切换为 RUNNING结束时间到了统一调度去判断是成交还是流拍。状态机一旦定义清楚后面的竞价逻辑、订单生成逻辑都会顺着这条主线走不会出现“订单生成了但拍卖还是 RUNNING”这种前后台对不上的问题。数据建模做完到了真正写代码的阶段你会发现最大的挑战不再是建表而是出价这个动作本身。下一章我会把从发布车源到出价落锤的整个链路拆开每一步都给出可以直接抄的 Java 代码。3. 核心流程的实现从发布车源到竞价落锤的最小闭环3.1 发布车源和创建拍卖场次一个事务完成两件事业务流程是这样卖家填写车辆信息品牌、型号、里程、车检报告后台管理员审核通过后发布车源挂到拍卖列表。发布车源这个动作如果只往 car 表插一行拍卖列表怎么知道这辆车要不要展示所以发布车源一定要和创建拍卖场次绑定在同一个事务里。Transactional(rollbackFor Exception.class) public Long publishCarWithAuction(Car car, BigDecimal startPrice, BigDecimal floorPrice, LocalDateTime startTime, LocalDateTime endTime) { // 1. 插入车辆基本信息status 为 PENDING car.setStatus(CarStatus.PENDING); carMapper.insert(car); // 2. 创建拍卖场次初始状态为 WAIT Auction auction new Auction(); auction.setCarId(car.getId()); auction.setStartPrice(startPrice); auction.setCurrentPrice(startPrice); auction.setFloorPrice(floorPrice); // 保留价低于此价不成交 auction.setStartTime(startTime); auction.setEndTime(endTime); auction.setStatus(AuctionStatus.WAIT); auctionMapper.insert(auction); // 3. 预写入 Redis 拍卖缓存 String key auction: auction.getId(); redisTemplate.opsForHash().putAll(key, Map.of( currentPrice, startPrice.toString(), floorPrice, floorPrice.toString(), status, String.valueOf(AuctionStatus.WAIT.getCode()), endTime, endTime.toString() )); redisTemplate.expire(key, Duration.ofDays(3)); return auction.getId(); }这段代码的逻辑是先插车辆再建场次最后预热 Redis 缓存。三个动作放在一个事务里前面任何一步失败整个事务回滚不会出现“车发布了但拍卖没建”的脏数据。参数说明startPrice是起拍价floorPrice是保留价注意两者有区别——出价可以低于保留价但最终成交时如果最高出价没到保留价系统判定流拍。这是二手车拍卖和普通商品竞价的典型差异。这里我用了 Redis Hash 结构而不是 String因为一场拍卖有多个属性价格、状态、结束时间Hash 能单独更新其中某个字段而且后面出价时可以直接用HINCRBY或 Lua 脚本做原子操作。你可能觉得预热 Redis 不是必须的但实际我遇到过一个情况拍卖开始前用户刷新列表所有请求都穿透到 MySQL 查最新价格DB 瞬间扛不住。提前把拍卖状态放到 Redis列表页只查缓存压力小很多。3.2 出价竞价的并发控制别让两条出价把数据改丢出价是拍卖系统的灵魂也是并发问题最集中的地方。每个用户点击出价时系统要做三件事检查当前出价是否高于最高价、扣减用户保证金额度、把出价记录写入 bid_record。这三件事如果不加控制两个用户同时出相同价格就可能出现“覆盖写”导致一人出价成功但另一人也被扣了钱。这个问题对应 java 并发面试题里经典的“并发原子性”问题也是 java 怎么保证数据一致性 的核心考点。常见的方案有两种一种是synchronized或Lock简单粗暴但只对单机有效另一种是利用 Redis 的 Lua 脚本原子执行这也是我推荐的做法因为拍卖系统必然要上 Redis用 Lua 顺手解决并发问题是性价比最高的选择。// 出价接口先走Redis Lua脚本保证原子性再异步落库 public BidResult bid(BidRequest request) { Long auctionId request.getAuctionId(); BigDecimal price request.getPrice(); // Lua脚本检查出价是否高于当前价是则更新Redis中的currentPrice String luaScript local current redis.call(HGET, KEYS[1], currentPrice) if not current then return -1 end if tonumber(ARGV[1]) tonumber(current) then return 0 end redis.call(HSET, KEYS[1], currentPrice, ARGV[1]) return 1; Long result redisTemplate.execute( new DefaultRedisScript(luaScript, Long.class), Arrays.asList(auction: auctionId), price.toString() ); if (result -1) { throw new BizException(拍卖不存在或已结束); } if (result 0) { throw new BizException(出价必须高于当前价); } // 异步写MySQL记录完整出价流水 bidAsyncService.saveBidRecord(auctionId, request.getUserId(), price); return new BidResult(price); }这段代码的关键在那个 Lua 脚本。tonumber(ARGV[1])比较的是出价和当前价如果小于等于当前价直接返回 0表示出价无效只有高于当前价才执行HSET更新。整个比较和更新在 Redis 单线程模型下是原子性的两个用户同时出价时Redis 会逐个执行不会出现覆盖写。异步落库是为了减少出价接口的响应时间但要注意异步任务失败后要有补偿机制后面避坑章节会讲。参数说明KEYS[1]是拍卖的 Redis keyARGV[1]是本次出价价格。使用 Lua 脚本时还有一个细节——脚本不能太长Redis 的 EVAL 命令每次都要传整个脚本如果脚本体积大网络传输也会成为瓶颈。我一般在几百字节以内只做核心原子判断其余逻辑放到 Java 里。有了 Redis 那层原子控制MySQL 的出价记录写入就不太容易出现主键冲突或覆盖了。但还有一个问题没解决出价成功后前端的剩余时间显示怎么办如果每个用户都从自己电脑的本地时间算剩余时长有人把系统时间改慢理论上可以一直等到最后几秒再出价。这里就轮到倒计时机制登场了。3.3 拍卖倒计时的设计把时间判定权放在服务端而不是浏览器倒计时是拍卖系统里最容易“翻车”的部分原因有两个一是浏览器本地时间不可信二是 Redis 过期事件并不保证实时。我见过有人用redisTemplate.expire(key, 600, TimeUnit.SECONDS)监听KeyExpirationEventMessageListener来决定拍卖结束但这个方案在 Redis 的过期淘汰策略下有一定延迟而且如果 Redis 重启未触发的过期键会丢失拍卖就永远结束不了。我的做法是双保险服务端统一用数据库记录的end_time判定同时用Scheduled定时任务每分钟扫一次库把到时间但状态还是 RUNNING 的拍卖改成 TIME_OVER。Component public class AuctionScheduleTask { Resource private AuctionMapper auctionMapper; Resource private RedisTemplateString, String redisTemplate; // 每30秒执行一次兜底将超时拍卖置为TIME_OVER Scheduled(fixedDelay 30_000) public void closeExpiredAuctions() { ListAuction overdueList auctionMapper.selectList( new LambdaQueryWrapperAuction() .eq(Auction::getStatus, AuctionStatus.RUNNING) .lt(Auction::getEndTime, LocalDateTime.now()) .last(LIMIT 100) ); for (Auction auction : overdueList) { // 判断是否有人出价到保留价决定成交还是流拍 auctionMapper.closeAuction(auction.getId(), AuctionStatus.TIME_OVER); redisTemplate.opsForHash().put(auction: auction.getId(), status, 2); } } }这段代码是 java 定时任务框架的典型用法Scheduled(fixedDelay 30_000)表示上一次任务执行完 30 秒后再执行下一次。注意我用的是fixedDelay而不是fixedRatefixedRate从上一次开始时间计时如果任务执行超过 30 秒就会重叠fixedDelay是完成后间隔 30 秒不会重叠。虽然看起来差不多但在数据库慢查询的时候fixedDelay更安全不会堆任务。真正实操时还有个容易忽略的参数last(LIMIT 100)。如果不加这个限制一次性扫出几千条超时拍卖每条都去更新数据库会让 GC 压力陡增。分页批量处理是稳定性的一个细节。倒计时结束后的状态切换不复杂核心是要有兜底逻辑。我见过很多项目只在 WebSocket 连接断开时判断结束时间一断网拍卖就卡住。服务端定时任务的好处是即使前端全部断连拍卖的状态也在按计划推进用户重新连上后拿到的数据依然是准确的。很多同学在毕设答辩时被问“如果用户关闭了浏览器页面拍卖还会继续吗”答案就在这里。4. 订单结算与状态恢复正常化成交后的一串后悔药机制4.1 成交确认与订单生成别让买家拍到车却下不了单当拍卖状态变为 TIME_OVER系统需要判断是否有人出价达到保留价。这里有一个关键的边界TIME_OVER 不等于成交它只是一个中间态。代码里我用closeAuction方法同时做了两件事——更新拍卖状态、返回值用来判断是生成订单还是标记流拍。Transactional(rollbackFor Exception.class) public void settleAuction(Long auctionId) { // 查询当前最高出价记录 BidRecord highestBid bidRecordMapper.findHighestByAuctionId(auctionId); Auction auction auctionMapper.selectById(auctionId); // 如果无人出价或最高价低于保留价则流拍 boolean isDeal (highestBid ! null) highestBid.getBidPrice().compareTo(auction.getFloorPrice()) 0; if (isDeal) { // 创建订单CREATED状态 Order order new Order(); order.setAuctionId(auctionId); order.setBuyerId(highestBid.getUserId()); order.setSellerId(auction.getSellerId()); order.setDealPrice(highestBid.getBidPrice()); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); // 拍卖最终状态置为FINISHED auctionMapper.updateStatus(auctionId, AuctionStatus.FINISHED); } else { auctionMapper.updateStatus(auctionId, AuctionStatus.FLOWED); } }compareTo在这里是 java 比较大小的标准写法用 0判断是否达到保留价。这个写法比highestBid.getBidPrice() auction.getFloorPrice()更严谨因为BigDecimal的运算符是不存在的只有compareTo方法。很多刚开始写 java 的同学在这行代码上栽过跟头——把BigDecimal用比较结果两个价格明明一样大却是 false。订单生成后买家需要在规定时间内比如 24 小时支付定金或全款。这个 24 小时如果完全依赖人工系统很快会出现“拍到车不付款”的脏数据。所以必须有定时任务把超时未支付的订单改成 CLOSED。这里就用到所谓的“后悔药机制”——拍卖成交后允许买家毁单但要承受保证金扣减等代价。我通常的做法是把保证金字段加在 user 表上出价时先冻结冻结金额 起拍价的 5%成交后解冻成功部分毁单则扣除。// 买家申请毁单在支付前 Transactional(rollbackFor Exception.class) public PayResult cancelOrder(Long orderId, Long userId) { Order order orderMapper.selectById(orderId); if (!order.getBuyerId().equals(userId)) { throw new BizException(只能操作自己的订单); } if (!OrderStatus.CREATED.equals(order.getStatus()) !OrderStatus.UNPAID.equals(order.getStatus())) { throw new BizException(当前订单状态不可取消); } // 扣除保证金或打入卖家账户订单关闭 userService.deductBail(order.getBuyerId(), order.getDealPrice().multiply(new BigDecimal(0.05))); orderMapper.updateStatus(orderId, OrderStatus.CLOSED); return PayResult.success(订单已关闭保证金扣除5%); }注意毁单操作是有状态限制的只有 CREATED 和 UNPAID 才能取消一旦进入 PAID 就必须走过户流程。这个限制也是状态机的价值所在——你不必在代码里到处写复杂的 if 判断状态机本身就把合法路径限定好了。4.2 定时状态补偿所有异步操作都得有后悔药异步是提高系统吞吐量的手段但也带来了一个问题异步任务可能失败可能丢失可能重复执行。出价记录是异步写库的如果写失败了Redis 里的当前价和 MySQL 里的最高出价就不一致。订单支付回调也可能因为网络超时没拿到结果。这些不一致不能靠人工去修必须有一套状态补偿机制。我常用的补偿方式就是增加一张task_retry表记录所有异步任务的执行状态和重试次数Component public class AsyncTaskCompensator { // 每60秒扫描一次所有失败或未确认的任务 Scheduled(fixedDelay 60_000) public void retryFailedTasks() { ListTaskRetry pendingList taskRetryMapper.selectList( new LambdaQueryWrapperTaskRetry() .in(TaskRetry::getStatus, 0, 2) // 0待执行2执行失败 .lt(TaskRetry::getRetryCount, 5) // 最多重试5次 ); for (TaskRetry task : pendingList) { try { // 根据taskType做不同的补偿业务 taskService.executeCompensation(task); taskRetryMapper.updateStatus(task.getId(), 1); // 成功 } catch (Exception e) { taskRetryMapper.incrementRetry(task.getId()); log.error(补偿任务执行失败, taskId{}, task.getId(), e); } } } }这个补偿器的设计思路是每个异步任务在执行前先落一条 task_retry 记录如果异步执行成功则把记录状态置为成功失败则保持待重试状态由定时任务捞起重试。重试次数上限设为 5超过 5 次就进入人工处理清单用一个manual_review字段标记。这套机制写起来不复杂但答辩时讲出来比“我用 Redis 缓存了所有数据”要有说服力得多。补偿任务最容易踩的坑是“重复执行导致副作用”。比如支付回调补偿如果订单已经 PAID 了补偿逻辑就不能再扣一次款。所以补偿业务里第一步永远是检查当前状态是否已经是终态是终态直接跳过。这就是 java 面试题里常说的“幂等性”的落地——所有补偿操作都要做成幂等的。到这里系统的主链路发布车源 → 出价 → 倒计时 → 成交/流拍 → 订单 → 毁单/支付已经闭环了。但闭环不意味着没有坑恰恰相反这套系统里全是隐蔽的坑。下一章我把自己踩过的五个大坑原原本本写出来每一条都是“现象 → 原因 → 解决”的格式照着排查能少走很多弯路。5. 毕业设计最容易翻车的 5 个坑并发、时区、数据库锁、集合迭代、环境5.1 并发出价导致价格错乱Redis 没有“扣减”操作逻辑现象模拟 20 个用户同时出价时最后成交价反而比某个中间出价更低数据看起来像“穿越”了。 原因我一开始是在 Java 代码里先读 Redis 当前价和出价比较再写回。两个线程同时读到同一个 currentPrice后写的一方覆盖了先写的一方的结果。这是典型的“读改写”非原子操作。 解决换用 3.2 节里的 Lua 脚本把“比较价格 更新价格”放到 Redis 内部原子执行。改完后 100 个并发线程测试价格始终是严格递增的。这个坑对应 java 并发题里最常问的“如何保证原子性”Lua 脚本就是 Redis 场景下的标准答案。5.2 倒计时作弊改本地系统时间就能拉长拍卖现象前端页面显示剩余 2 秒时用户把电脑系统时间改慢 5 分钟倒计时停住还能继续出价。 原因倒计时计算用的是endTime.getTime() - System.currentTimeMillis()这里的System.currentTimeMillis()是客户端系统的本地时间。 解决改用服务端时间。前端通过 WebSocket 连接建立时从服务端拿一个时间戳之后的倒计时都用这个服务端毫秒数减去剩余的本地漂移量同时每次出价都由服务端校验endTime超过时间直接拒绝出价。双保险的意思是即使前端显示有误差出价接口也会拦一次数据不会脏。5.3 Redis 过期事件不可靠拍卖到点却不结束现象拍卖时间到了Redis 的过期事件监听器触发了但没人接拍卖一直处于 RUNNING。 原因Redis 的 key 过期事件不是准实时触发的它依赖 Redis 的过期淘汰策略有一定延迟另外如果 Redis 进程重启所有 TTL 数据丢失监听器自然不会再触发。 解决放弃“依赖 Redis 过期事件”作为唯一出口。改用第 3.3 节的Scheduled定时扫表兜底每 30 秒把超时拍卖全部置为 TIME_OVER。Redis 过期事件可以作为加速器但绝不能作为唯一依赖。5.4 数据库更新丢失BidRecord 表出现重复主键现象两条出价记录同时写 MySQL报Duplicate entry的异常。 原因我用了数据库自增主键但出价接口是异步写库的。异步线程先执行时BidRecord 的主键没有冲突但另一个同步线程也插入了同一条业务主键比如auction_id user_id的联合唯一索引导致重复。 解决给bid_record表增加一个bid_no字段业务流水号在插入前用 Redis 的INCR生成同时建立(auction_id, bid_no)的联合唯一索引。异步落库时如果遇到重复主键捕获异常后重试一次重试仍失败则丢弃这条记录并打日志。5.5 Spring Boot 启动失败端口占用和 JDK 版本不对最坑人现象java -jar启动项目控制台报Port 8080 was already in use或者直接报UnsupportedClassVersionError。 原因一是被其他程序占用了 8080 端口二是 JDK 版本和项目编译版本不一致比如项目用 JDK 17 编译运行环境却装的是 JDK 8。 解决端口占用用netstat -ano | findstr :8080Windows或lsof -i :8080Mac/Linux找到进程后杀掉版本不一致则检查JAVA_HOME环境变量指向的路径或者直接在 IDE 里把 Project Structure 的 SDK 和 Maven 的Java Compiler都统一。这个坑别看简单我在答辩前夜遇到过三次每一次都是因为环境变量配置多装了一个 JDK 版本导致的。6. 从毕业设计到可上线系统验证手段和三个加分改造毕设做完不是终点答辩前的验证才是重中之重。我推荐一个最直接的压测方法用 JMeter 模拟 30 个并发用户同时出价持续跑 5 分钟观察两个指标——Redis 里的最终价格是否为严格递增序列MySQL 里的bid_record总数是否和 Redis 的出价次数一致。这两个指标能在一分钟内暴露并发控制的漏洞。压测命令不用太复杂JMeter 里加一个 HTTP 采样器POST 到出价接口参数用 CSV 文件给每个线程不同的价格即可。跑完后如果发现不一致赶紧回到第 3.2 节看 Lua 脚本是不是有逻辑遗漏。在这套系统里我最后还做了三个加分改造你也完全可以照做。第一车辆的图片和车辆检测报告单独放在一张表里不做物理外键页面加载时把 car_id 作为查询条件批量查出来避免 N1 查询导致列表页卡顿。第二拍卖列表页的所有车辆数据先读 Redis 缓存缓存未命中时再由一个请求拉取 MySQL 并回填缓存这个拆分让列表接口的延迟从 300ms 降到了 40ms 左右。第三支付模块不真正接第三方支付而是做一个模拟支付页面——点击支付后直接把订单置为 PAID然后在支付流水里打一条模拟回调记录。这样既能闭环流程又不用去申请商户号和密钥。最后分享一个我的个人习惯每次改完并发相关代码不要只看功能跑不跑得通一定要主动加上压力测试——用 JMeter 或 Postman 的 Runner 去并发点击出价。凡是涉及“当前价”“库存”“状态更新”的逻辑都不该只在单线程下验证通过就算完成。我自己的第一个拍卖项目就是漏了并发测试答辩当天前端页面演示一切正常评委突然让两个学生同时抢拍成交价直接算错当场翻车。后来我把上面这套 Lua 脚本和幂等补偿逻辑补齐之后才真正敢说这个系统“能跑”。毕业设计的目的不是把系统写出来给老师看一眼而是通过这个系统把 Java 里那些看不见摸不着的并发、一致性、状态流转问题变成自己能讲清楚的东西。这套二手车交易拍卖系统从数据建模到拍卖落锤再到订单闭合中间的每个环节都有可以深挖的细节只要你肯花时间把每一块的边界条件测一遍答辩时被问得越细你反而越有话讲。希望这套方案和这些踩坑记录能帮到你让你少走一点我当时走过的弯路。本文还有配套的精品资源点击获取