资讯动态

SpringBoot+Vue在线拍卖系统:并发出价与状态机设计实践

发布时间:2026/10/13 18:45:54 来源:尧图企业网站定制
正在做毕设或者公司内部需要搞一个拍卖类系统的时候很多人第一反应是去GitHub上随便找个开源项目改吧改吧。但真上手你会发现市面上的开源拍卖系统要么技术栈太老要么代码乱得根本不敢动要么压根没有配套文档部署起来都是坑。这套基于SpringBoot和Vue的在线拍卖系统是我前后梳理了两版之后整理出来的完整方案既适合毕业设计直接用也适合想快速搭一个拍卖业务原型的开发者参考。重点在于它不只是能跑而是整个竞拍逻辑、并发控制、订单闭环都做了合理设计附带完整源码和毕业论文部署起来基本不用改东西。这套系统解决的核心问题很明确一个用户能够注册登录、浏览正在拍卖的商品、在规定时间内出价竞拍卖家或管理员可以上架商品、设置起拍价和加价幅度拍卖结束后系统自动生成订单买家用虚拟余额支付前后端通过JWT做身份认证价格通过轮询或者WebSocket实时刷新。对比普通CRUD项目这套系统的技术含金量主要体现在“并发出价”和“状态机流转”这两个点上也正是面试官和答辩老师最关注的地方。1. 项目整体拆解一个拍卖系统到底在做什么1.1 核心业务链路从“上架”到“成交”的完整闭环不要一上来就看代码先把业务流程画清楚。在线拍卖系统表面上是商品交易但本质上是一条需要严格控制状态的业务链路商品上架 - 设置起拍时间/结束时间/起拍价/加价幅度 - 拍卖状态变为“待开始” - 到达开始时间自动变为“进行中” - 用户浏览详情、参与出价 - 出价成功后更新当前价格 - 到达结束时间自动变为“已结束” - 系统找出最高出价者 - 生成订单 - 买家支付 - 卖家发货 - 交易完成这里最容易忽略的是状态判定。很多同学做拍卖系统状态是手动改的比如管理员点一下“开始拍卖”。但真实场景里上架的商品应该到时间自动开拍、到时间自动结束不需要任何人介入。所以数据库里要存开始时间和结束时间程序里做状态机推导而不是存一个死的status字段。这是这个项目设计上的第一个关键点。1.2 技术栈选型为什么是SpringBootVue而不是别的有个很现实的问题为什么不选ServletJSP为什么不选前后端不分离的架构原因很简单。现在主流的Web开发模式就是前后端分离SpringBoot提供RESTful API接口Vue负责页面呈现和交互。毕业设计如果用JSP答辩时老师第一个问题可能就是“为什么不用前后端分离”。企业里更不用说了VueSpringBoot已经是Java Web岗位的标配组合。这套系统选择这个技术栈不仅仅是因为“流行”而是因为它能清晰地把复杂逻辑拆开后端只操心业务规则、并发控制、数据持久化用Postman就能全套测接口。前端只操心页面渲染、路由跳转、状态展示用Vue的响应式机制处理用户操作。前后端通过JSON格式的数据交互接口边界清晰论文里也容易画架构图。另外数据库选择了MySQL缓存可以选Redis也可以不用我在这套系统里把Redis作为可选项基础版本用数据库乐观锁就足够支撑教学演示和小规模并发。如果论文需要写“高并发优化方案”再把Redis分布式锁加进去作为扩展点这个设计思路答辩会非常加分。2. 数据库设计拍卖系统的地基怎么打2.1 核心表结构与字段设计数据库设计决定了整个系统的上限。我的设计一共7张核心表用户表、商品表、竞拍记录表、订单表、支付记录表、收藏表、系统日志表。这里把最关键的几个表拆开讲。用户表不用多说标准字段user_id、username、passwordBCrypt加密、balance虚拟余额、role角色区分管理员和普通用户。商品表是核心字段设计直接影响业务逻辑字段名类型说明item_idbigint主键titlevarchar商品名称descriptiontext商品描述starting_pricedecimal(10,2)起拍价current_pricedecimal(10,2)当前最高价初始等于起拍价bid_incrementdecimal(10,2)每次加价的最低幅度start_timedatetime拍卖开始时间end_timedatetime拍卖结束时间statustinyint0待开始 1进行中 2已结束 3流拍seller_idbigint卖家IDbuyer_idbigint最终中标买家ID结束时回填final_pricedecimal(10,2)最终成交价结束时回填view_countint浏览量注意current_price这个字段很多同学容易在表里同时存“最高出价”和“当前价格”这是重复存储。正确做法是商品表只存current_price作为最新价的冗余竞拍记录表存每一次出价的明细。这样列表页展示价格时不用聚合查询性能好很多。竞拍记录表非常关键字段名类型说明record_idbigint主键item_idbigint拍卖商品IDuser_idbigint出价用户IDbid_pricedecimal(10,2)本次出价金额create_timedatetime出价时间这张表只做插入不做更新。一旦产生一条出价记录它就是历史事实。系统显示“当前价”时读商品表的current_price“出价历史”时查这张表。需要提醒的是bid_price应该存用户“这次的出价金额”而不是“加价的差额”这样统计总价、追溯历史都清晰。2.2 竞拍记录的幂等与版本控制竞拍记录表在高并发下有个隐患同一个用户连续点击“出价”按钮前端可能会发出重复请求后端如果没有幂等保护就会产生两条相同价格的出价记录把商品表的价格抬错。我的处理方式是给商品表加一个version字段每次出价时带上这个版本号做乐观锁比对。表结构里增加version intUPDATE语句里拼接AND version #{oldVersion}影响行数为0就说明有人抢先出价了本次请求直接返回“价格已变动请刷新重试”。同时同一个用户对同一个商品的出价请求可以在前端做当前价格校验——按钮点击后立即置灰直到服务端返回结果再恢复。还有一个细节竞拍表要加唯一约束吗如果想让同一个用户同一场拍卖只能有一条有效出价记录可以加(user_id, item_id)的唯一索引。但考虑到业务上允许用户多次加价我的做法是不加唯一索引靠version版本控制解决并发问题同一用户重复出价的问题交给业务层判断如果bid_price不高于当前价直接拒绝。2.3 索引与事务设计的取舍表建好了索引不能乱加。拍卖系统的查询场景很集中首页/列表页按status和end_time查询正在拍卖的商品在status和end_time上建联合索引。商品详情主键查询自带索引。出价历史按item_id查询在item_id上建索引。用户订单按user_id查询订单在user_id上建索引。事务设计上出价这个动作一定要在同一个事务里完成三步校验商品状态和价格、更新商品表current_price、插入竞拍记录。三个操作要么全成功要么全失败。这里有个初学者常犯的错先查一次商品判断状态没问题再更新价格再插入记录。这中间如果有另一个用户抢先出价就出现“判断时价格是100写入时已经变成120”的问题。所以必须用UPDATE语句的乐观锁直接完成“条件更新”而不是先查再更。3. 后端核心模块并发竞拍的正确打开方式3.1 出价接口的并发控制乐观锁与Redis的取舍这是整个项目技术含量最高的一个点。出价接口的伪代码大致是这样Transactional public BidResult placeBid(Long itemId, Long userId, BigDecimal bidPrice) { // 1. 查询商品判断状态是否为“进行中” Item item itemMapper.selectById(itemId); if (item.getStatus() ! AuctionStatus.RUNNING.getCode()) { return BidResult.fail(拍卖未在进行中); } // 2. 校验出价必须不低于当前价加价幅度 BigDecimal minPrice item.getCurrentPrice().add(item.getBidIncrement()); if (bidPrice.compareTo(minPrice) 0) { return BidResult.fail(出价低于最低加价幅度); } // 3. 乐观锁更新商品当前价 // UPDATE item SET current_price #{bidPrice}, version version 1 // WHERE item_id #{itemId} AND version #{version} AND status 1 int rows itemMapper.updateCurrentPriceWithVersion(itemId, bidPrice, item.getVersion()); if (rows 0) { return BidResult.fail(价格已更新请重新出价); } // 4. 插入竞拍记录 BidRecord record new BidRecord(); record.setItemId(itemId); record.setUserId(userId); record.setBidPrice(bidPrice); bidRecordMapper.insert(record); return BidResult.success(); }整个方法用Transactional包起来核心秘密就在第3步。UPDATE ... WHERE item_id? AND version? AND status1是一条原子语句数据库层面会锁住符合条件的那行记录。并发情况下两个用户同时出价只有一个用户的UPDATE能影响1行另一个影响0行从而判定出价失败。这就是乐观锁相对悲观锁的好处不加SELECT FOR UPDATE普通查询不阻塞只有实际更新那一刻才产生行锁性能压力小得多。如果你论文里想突出“高并发优化”可以在扩展部分写Redis方案用Lua脚本把“判断价格、更新价格、插入出价记录”三个操作原子化地放进Redis执行。但我想提醒一句纯数据库乐观锁在教学和答辩场景已经非常够用强行上Redis反而容易在论文里说不清还会引入缓存一致性的新问题。3.2 拍卖状态机的实现时间判定是核心状态机的设计很简单但实现容易出Bug。我的状态枚举是public enum AuctionStatus { PENDING(0, 待开始), RUNNING(1, 进行中), FINISHED(2, 已结束), FAILED(3, 流拍); }用户查询商品详情时不能直接读数据库里的status字段而是要结合当前时间动态判断当前时间小于start_time就是待开始在start_time和end_time之间就是进行中大于end_time就是已结束。数据库的status字段只是冗余状态真正权威的是时间。这里有个特别容易踩的坑时区问题。如果服务器部署在阿里云默认时区是UTC而你在本地开发时用的东八区插入的datetime值可能会出现8小时的偏差。我的做法是项目配置里强制指定serverTimezoneAsia/Shanghai数据库连接串加useTimezonetrue同时后端统一用LocalDateTime类型接收和返回时间前端展示用时间戳转换。别小看这个问题我见过有人调试了一整天才发现是时区导致的拍卖时间错乱。另外在服务里我写了一个定时任务每分钟扫描一次数据库把已经超过end_time且状态还是“进行中”的商品批量改为“已结束”状态同时回填buyer_id和final_price。定时任务是为了保证列表页能看到正确状态而动态判定保证了详情页即使在定时任务意外停掉时也能正确判断。3.3 定时任务处理超时订单拍卖结束后生成订单买家需要在30分钟内支付超时订单自动取消、商品重新上架。这个逻辑如果靠用户手动触发是不现实的必须用定时任务。我的实现是Spring的Scheduled注解配置了一个固定频率的任务Scheduled(cron 0 */1 * * * ?) public void handleTimeoutOrders() { // 查询所有status1待支付且create_time早于当前时间30分钟的订单 ListOrder timeoutOrders orderMapper.selectTimeoutOrders(LocalDateTime.now().minusMinutes(30)); for (Order order : timeoutOrders) { // 订单状态改为已取消 order.setStatus(2); orderMapper.updateById(order); // 释放对应拍卖商品状态改为重新拍卖或完结 Item item itemMapper.selectById(order.getItemId()); item.setStatus(2); itemMapper.updateById(item); } }这个功能在论文里能写出一个完整的“订单超时处理模块”也是答辩时一个亮点。虽然也有用消息队列延迟消息的更优方案但单体应用里Scheduled定时扫描完全够用而且容易理解。3.4 JWT认证与权限控制系统分了三种角色游客、普通用户、管理员。游客可以浏览商品列表和详情但出价必须登录。用户登录后用JWTJSON Web Token鉴权后端用一个拦截器解析请求头中的Authorization字段校验通过后把userId放入ThreadLocal供业务层直接取用。JWT的好处在于无状态。服务器不需要存Session分布式部署时也能无缝扩展。我在拦截器里做了一层角色校验public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); UserContext.setUserId(claims.get(userId, Long.class)); UserContext.setRole(claims.get(role, String.class)); return true; } catch (Exception e) { response.setStatus(401); return false; } }两个细节点一是登录密码必须用BCrypt加密不要用MD5。MD5查表就能破解BCrypt每次生成的哈希都不同安全性不是一个量级。二是JWT密钥不要硬编码在代码里放到application.yml配置文件中部署时用环境变量注入。4. 前端Vue实现实时刷新、倒计时和出价交互4.1 页面结构与核心组件前端我用的Vue 2 Element UI虽然Vue 3是趋势但Element UI在快速搭建管理系统和毕设场景下依然是非常稳定的选择。整个前端按模块拆分首页商品卡片列表展示在拍商品带倒计时和当前价格。商品详情页核心交互页面包含商品大图、描述、当前价、出价记录列表、出价输入框、倒计时。登录注册页表单校验 JWT存储。个人中心我的出价记录、我的拍卖我上架的、我的订单。管理后台用户管理、商品审核、分类管理。组件化是Vue的核心思想。倒计时、价格展示、出价按钮都是独立组件通过props接收数据通过事件触发父组件更新。这样每个组件单独调试出问题也容易定位。4.2 价格实时刷新轮询还是WebSocket这是前端设计上的另一个技术亮点。实时价格刷新有两种方案轮询方案前端每隔3到5秒请求一次详情接口获取最新价格。实现简单但空转请求多而且3秒的延迟在拍卖场景里用户体验不够好。WebSocket方案浏览器和服务器保持长连接价格一有变化服务端主动推送实时性最强。但后端要额外处理WebSocket的会话管理、断线重连等逻辑复杂度上了一个台阶。我的做法是详情页用轮询间隔3秒因为这是主要出价场景3秒的延迟已经可以接受同时我预留了WebSocket的扩展说明在论文里作为“系统优化方向”写。如果你想把项目往更高分做可以把出价成功后的广播改成WebSocket推送代码量大概多100多行但对整体架构的加分非常大。轮询实现时有个坑组件销毁时一定要清除定时器否则用户退出页面后请求还在发控制台一堆报错。正确做法是在beforeDestroy钩子里clearInterval这在面试时被问到的概率很高。4.3 倒计时组件与竞拍状态联动倒计时组件是整个前端最容易出Bug的地方。最常见的错误写法是每次刷新减1秒// 错误示范累减方式 this.remainingSeconds - 1;这种做法在页面切到后台或者电脑休眠唤醒后定时器会被浏览器节流时间就不准了。正确写法是存一个固定的截止时间戳每次渲染时用截止时间戳 - 当前时间戳重新计算剩余时间// 正确示范时间差方式 countdown() { const now Date.now(); const end new Date(this.item.endTime).getTime(); this.remainingMs end - now; }页面每秒钟执行一次countdown方法只用计算差值不管定时器有没有被节流只要恢复执行就能立即校准。倒计时归零后要自动刷新页面状态让拍卖状态从“进行中”变为“已结束”同时禁用出价按钮。4.4 管理后台的实现思路管理后台和前台是两个独立的路由模块。我用Vue Router的嵌套路由做了一个简单的权限控制登录用户的role字段是admin前端渲染菜单时就多显示“后台管理”入口路由配置里对/admin开头的路径统一加meta.requiresAdmin标记在全局守卫里做角色判断没有权限直接跳转404而不是仅仅隐藏按钮。管理后台的功能不需要特别复杂核心就三个商品管理审核上架、强制下架、查看出价记录、用户管理禁用、启用、重置余额、订单管理查看所有订单、标记发货。这几个功能用Element UI的Table组件配合Dialog表单就能全部搞定工作量不大但功能完整。5. 部署实操从源码到可访问的系统5.1 后端打包与部署这套系统部署不挑环境一台2核4G的云服务器完全够用。后端用Maven打包mvn clean package -DskipTests打包完成后target目录下会生成一个jar包。我把配置文件里的数据库连接改成服务器上的实际地址然后用一条命令启动nohup java -jar auction-backend.jar --spring.profiles.activeprod auction.log 21 这里说两个经验。第一nohup 是为了让服务在退出SSH终端后继续运行输出日志重定向到auction.log方便排查问题。第二启动命令里直接带--spring.profiles.activeprod可以覆盖配置文件里的默认profile避免了为不同环境改很多份配置文件的麻烦。如果有条件用systemd配置一个服务单元服务崩溃后自动重启比nohup可靠得多。不过对毕设演示来说nohup已经足够。5.2 前端构建与部署前端打包更简单npm run build构建产物在dist目录。部署静态文件的方式有两种一种是用Nginx托管dist文件夹同时把/api请求反向代理到后端的8080端口另一种是把dist目录里的static资源直接扔进SpringBoot的src/main/resources/static目录重新打包成单一体Jar做到前后端同源部署。我推荐第一种Nginx方案更贴近企业生产环境而且Nginx还能顺便做Gzip压缩和HTTPS证书配置。关键配置就这几行server { listen 80; server_name your-domain.com; location / { root /var/www/auction; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } }注意try_files这一行它保证前端路由在刷新时不会404这是SPA单页应用部署中最容易漏掉的一个配置。5.3 初始化数据与验证部署完成后首次访问系统要验证几个关键链路注册新用户、登录、管理员创建商品并设置开始时间为当前时间的前几分钟、等待状态变为进行中、用户出价、模拟另一个用户更高出价、等待拍卖结束、确认生成订单。把这条链路完整跑一遍所有模块基本就都没有问题了。我在这套源码的sql目录里放好了建表脚本和初始化数据脚本。初始化数据里包含了一个管理员账号和几个测试商品管理员账号的密码在README里有说明。这样拿到源码的同学不需要从零开始录数据直接把脚本导入数据库就能看到演示效果。6. 常见问题排查与避坑实录6.1 问题速查表问题现象可能原因解决方案前端请求接口报403JWT过期或拦截器未放行登录接口检查前端token是否过期检查后端白名单配置出价提示“价格已更新”乐观锁版本冲突正常现象前端刷新当前价格后重新出价倒计时显示NaN时间类型转换失败确认后端返回的时间格式是否为ISO标准格式拍卖结束时间到了还能出价系统时间与数据库时间不一致检查服务器时区配置统一为Asia/Shanghai打包后前端页面白屏Vue Router history模式静态资源配置错误检查Nginx的try_files配置上传图片不显示静态资源路径被拦截配置WebMvcConfigurer放行file目录6.2 我踩过的几个坑第一个坑是跨域。开发环境里前端跑在8081端口后端跑在8080端口直接请求必然触发浏览器跨域。我用CORS跨域配置解决后端加一个WebMvcConfigurer配置类允许指定来源和请求头。但生产环境用Nginx同源部署后这个配置反而多余了所以我把跨域配置放到了多环境配置里只在dev环境生效。第二个坑是余额扣减。拍卖系统里用户账户有虚拟余额出价时不能直接把钱扣掉而是应该在拍卖结束时统一清算。我之前第一版犯过这个错出价一次扣一次钱结果用户出了5次价被冻结了5份钱最后成交时逻辑全乱。更合理的方案是竞拍结束生成订单后系统校验最高出价者的余额是否足够不足则取消订单并顺延给第二高出价者这个“顺延逻辑”我建议在论文扩展里写属于加分项。第三个坑是管理员删除商品时的外键约束。如果商品下有竞拍记录和订单直接删除会外键报错。我的处理是表设计时不强制物理外键只在逻辑层面管理关联关系删除商品时检查有没有关联数据有则改为逻辑删除商品表加deleted字段。第四个坑是频繁查询导致的N1问题。商品列表页每个商品都要查询卖家名称和最新出价记录如果不做优化就是一个商品三次查询20个商品就是60次数据库交互。我在Mapper层用了连表查询一次性查完配合前端的分页组件性能完全够用。7. 答辩与使用建议怎么把这个项目讲得比别人出彩如果你拿这套系统去做毕业设计我建议多准备几个问题的回答为什么用Vue不用React乐观锁和悲观锁各自的优缺点如果拍卖商品量达到百万级数据库怎么做分库分表这些问题不吃透答辩时容易被追问到细节。我个人实操后的感受是这套系统真正的价值不止是“能跑”而是把拍卖业务中那些容易想当然的地方——比如并发出价、时间判定、超时订单——都进行了合理设计。而这些东西恰恰是网上那些Demo项目里最缺失的部分。建议你拿到源码后先不要急着一键跑起来花一个下午把数据库表结构和核心出价接口的代码读一遍搞清楚每张表为什么这么设计、每个判断为什么放在这个位置之后再改代码、加功能整个过程的收获会比你想象中大得多。最后分享一个扩展方向把当前价格的推送改成WebSocket实时推送再把管理端的“流拍处理”和“拍卖延期”功能补上这套系统就是从“课程设计”到“企业级应用”的质变。可以的话再给项目加一个简单的搜索模块按名称和分类过滤日常使用体验会好很多这个改动不算复杂却能在答辩和简历里多一个可讲的亮点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑