
简介一份面向计算机专业毕业设计场景的校园闲置物品拍卖系统完整项目采用Java、SpringBoot3与Vue.js3技术栈实现前后端分离架构。系统聚焦校园二手交易需求管理后台支持拍卖物品管理、用户管理、交易记录查询用户前台覆盖物品浏览、竞拍、购买等完整流程并兼顾节约资源与环保价值。相比常规商城本项目引入竞拍机制在出价、倒计时、成交确认等环节均有对应模块实现业务逻辑完整项目目录分层清晰前后端代码易读适合二次开发。资源包共6个文件大小约73.12MB包含源码压缩包、返修源码、数据库SQL脚本、需求文档Word及操作录屏MP4覆盖从开发、部署到演示答辩的关键材料。已有147人学习下载。借助录屏教程和启动说明可快速跑通系统理解SpringBoot3接口设计、Vue.js3页面交互及MySQL8数据表结构也能对照需求文档梳理业务流程便于进一步扩展功能或整理毕业设计文档。1. 校园闲置物品拍卖系统不是“又一个CRUD”而是毕设里少有的“能运营”的选题每年毕业设计选题季总有一批人会冲着“校园闲置物品拍卖”这几个字下单以为是做个二手交易平台。真正动手才发现商品发布、图片上传、竞拍出价、订单生成、个人中心这些功能单个拿出来都不难但要把它们串成一个完整闭环再套上SpringBoot3和Vue3这一对还算新的技术栈就暴露出问题了——数据库表怎么设计才能支撑“拍卖”而不是“普通商城”多人同时出价怎么保证不超卖前端Axios拦截器和后端JWT鉴权怎么配合这些才是这个题目真正值钱的地方。这篇笔记面向的是拿到这个题目但还没动手的同学也适合想快速搭一套“带竞拍逻辑的管理系统”做练习的从业者。我按自己做过类似项目的顺序来讲先拆业务边界再定表结构和接口然后分别落后端和前端的核心代码最后给出部署顺序和踩坑记录。你不需要真的做过拍卖系统只要跟着这个思路走至少能避开一半的返工。先说一个反直觉的结论这个项目的难点不在“拍卖”而在“并发出价”和“订单状态流转”。拍卖只是多了一个出价表和一个最高价判断逻辑而订单从“竞拍成功”到“买家确认收货”之间的状态机才是让毕设答辩时能讲出东西的地方。所以本文会花较多篇幅在并发和状态设计上而不是一遍遍重复怎么写增删改查。2. 业务功能拆解从“玩家视角”反推系统模块2.1 三个角色三套权限一条闭环校园闲置物品拍卖系统最核心的是角色划分常见做法是三种角色管理员、卖家发布者、买家竞拍者。同一个用户账号可以既是卖家又是买家所以不要做成“用户表里加一个role字段”的简单设计因为一个人可能同时拥有两种身份。正确的做法是用户表只存基础信息用一张“用户角色关联表”或者直接在用户表里用int做位标记但业务判断时按“当前动作”来校验而不是全局校验。推荐在SpringBoot3里用Spring Security或者Sa-Token做权限考虑到毕业设计的答辩深度用Sa-Token更轻量注解式鉴权足够用。角色和权限的映射关系做成数据库表不要写死在代码里。别小看这个设计答辩时老师一定会问“管理员能不能直接修改竞价记录”这类越权问题你需要一个合理的权限模型来回答。核心业务闭环是卖家发布闲置物品设定起拍价、加价幅度、拍卖截止时间买家在截止前出价每次出价必须高于当前最高价截止后系统自动判定最高价者中标生成订单买家支付或线下交易确认收货后订单完成管理员负责审核商品、处理违规和争议。围绕这个闭环功能模块分成六个用户中心、商品模块、竞拍模块、订单模块、消息通知、后台管理。2.2 商品发布与竞拍规则参数不设计好后面全是坑商品发布页面看起来简单但两个参数必须符合拍卖语义起拍价和加价幅度。起拍价不能为负数加价幅度必须大于0这两个字段在前端校验和后端校验都要做前端校验只是为了用户体验后端校验才是安全底线。还有一个容易被忽略的是“拍卖截止时间”它必须晚于当前时间而且建议限制最小间隔——比如至少10分钟后才能截止否则会出现刚发布就被秒拍的极端情况。竞拍规则里最重要的一条是“出价必须是当前最高价加至少一个加价幅度”这个判断必须放在后端事务里做不能依赖前端传过来的价格。竞拍记录表需要记录的是竞拍人ID、商品ID、出价金额、出价时间。每次出价成功后要同时更新商品表的“当前最高价”和“最高出价人ID”这两个字段是冗余设计但查询时能少一次Join对毕业设计来说够用答辩时还能讲“用空间换时间”。加价幅度有两种设计固定幅度和按比例幅度。固定幅度对新手用户更友好建议默认用固定幅度但商品发布时允许卖家自定义。注意不能在发布后修改加价幅度否则会产生竞拍纠纷这一点要作为一条业务规则写在后端。2.3 消息通知与订单状态机给答辩加分的隐藏模块消息通知模块很多同学会省略但建议至少做一个站内信。触发时机有四个出价被反超时通知原出价人、拍卖成功时通知买家和卖家、买家确认收货后通知卖家、卖家对订单发起投诉时通知管理员。不需要做邮件或短信站内信 未读数量角标就够。订单状态机是这个项目里最值得写清楚的地方。我建议用四态待付款、待发货、待收货、已完成。拍卖结束后自动生成待付款订单设置24小时支付时限超时自动取消并关单同时给第二高价者递补机会——当然递补逻辑对毕设来说可以不做但要在答辩时说明你“知道这个机制”只是出于系统复杂度考虑没有实现。每个状态变更都写一条操作记录到operate_log表方便审计。3. 数据库设计与接口约定:拍卖逻辑不是“商品表加个price”那么简单3.1 表结构设计:一张核心表加三张辅助表数据库设计直接决定后端代码能少写多少。常见的表有七张:用户表(user)、商品表(product)、竞拍记录表(bid_record)、订单表(order)、收藏表(favorite)、消息表(message/notification)、操作日志表(operate_log)。商品表是最关键的表必须包含的字段分为基础信息、拍卖参数、冗余状态三类:字段说明id主键建议用雪花IDtitle / description / cover_image标题、描述、封面图category_id分类ID关联分类表start_price起拍价bid_increment加价幅度current_price当前最高价冗余字段current_bidder_id当前最高出价人ID冗余字段auction_start_time / auction_end_time起拍时间和截止时间status商品状态0待审核、1拍卖中、2已成交、3已下架、4流拍seller_id卖家ID竞拍记录表(bid_record)必须给“商品ID 出价时间”建联合索引因为每次出价查询都要按商品和价格倒序取记录。出价金额字段用DECIMAL(10,2)不要用float或double否则金额计算会出现精度问题这是后端开发里最基础的坑但每年都有人踩。辅助表里消息表和操作日志表需要注意字段的通用性消息表用type区分“出价反超/拍卖成功/系统通知”操作日志表存actor_id、action、target_type、target_id、create_time不要为了某个具体场景把字段写死。3.2 REST接口约定状态码、统一返回体、鉴权头接口设计统一用REST风格路径按资源命名:/api/auth/login、/api/product/page、/api/bid/place、/api/order/page。返回体用一个统一的Result 类包裹,包含code、message、data三个字段code约定200成功、401未登录、403无权限、500服务器错误。很多同学喜欢用HTTP状态码直接做业务状态但这样前端很难区分“登录过期”和“服务端异常”统一返回体是更稳妥的方案。鉴权用JWT登录成功后返回token前端存储到localStorage或Pinia里每次请求在Axios拦截器中往header加Authorization: Bearer 。后端用一个拦截器解析token把userId放入ThreadLocal或Sa-Token的会话中业务代码里直接获取当前用户ID。注意拦截器要放行登录接口、商品列表接口、商品详情接口因为这些是游客也能看的但如果竞拍接口没鉴权那这项目基本不合格。3.3 核心SQL分页查询和竞拍记录商品列表页需要按状态、分类、关键词过滤排序方式有“即将截止”和“最新发布”两种。这个查询用MyBatis-Plus的LambdaQueryWrapper就能写但注意分页要配置PaginationInnerInterceptor插件否则Page对象的total永远是0。另外status字段建议做索引因为列表页的默认查询条件就是status1(拍卖中)。竞拍记录的查询永远是“按商品ID取最新N条”SQL对应SELECT user_id, price, create_time FROM bid_record WHERE product_id #{productId} ORDER BY price DESC LIMIT #{limit}这段SQL的逻辑说明:排序用price而不是create_time是因为可能出现“同秒内两个出价MySQL的datetime精度不够”的情况。虽然概率极低但排序字段选price语义上更贴合“谁出价高谁在上面”。参数limit建议传5或10前端只需要显示最近几条。注意这里Order By price DESC不会影响竞拍判断竞拍判断是拿用户传入的出价和商品表的current_price做比较不需要查bid_record表这也是冗余current_price字段的意义——避免并发下读到不一致的记录。4. 后端SpringBoot3实现工程结构、竞拍并发与核心服务4.1 工程结构按业务模块分包别按三层架构硬切不推荐建一个大包然后全塞service/controller/mapper项目看多了就知道按业务边界分包更好。常见结构是:com.campus.auction ├── common // Result, 异常处理, 工具类 ├── config // Sa-Token/拦截器, MyBatis-Plus配置 ├── module │ ├── auth // 登录、注册、token │ ├── product // 商品发布、列表、详情、上下架 │ ├── bid // 出价、竞拍记录 │ ├── order // 订单生成、支付确认、收货 │ ├── message // 站内信 │ └── admin // 后台管理这个结构的好处是每个模块之间的依赖方向是清晰的bid依赖productorder依赖product和bidadmin依赖所有模块。controller层尽量薄只做参数接收和调用service核心逻辑放service层且私有方法拆细一点方便写单元测试——答辩时能拿出几个JUnit测试用例是非常加分的。依赖方面用SpringBoot3注意JDK必须17JDK8跑不了SpringBoot3这个是很多入门同学第一次翻车的地方。ORM选MyBatis-Plus 3.5.x版本它能帮你省掉大量单表CRUD代码分页插件、逻辑删除、自动填充这些功能都内置了基本不需要手写Provider。4.2 竞拍并发数据库行锁是最直观的解法Redis是加分项竞拍接口是本项目的核心也是最容易被问住的点。如果两个用户几乎同时出价且两者的出价都高于current_price那么数据库层面的更新是:Transactional(rollbackFor Exception.class) public synchronized BidResult placeBid(Long productId, BigDecimal price) { // 1. 锁行查询商品并锁定 Product product productMapper.selectByIdForUpdate(productId); // 2. 校验竞拍状态 if (product.getStatus() ! 1) { throw new BizException(商品不在竞拍中); } if (price.compareTo(product.getCurrentPrice().add(product.getBidIncrement())) 0) { throw new BizException(出价不能低于当前最高价加加价幅度); } if (LocalDateTime.now().isAfter(product.getAuctionEndTime())) { throw new BizException(拍卖已结束); } // 3. 更新商品最高价 product.setCurrentPrice(price); product.setCurrentBidderId(currentUserId); productMapper.updateById(product); // 4. 插入竞拍记录 BidRecord record new BidRecord(); record.setProductId(productId); record.setUserId(currentUserId); record.setPrice(price); bidRecordMapper.insert(record); return BidResult.success(productId, price); }注意几个点。selectByIdForUpdate是自定义SQL对应SELECT * FROM product WHERE id #{id} FOR UPDATE这会在事务内锁住这一行直到事务提交另一个线程的同样的查询会被阻塞从而避免并发写覆盖。service方法加了Transactional锁和更新必须同一个方法内完成不能拆到多个方法后再拼接事务。并发校验必须在拿到锁之后做否则你查到的current_price是锁之前的旧值这就是所谓的“读已提交”。数值比较用compareTo不要用equals。BigDecimal的equals会比较精度0.00和0是不同对象但compareTo只看数值大小。如果用户传的是10.00你查出来的是10equals会返回false导致误判。这个坑非常隐蔽写的时候不觉得测试时也复现不出来直到某天有人传了一个带两位小数的出价你才会发现所有高价竞拍都失败了。如果想让答辩更有亮点可以考虑在服务层加一个Redis分布式锁redisTemplate.opsForValue().setIfAbsent(bid:lock: productId, 1, 5, TimeUnit.SECONDS)。先尝试获取锁失败则提示“操作频繁”成功则继续走数据库行锁逻辑。这样你可以在答辩时说“数据库行锁保证一致性Redis锁保证接口层的并发可控防止大量线程同时打到数据库上。”这个深度对本科毕设来说已经足够。4.3 订单生成与超时关单定时任务 状态机拍卖结束后生成订单最简单的做法是提供一个“结算接口”由定时任务每分钟扫描一次Scheduled(cron 0 * * * * ?) public void settleExpiredAuctions() { ListProduct expiredProducts productMapper.selectExpiredAndUnsettled(); for (Product product : expiredProducts) { try { orderService.createOrderFromAuction(product); } catch (Exception e) { log.error(结算失败, productId{}, product.getId(), e); } } }selectExpiredAndUnsettled的条件是status1且auction_end_time小于等于当前时间且当前时间比auction_end_time晚不超过30分钟——加了时间窗口是为了防止定时任务重跑时把刚结算过的商品再结算一次。createOrderFromAuction里先判断product.getCurrentBidderId()是否为空为空则是流拍直接更新状态为4不为空则创建待付款订单。注意整个for循环里每个商品独立try-catch否则一条数据异常会导致整个批次全部回滚。定时任务别忘了在启动类或配置类上加EnableScheduling。如果使用单机定时任务在集群部署时会有重复执行的问题但毕设是单机部署不存在这个问题答辩时如果被问到如实说“当前设计基于单机部署集群场景需引入分布式调度框架”就好。4.4 文件上传与本地存储路径商品图片上传用MultipartFile接收存储到本机固定目录然后将访问URL拼接到数据库。常见的坑在路径Windows和Linux的路径分隔符不一样建议用System.getProperty(os.name)判断后拼接或者直接用相对路径然后通过一个本地静态资源映射暴露出去。PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename UUID.randomUUID() ext; File targetFile new File(UPLOAD_DIR, filename); file.transferTo(targetFile); return Result.success(/images/ filename); }扩展名校验必须做白名单只能是jpg、png、gif、webp不要用黑名单——黑名单永远不完整而且攻击者可以大小写绕过。大文件限制建议在SpringBoot配置里设max-file-size5MB、max-request-size20MB这也属于安全层面的基础防线。5. 前端Vue.js3实现:页面结构、Pinia状态管理与Axios封装5.1 工程骨架与依赖选型前端用Vite创建Vue3项目比webpack快很多npm create vitelatest后选择vue模板即可。UI组件库选Element Plus它在Vue3生态里地位相当于Vue2里的Element UI表单组件和表格组件都够用而且文档和社区非常成熟。状态管理用Pinia比Vuex更轻TS支持更好。路由用Vue Router 4官方标配。工程结构建议按“视图 组件 状态”拆:src/ ├── api/ // 接口定义按模块拆分 ├── assets/ // 图片、样式 ├── components/ // 公共组件 ├── router/ // 路由表 ├── stores/ // Pinia的store ├── views/ // 页面 │ ├── home/ │ ├── product/ │ ├── bid/ │ ├── order/ │ ├── user/ │ └── admin/ └── utils/ // axios封装、日期格式化等5.2 Axios封装:拦截器统一处理token和错误码Axios封装是前端工程化的基础。请求拦截器里从Pinia取token加到header响应拦截器里统一处理code后端返回401就跳登录页返回500就用Element Plus的Message组件弹出错误信息。下面是一个常见封装方式:import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { const userStore useUserStore() userStore.clearToken() router.push(/login) ElMessage.warning(登录已过期请重新登录) } else { ElMessage.error(网络异常请稍后重试) } return Promise.reject(error) } ) export default requestresponse拦截器里直接返回res.data所有页面调用时拿到的是真正的业务数据不需要再解一层。注意后端统一返回体里的code是业务code和HTTP状态码不要混淆。 /api这个baseURL在开发时会通过Vite的proxy转发到后端。5.3 Pinia定义用户状态:token、userInfo、角色判断用户状态的全局管理建议这样写:import { defineStore } from pinia import { loginApi, getUserInfoApi } from /api/auth export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null }), actions: { async login(payload) { const data await loginApi(payload) this.token data.token localStorage.setItem(token, data.token) await this.fetchUserInfo() }, async fetchUserInfo() { const data await getUserInfoApi() this.userInfo data }, clearToken() { this.token this.userInfo null localStorage.removeItem(token) } } })这里的核心逻辑是token持久化到localStorage刷新页面不丢登录态userInfo不持久化刷新后重新请求因为它包含角色等动态信息退出登录调用clearToken同时路由跳转回首页。注意state里访问localStorage要写在顶层而非函数内部这样store初始化时就会读取否则用户刷新页面后token还没到内存中请求拦截器会认为未登录。5.4 竞拍弹窗与倒计时组件竞拍出价是前端交互的重点。建议设计成Dialog弹窗打开时通过WebSocket或轮询获取当前最高价用户输入新价格前端先做一次校验价格必须大于当前最高价加加价幅度再调用竞拍接口。如果校验不通过直接在表单下用文案提示不要弹错误框体验更好。倒计时组件用setInterval实现每秒更新剩余时间。注意组件卸载时要clearInterval否则出价成功后定时器还在跑页面出现“拍卖已结束但倒计时还在走”的Bug。倒计时到0时轮询后端结算结果或者直接提示刷新。一个更稳妥的实现秒数是前端算的但核心判断永远以服务端时间为准前端倒计时只做展示。这同样是一条值得在答辩时讲的设计原则——前端的时间不可信。5.5 管理后台:表格、状态标签、审核操作管理后台的页面复杂度不高但建议做成完整的一章商品管理表格展示所有商品状态列用el-tag渲染不同颜色待审核商品有“通过/驳回”两个按钮用户管理表格展示用户列表和角色支持禁用账号订单管理按状态过滤。后台的路由守卫在router.beforeEach里判断用户角色管理员id为1的路径前缀统一用/admin不是管理员访问就直接跳首页。注意前端路由守卫只是体验层面的限制真正的权限校验在后端接口上即使有人绕过前端直接请求后端接口后端也能拦住这才叫安全。6. 部署与避坑JDK版本、上传路径、并发超卖这几个坑我替你踩过了6.0 部署顺序把后端打成jar包前端npm run build后把dist目录部署到Nginx。后端服务的端口建议设定为8080或8081Nginx配置里把/api前缀反向代理到后端地址同时把前端的静态资源root指向dist目录。启动前检查三件事JDK版本必须是17或更高MySQL版本建议8.xRedis如果没用到就不强求。如果本地有多个JDK版本请确保java -version输出的是17否则SpringBoot3根本起不来这个问题几乎每天都有同学问。6.1 坑一图片上传成功但访问404这是个“现象”、“原因”、“解决”的关系。现象是文件上传接口返回成功但浏览器访问图片URL时404。原因绝大多数是没有做静态资源映射SpringBoot对本地磁盘的路径默认不提供HTTP访问。解决方法是写一个配置类Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath System.getProperty(user.dir) /upload/; registry.addResourceHandler(/images/**) .addResourceHandler(uploadPath **); } }但更稳妥的做法是直接把上传目录放在项目resources/static下开发时省事部署时也不会丢。不过说实话生产环境放项目目录内会有重启丢文件的风险毕设就不用考虑这一层了答辩时提一句“生产环境应使用对象存储”就好。6.2 坑二并发出价超卖价格被覆盖现象是两个人同时出价后提交的人拿到了成功结果但数据库里价格却比先提交的低。原因是没加锁事务A和事务B同时读到了相同的current_price各自判断一次“出价大于当前价”然后分别写入最终结果是后提交的覆盖了先提交的。解决方法是加selectForUpdate行锁并确保service方法是Transactional的。注意行锁只能锁行如果product表没有按主键查询FOR UPDATEMySQL会锁全表性能会急剧下降所以selectByIdForUpdate里的#{}参数必须传主键id。还有一点容易漏掉被事务保护的出价方法里所有业务字段的更新都要放在事务里不能用MyBatis-Plus的updateById包裹一个不带锁的Service方法——那样锁会提前释放。处理方式是把竞拍逻辑放到同一个Service方法里这样事务边界就是你方法体的边界。6.3 坑三本地能跑部署到服务器后访问接口502现象是前端页面正常打开但调用接口全部502。原因是Nginx的proxy_pass配置问题后端启动在127.0.0.1:8080Nginx配置里proxy_pass写成了/而不是http://127.0.0.1:8080/。解决办法是检查Nginx配置文件里location /api/这一段确保proxy_pass指向后端实际地址。还有可能是后端服务没启动成功或者端口没开可以先用curl http://localhost:8080/api/auth/login验证后端是否可达再逐层排查。这类问题不要乱猜按网络链路一步一步走浏览器→Nginx→后端端口→应用日志每层都有日志看日志是最快的。6.4 坑四定时任务结算重复订单创建了两笔现象是同一商品拍卖结束后生成了两个相同的订单。原因是定时任务跑了两遍第一遍执行了一半应用重启了长事务回滚后第二遍又执行了。解决思路是幂等设计在订单表对product_id加唯一约束createOrderFromAuction里先查询是否已存在订单存在则不创建。毕业设计做到这个程度就够不用引入分布式锁。6.5 坑五前端npm run dev能跑build后白屏现象是Vite构建后没有报错但部署到Nginx后打开是白屏。原因通常是base路径的问题Vite默认base是“/”如果你把前端静态文件放在服务器的某个子目录下资源路径会对不上。解决办法是在vite.config.ts里设置base: ./让资源相对当前路径加载。这个坑在毕设里出现频率排前三记住了就不会白折腾一晚上。这些坑不是从文档里看出来的是你真跑一遍才会撞上的。老实说每次重做一个项目我大概有三分之一的时间都花在这些环境问题和边界情况上后来养成的习惯是每到一个新环境先把最小链路登录→列表→详情跑通再往上面叠业务而不是把所有代码写完再一次性启动调试。毕竟咱们做工程最怕的不是业务难而是“哪一步有问题都说不清楚”。希望这篇拆解能帮你把这个毕设做得更顺把该避的坑提前绕开别把时间浪费在环境上。本文还有配套的精品资源点击获取