做了这么多年Java后端,也带过不少实习生和毕业生,我越来越觉得“拍卖系统”是毕设选题里被低估的一个方向。市面上大量“学生管理系统”“图书管理系统”同质化严重,答辩时老师一眼看穿,很难有亮点。而SpringBoot+Vue的在线拍卖系统,业务闭环完整、有并发场景、有状态流转、有前后端分离,不管是课设、毕设还是自己练手学习,都能让你真正学到东西,而不是停留在“增删改查”表面。
这篇文章就围绕这个项目的完整实现展开,包含技术选型逻辑、数据库设计、后端核心模块(尤其出价并发控制)、前端关键交互、部署上线以及问题排查。内容会比较多,但每一步都是可落地、可复现的,适合有一定Java基础、正在找全栈项目练手的朋友。
1. 项目定位与技术选型思路
1.1 为什么是SpringBoot+Vue+MySQL这套组合
先说结论:这套技术栈是目前国内中小型项目、企业面试题和校园毕设里出现频率最高的组合之一。SpringBoot简化了SSM时代的配置地狱,内嵌Tomcat让部署不再是痛苦事;Vue作为渐进式前端框架,学习曲线平缓、生态丰富;MySQL则是最经典的开源关系型数据库,资料多、排错容易。三者组合,既主流又有深度,答辩时老师不会觉得你在炫技,但你又能在业务里展示并发控制、状态机设计这些硬功夫。
SpringBoot真正让我觉得“省心”的,是它的自动装配机制。你引入spring-boot-starter-web,内嵌容器、DispatcherServlet这些就被自动配置好了;引入spring-boot-starter-data-redis,RedisTemplate就能直接注入使用。原理上,@SpringBootApplication里的@EnableAutoConfiguration会扫描META-INF/spring.factories(新版本是AutoConfiguration.imports)中声明的自动配置类,再配合@ConditionalOnClass、@ConditionalOnMissingBean等条件注解按需生效。面试时被问到“SpringBoot自动装配原理”,其实就是这句话展开说清楚,这也是我在后面设计项目时一直遵循的思路:全局配置简化,但核心逻辑自己掌控。
Vue这边,我选的是Vue2 + Element UI组合。很多新手一上来就追Vue3 + Element Plus,不是说不行,而是网上大量毕设参考代码、踩坑文章都基于Vue2,遇到问题更容易找到答案。如果你是自学者,先把一套组合吃透,比频繁换技术栈更重要。前端管用户交互,后端管业务逻辑,前后端分离通过JSON交互,这种协作模式也是目前企业的标准做法。
1.2 在线拍卖系统的业务闭环与技术亮点
拍卖系统和普通电商系统最大的区别在于“价格是动态变化的”。普通商城是明码标价,用户下单支付就行;拍卖系统则是起拍价+加价幅度+出价记录+截拍时间,这导致业务上有几个真正值得下功夫的点:
第一是出价并发控制:多个用户同时出价,必须保证最终只有一个成功,而且系统里记录的当前最高价不能乱。这个场景能用来讲清楚“锁”“事务”“隔离级别”这些概念,而不是停留在背面试题。
第二是拍卖状态流转:一个拍品要经历“待审核 -> 竞拍中 -> 已结束(成交/流拍)”,状态的推进可以由定时任务扫描触发,也可以由用户操作触发。把这个理顺,你就理解了状态机设计在业务系统里的作用。
第三是前后端高频交互:前端要展示倒计时、刷新最新出价、在最后几秒防止“截拍狙击”,这里就涉及轮询、接口幂等、组件生命周期管理等细节。
第四是管理后台闭环:管理员审核拍品、管理场次、处理成交订单,这个部分能让你的系统从“demo”变成“完整平台”,也是答辩时展示系统完备性的重要模块。
这套项目的整体架构可以概括为:三个端(用户前端、管理后台、后端服务),一个数据库(MySQL)。用户前端用Vue搭建,管理后台可以和用户端合并成一个工程,划分路由即可;后端按标准分层结构拆成controller / service / mapper / entity;数据库以商品表和出价记录表为核心,向外辐射出订单、用户、公告等表。
2. 系统功能模块与数据库设计
2.1 角色权限与功能拆分
在设计系统前,先把角色理清楚。一个在线拍卖平台必须有普通用户和管理员两种角色,否则“管理平台”名不副实。
普通用户端核心功能:
- 注册登录、个人信息维护
- 浏览拍卖中的拍品列表、查看拍品详情
- 对竞拍中的拍品出价,查看自己的出价记录
- 竞拍成功后生成待支付订单,进入个人订单列表
- 查看平台公告
管理后台核心功能:
- 用户管理:查看用户列表、禁用异常账号
- 拍品管理:审核用户提交的拍品、上架/下架、设置拍卖场次
- 场次管理:创建拍卖场次,设定开始时间、结束时间
- 订单管理:查看成交订单、处理发货
- 公告管理:发布平台公告
- 数据看板:统计成交额、成交数量、热门拍品等
功能清单看着多,但落到数据库上其实只有六张核心表就够:用户表、拍品表、出价记录表、订单表、拍卖场次表、公告表。加上一个操作日志表(可选)就可以支撑完整业务。
2.2 数据库表结构设计要点
数据库设计是毕设答辩的高频提问区域,设计得好不好,内行人一眼就能看出来。这里先说几个关键原则,再给核心建表SQL。
金额字段用Decimal,绝不用Float/Doublefloat和double在MySQL里是近似存储,做价格计算会出“0.1+0.2不等于0.3”这种诡异问题。金额用DECIMAL(10,2),精确到分,稳妥又专业。
状态字段用TinyInt存储,代码里用枚举对应数据库里存0、1、2、3这样的数字,不要直接存中文。后端定义枚举类统一管理,避免代码里到处写魔法数字。比如拍品状态:
@Getter @AllArgsConstructor public enum AuctionStatus { PENDING(0, "待审核"), ONGOING(1, "竞拍中"), FINISHED(2, "已结束"), SOLD(3, "已成交"), FLOW(4, "流拍"); private final int code; private final String desc; }出价记录表要建联合索引一次拍卖可能产生几百上千条出价记录,需要经常按“拍品ID + 出价金额”查询最高价和出价历史,所以表结构里要提前考虑索引。这也是答题時可以讲的细节:“MySQL索引为什么用B+树”“联合索引最左前缀原则”这些基础知识,正好在这个表上做文章。
用户表:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `role` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1普通用户 2管理员', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1正常 0禁用', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';拍品表:
CREATE TABLE `goods` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `goods_name` varchar(100) NOT NULL COMMENT '拍品名称', `description` text COMMENT '拍品描述', `cover_img` varchar(255) DEFAULT NULL COMMENT '封面图URL', `start_price` decimal(10,2) NOT NULL COMMENT '起拍价', `current_price` decimal(10,2) DEFAULT NULL COMMENT '当前最高出价', `bid_increment` decimal(10,2) NOT NULL DEFAULT '100.00' COMMENT '最低加价幅度', `auction_id` bigint(20) DEFAULT NULL COMMENT '所属场次ID', `seller_id` bigint(20) DEFAULT NULL COMMENT '委托用户ID', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待审核 1竞拍中 2已结束 3已成交 4流拍', `start_time` datetime DEFAULT NULL COMMENT '开拍时间', `end_time` datetime DEFAULT NULL COMMENT '截拍时间', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_auction_id` (`auction_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拍品表';出价记录表:
CREATE TABLE `bid_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `goods_id` bigint(20) NOT NULL COMMENT '拍品ID', `user_id` bigint(20) NOT NULL COMMENT '出价用户ID', `price` decimal(10,2) NOT NULL COMMENT '出价金额', `create_time` datetime DEFAULT NULL COMMENT '出价时间', PRIMARY KEY (`id`), KEY `idx_goods_price` (`goods_id`, `price`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出价记录表';这里有个细节值得展开:current_price字段被冗余到了拍品表里。为什么不直接每次从出价记录表里取MAX(price)?因为在“拍卖进行中”这个高频查询场景下,每次聚合一张越来越大的记录表,性能会随着数据量增长明显下降。把当前最高价冗余在拍品表上,查询详情页时只需要带走一条记录,效率最高。代价是出价时需要在一个事务里同时更新拍品表并插入出价记录,保证数据一致。这个“空间换时间”的思路,恰恰是面试中常考的“反范式设计”的典型例子。
2.3 订单表与关联关系
竞拍结束后,系统会根据出价记录生成待支付订单。订单表建议单独拆出来,而不是把订单信息挂在拍品表上,原因很简单:订单有自己独立的生命周期(待支付、已支付、已发货、已完成),也有独立的金额快照需求。比如拍品当前价格可能因为后续操作变化,但订单里的成交金额必须冻结在成交那一刻。
订单表核心字段:
CREATE TABLE `order_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `goods_id` bigint(20) NOT NULL COMMENT '拍品ID', `buyer_id` bigint(20) NOT NULL COMMENT '买家ID', `seller_id` bigint(20) NOT NULL COMMENT '卖家ID', `amount` decimal(10,2) NOT NULL COMMENT '成交金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已发货 3已完成', `pay_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_buyer_id` (`buyer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';表与表之间的关系不复杂:一个用户可以委托多个拍品(1对多),一个拍品属于一个场次(多对1),一个拍品有N条出价记录(1对N),一个成交拍品对应一条订单(1对1)。把这些关系画清楚,做前端页面和写接口的时候思路会非常清晰。
3. 后端核心实现:SpringBoot关键模块
3.1 项目结构与基础设施搭建
后端我习惯按“controller -> service -> mapper -> entity”四层拆分,再补上config、common等辅助包。完整结构如下:
com.auction.system ├── controller # 接口层 │ ├── AuthController │ ├── GoodsController │ ├── BidController │ ├── OrderController │ └── AdminController ├── service # 业务层 │ ├── AuthService │ ├── GoodsService │ ├── BidService │ └── OrderService ├── mapper # 数据访问层 │ ├── UserMapper │ ├── GoodsMapper │ ├── BidRecordMapper │ └── OrderMapper ├── entity # 数据库实体 │ ├── User │ ├── Goods │ ├── BidRecord │ └── OrderInfo ├── config # 配置类 │ ├── CorsConfig │ ├── WebMvcConfig │ └── MybatisPlusConfig └── common # 封装的通用类 ├── Result ├── BusinessException └── GlobalExceptionHandler项目依赖在pom.xml里加这几组就够:SpringBoot Web、MyBatis-Plus、MySQL驱动、Lombok、JWT工具包、Hutool工具集。
mybatis-plus 是我比较推荐的持久层框架,它的内置CRUD方法能省掉大量Java代码。普通单表操作,你甚至不需要写XML,只用LambdaQueryWrapper就能搞定。它不是把MyBatis的灵活能力弄丢,而是把“动态SQL拼装”这个麻烦事藏了起来。对于毕设级别的项目,完全够用,而且代码可读性比手写一堆XML好得多。
配置方面,application.yml里最核心的是数据源配置。MySQL 8.0以上版本要注意驱动类变化和时区问题:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/auction_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplserverTimezone=Asia/Shanghai这个参数非常关键,缺失时数据库连接会直接报The server time zone value异常。这是我见过出现频率最高的启动报错之一。
3.2 登录鉴权与JWT实践
管理平台必须做登录鉴权,不可能让游客直接调用管理接口。这里我采用JWT无状态鉴权方案,原因是它实现简单、前后端分离天然友好,不需要在服务端维护Session。
注册时,密码用BCrypt加密。BCrypt和MD5、SHA这种摘要算法最大的不同是自带随机盐,每次加密结果都不同,同一个密码存到数据库里也是不同的密文,这样即使数据库泄露,彩虹表攻击也很难生效。
// 注册时加密 String encodedPwd = BCrypt.hashpw(user.getPassword(), BCrypt.gensalt()); user.setPassword(encodedPwd); // 登录时校验 if (BCrypt.checkpw(rawPassword, user.getPassword())) { // 密码正确,生成token String token = JwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); }登录成功后将用户ID和角色写入JWT,返回给前端。前端把Token存在localStorage里,后续每个请求在拦截器中带上Authorization: Bearer <token>头。
后端做一个拦截器统一校验:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); if (claims != null) { request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } } response.setStatus(401); return false; } }在WebMvcConfig里注册拦截器时,要设计好放行路径。登录、注册、拍品列表、拍品详情这些公开接口放行,出价、订单、管理后台相关接口都要拦截。管理后台接口还要再校验role == 2,防止普通用户越权访问。
3.3 拍卖业务核心:出价并发控制的三种方案
这是整个系统的灵魂,也是你答辩时最有技术含量的谈资。先说问题:当两个用户同时出价,都读到当前价格是1000元,一个报1200,一个报1300,怎么保证最终数据库里的结果不是乱的?
方案一:数据库悲观锁(FOR UPDATE)
在事务中查询拍品时使用SELECT ... FOR UPDATE,把这一行锁住,直到事务提交或回滚才释放。并发场景下,第二个用户的查询会阻塞,等第一个事务完成后再读取到最新数据。
@Transactional(rollbackFor = Exception.class) public BidResult placeBid(BidRequest request) { Goods goods = goodsMapper.selectForUpdate(request.getGoodsId()); if (goods == null || goods.getStatus() != 1) { throw new BizException("拍品不存在或不在竞拍期"); } BigDecimal currentPrice = goods.getCurrentPrice() == null ? goods.getStartPrice() : goods.getCurrentPrice(); if (request.getPrice().compareTo(currentPrice.add(goods.getBidIncrement())) < 0) { throw new BizException("出价不能低于当前价加最低加价幅度"); } // 更新当前价并插入出价记录 goods.setCurrentPrice(request.getPrice()); goodsMapper.updateById(goods); BidRecord record = new BidRecord(); record.setGoodsId(goods.getId()); record.setUserId(request.getUserId()); record.setPrice(request.getPrice()); bidRecordMapper.insert(record); // 自动延时逻辑:距结束不足1分钟则延长1分钟 return BidResult.success(goods.getCurrentPrice()); }这个方案实现简单、思路清晰,对毕设项目来说完全够用。缺点是并发高时数据库行锁竞争会成为瓶颈,但拍卖这种低频高价值场景,完全不需要担心。
方案二:乐观锁(CAS)
不锁行,而是在更新时带上版本号或旧状态作为条件。更新的影响行数为0,说明数据已经被别人改了,本次操作失败。
UPDATE goods SET current_price = #{newPrice}, version = version + 1 WHERE id = #{goodsId} AND current_price = #{oldPrice}代码里对应的是:
boolean success = goodsMapper.updatePriceWithVersion(goodsId, newPrice, oldPrice); if (!success) { throw new BizException("手慢了,价格已被刷新,请重新出价"); }乐观锁的优点是并发性能更好,缺点是冲突频繁时用户体验差,用户可能反复刷新重试。适合“读多写少、冲突概率低”的业务,拍卖出价其实也算适配。
方案三:Redis+Lua脚本
把当前价格、结束时间等数据放到Redis里,用Lua脚本原子性地执行“读价格 -> 判断是否满足加价幅度 -> 更新价格 -> 记录出价人”这一串操作。这是电商秒杀场景的经典方案,性能极高,但实现复杂,需要额外部署Redis,而且最终数据还是要异步落库。
我的建议是:项目里主推“悲观锁+事务”,同时把乐观锁代码也写在同一个Service里,注释说明两套方案的适用场景。答辩时老师问你“并发问题怎么解决”,你不仅能回答,还能对比分析三个方案的优劣,这就是加分项。
3.4 定时任务与拍卖状态流转
拍卖系统的另一个核心问题是状态流转:一场拍卖从“未开始”到“竞拍中”再到“已结束”,以及结束时判断成交还是流拍,必须有可靠机制触发。
我采用Spring自带的@Scheduled定时任务,单机环境下足够稳定。业务逻辑是每分钟扫描一次拍品表:
- 把
start_time <= 当前时间 <= end_time且状态为“待审核(实际上架后)”的拍品置为“竞拍中” - 把
end_time < 当前时间且状态为“竞拍中”的拍品置为“已结束” - 对“已结束”的拍品检查是否有出价记录,有则生成订单并置为“已成交”,没有则置为“流拍”
@Component @Slf4j public class AuctionStatusTask { @Scheduled(fixedDelay = 60000) public void processAuctionStatus() { // 1. 扫描开拍:状态为待审核/已上架,start_time已到,置为竞拍中 // 2. 扫描截拍:状态为竞拍中,end_time已过,置为已结束 // 3. 生成订单:对已结束的拍品,判断最高出价并生成订单 } }细心的人会发现,光有每分钟扫描还不够。如果用户在结束前30秒出价,这个出价是有效的,但下一分钟扫描时拍品已经结束,这个出价就“来不及”了。所以出价接口里要有自动延时逻辑:当出价时间距离end_time不足1分钟时,将end_time顺延1分钟。这就是很多拍卖平台“最后1分钟有人出价,拍卖延长1分钟”的实现原理,本质上是为了防止最后时刻的“狙击出价”。
状态流转统一用AuctionStatus枚举维护,每次状态变更都写日志,这样出了问题可以追溯。把这条链路跑通,你的系统就不再是简单的CRUD了,而是一个有“业务节奏”的平台。
4. 前端核心实现:Vue与管理后台
4.1 前端工程化与路由设计
前端建议用Vue CLI 4或5创建项目。如果你是跟着教程安装遇到过ERESOLVE之类依赖报错,可以手动把npm源切成淘宝镜像,再安装依赖,基本能解决大部分环境问题。
npm config set registry https://registry.npmmirror.com vue create auction-web npm install vue-router@3 axios element-ui@2路由设计上分成两大块:用户端和管理端。用户端路由包括首页、拍品列表、拍品详情、用户中心、登录注册;管理端路由包括仪表盘、拍品管理、用户管理、订单管理、场次管理、公告管理。管理端的路由组件全部放在views/admin目录下,并且用路由守卫做权限控制:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (to.path.startsWith('/admin')) { const role = localStorage.getItem('role') if (role !== '2') { next('/') } else { next() } } else { next() } })接口请求统一封装在utils/request.js里,创建axios实例、设置baseURL、添加请求拦截器注入Token、响应拦截器统一处理错误码。这样业务代码里不需要重复处理登录过期、接口报错这些公共逻辑。
4.2 核心页面交互:竞拍出价与倒计时
拍品详情页是用户端最核心的页面。要展示的信息包括:拍品图片、名称、描述、当前最高价、最低加价幅度、倒计时、出价记录列表、出价输入框。这里有两个关键技术点:
倒计时组件
倒计时不能用前端生成的时间来计算,必须根据服务端返回的endTime和本地时间做差值。很多新手直接setInterval每秒减一,页面刷新或短暂卡顿后数据就错乱了。正确写法是每次计算new Date(endTime).getTime() - Date.now()。
<template> <span>{{ formatTime }}</span> </template> <script> export default { name: 'CountDown', props: { endTime: { type: String, required: true } }, data() { return { remain: 0, timer: null } }, mounted() { this.calc() this.timer = setInterval(this.calc, 1000) }, beforeDestroy() { clearInterval(this.timer) }, methods: { calc() { const diff = new Date(this.endTime).getTime() - Date.now() this.remain = Math.max(0, parseInt(diff / 1000)) if (this.remain <= 0) { clearInterval(this.timer) this.$emit('timeup') } } }, computed: { formatTime() { const h = parseInt(this.remain / 3600) const m = parseInt((this.remain % 3600) / 60) const s = this.remain % 60 return `${h}时${m}分${s}秒` } } } </script>大于24小时不用管,组件会自动计算。
出价交互
出价框里预填“当前价 + 加价幅度”,前端先做一轮校验,不满足条件直接提示,减少无效请求。用户点“出价”按钮后,调用后端接口。成功则更新当前价格、刷新出价记录列表、弹窗提示“出价成功”;失败则弹出“出价已被超过,最新价为xxx,请重新出价”,同时刷新最新价格。
由于拍卖页需要展示最新价格,我又不想为了一个小项目上WebSocket,就采用简单轮询方案:页面挂载后每3秒调一次“查询拍品详情”接口,更新价格和倒计时。组件销毁时清掉定时器,避免内存泄漏。答辩时可以提一句“生产级别可以升级为WebSocket或SSE推送”,体现你考虑过这个问题。
4.3 管理后台:商品审核与订单管理
管理后台在技术上和用户端没有本质区别,更多的是业务功能的设计。拍品管理页面是一张表格,列出所有拍品,状态列用标签颜色区分:待审核用黄色、竞拍中用绿色、已结束用灰色、已成交用蓝色。管理员可以对“待审核”的拍品点击“通过”或“驳回”。通过后拍品自动关联到正在进行的场次,等待定时任务或管理员手动将其置为“竞拍中”。
订单管理页面展示所有成交订单,包括订单编号、拍品名称、买卖双方、成交金额、订单状态。管理员可以执行“发货”操作,订单状态从“已支付”变为“已发货”。
上传图片这个功能建议使用Element UI的el-upload组件,上传接口接收文件后保存到服务器本地目录,数据库里只存URL。服务端要做文件大小和格式校验,防止用户传超大文件或恶意脚本。跨域配置要放开这个上传接口。
4.4 前后端联调与接口约定
前后端分离项目最容易在联调阶段出问题,提前约定好规范能省很多时间。我的习惯是统一返回结构:
{ "code": 200, "message": "success", "data": {} }错误时返回非200的code,前端拦截器统一弹出message。所有分页接口入参统一用pageNum和pageSize,返回结构统一是{ records, total },这样前端写表格组件时可以封装一套通用逻辑。
跨域问题的处理,我推荐在后端加全局CORS配置,简单又不容易漏:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }有一个坑必须提醒:如果后端返回的实体类主键用的是Long,而数据库主键用了雪花ID或类似超过2^53的值,前端JavaScript解析会丢失精度。你可能会看到两条记录的ID一模一样,排查半天才发现是精度问题。解决办法是给主键字段加上@JsonSerialize(using = ToStringSerializer.class),让后端返回字符串类型:
public class Goods { @JsonSerialize(using = ToStringSerializer.class) private Long id; }这个坑几乎每个做全栈项目的人都会遇到,提前处理好能省一晚上的排查时间。
5. 部署上线与常见问题排查
5.1 本地运行环境配置
项目想在自己电脑上跑起来,环境至少要满足:
- JDK 8或JDK 11(这个项目的SpringBoot版本建议用2.7.x,不要盲目追3.x)
- Maven 3.6以上
- MySQL 5.7或8.0
- Node.js 14以上,npm 6以上
启动步骤其实很固定:
- 创建数据库:
CREATE DATABASE auction_db DEFAULT CHARACTER SET utf8mb4; - 导入
init.sql脚本,生成所有表结构 - 修改后端
application.yml中的数据库用户密码 - 启动后端:
mvn spring-boot:run,确认8080端口启动成功 - 进入前端目录:
npm install,然后npm run serve - 浏览器访问
http://localhost:8081(Vue默认端口5173或8080,冲突就改一个)
这里特别提两句“SpringBoot版本太高”的问题。很多人喜欢下载最新版SpringBoot,比如3.x,但3.x最低要求JDK17,而且很多老教程里的starter坐标变了。如果你用的还是JDK8,老老实实用SpringBoot 2.7.18,它是2.x的最后一个支持版本,资料最多、最稳定。
5.2 部署方案与性能优化
到了展示阶段,可以把项目部署到服务器上。前端打包后是一个dist目录,里面全是静态文件;后端打包成可执行JAR包。用Nginx托管前端静态文件,同时把/api请求反向代理到后端服务,这是目前最主流的前后端分离部署方式:
server { listen 80; server_name auction.example.com; root /opt/auction/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 前端history路由模式必须加这个,刷新页面才不会404 location / { try_files $uri $uri/ /index.html; } }后端打包部署命令:
mvn clean package -DskipTests java -jar auction-server.jar --spring.profiles.active=prodvue 打包后布局异常这个问题我见过很多次,通常是因为前端资源用了绝对路径/js/app.js,部署到子目录后全部404,导致页面样式全乱。解决办法是修改vue.config.js。
module.exports = { publicPath: './' }让打包后的资源都变成相对路径,这样部署到任何子目录都能正常加载。
性能优化方面,毕设项目不需要做到极致,但要知道方向。第一层是加Redis缓存热门拍品信息,减少数据库压力;第二层是给常用查询加索引如出价记录表的(goods_id, price)联合索引;第三层是把定时任务、订单生成这些逻辑尽量异步化。真把这些做好,你的项目已经远超一般毕设水平。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 后端启动报“The server time zone value” | MySQL连接字符串缺少时区参数 | URL末尾加serverTimezone=Asia/Shanghai |
| 前端请求接口提示跨域 | 后端未配置CORS或配置覆盖不全 | 添加全局CORS配置类 |
| 出价后当前价格不更新 | 事务未提交或用了乐观锁但没处理失败 | 检查事务注解;乐观锁失败时给用户明确提示 |
| 图片上传后访问404 | 静态资源映射路径未配置 | 配置WebMvcConfigurer的addResourceHandlers |
| 打包部署后页面空白或样式丢失 | 前端资源路径是绝对路径 | vue.config.js里设置publicPath: './' |
| Long类型ID前端显示精度错误 | JavaScript超出2^53精度 | 给Long字段加@JsonSerialize(using = ToStringSerializer.class) |
| 定时任务到点没执行 | 主类缺少@EnableScheduling注解 | 在启动类上添加该注解 |
| 页面刷新404(History模式) | Nginx未配置try_files | 添加try_files $uri $uri/ /index.html; |
| npm install安装报错 | npm版本或依赖树冲突 | 切换淘宝镜像源,或使用--legacy-peer-deps |
6. 进阶扩展建议与个人心得
项目跑通后不要急着写在简历上了事,有几个方向可以继续深挖,既能加深理解,又能变成答辩时的加分项:
方向一:接入WebSocket实时推送
现在的轮询方案每3秒请求一次,体验一般。把它升级成SpringBoot的WebSocket后,后端在有新出价时主动推送最新价格给所有在线客户端,既能省流量,又能做到“秒级”更新,还能引出高并发下WebSocket集群如何共享Session的话题。
方向二:引入Redis缓存和分布式锁
把拍品详情、出价记录这些读多写少的数据放到Redis里,热点数据查询压力立刻降下来。出价接口可以用Redis的分布式锁替换数据库行锁,你会更有体感地理解“锁为什么不能乱用”,这也是面试里被问到的分布式场景。
方向三:对接第三方支付接口
订单生成后停留在“待支付”状态,如果能在沙箱环境接入一个支付流程,整个业务闭环就更完整了。
做这个项目时我自己最大的感受是:越早把“用户角色”想清楚,后面写代码越顺手。第二点心得是,状态字段一定要一开始就用枚举,别图省事写数字。写数字一时爽,等加了定时任务、状态流转、前端标签颜色映射,你会发现到处是“3代表什么来着”的困惑。第三,也是最重要的一点——这个项目真正让你值回票价的地方并不是“会写登录、会写列表”,而是你亲手处理过并发出价、状态流转、延时截拍、精度丢失这些真实问题。带着这些问题去答辩,哪怕老师问得再深,你也能从容接住。