☰
SpringBoot+Vue校园在线拍卖系统设计:并发出价与实时竞价实现
2026/10/10 12:09:48 网站建设 项目流程

毕业季一到,校园里二手交易的需求就爆了——有人在群里刷屏卖教材,有人摆摊卖台灯,还有人想把用过的相机挂在网上拍个好价钱。我去年帮学校做了一套基于SpringBoot和Vue的校园在线拍卖系统,从需求分析一直跟到部署上线,今天把整个设计思路和实现过程完整写出来。这套系统无论你是拿来做毕业设计,还是真的想给学院做一个内部拍卖平台,都可以直接参照。我会把技术选型怎么定、并发出价怎么防超拍、拍卖结束怎么准时触发、前端实时竞价怎么做、视频验货怎么播放这些核心问题全部展开讲,同时记录一些真实踩过的坑,方便你少走弯路。

1. 需求梳理:校园拍卖不是普通电商的缩小版

1.1 校园场景的三个特殊性

做校园拍卖系统,最容易犯的错就是直接拿阿里拍卖或闲鱼的模式来套。校园环境和公网C2C交易有很大区别,这是需求层面首先要想清楚的。

第一个是身份可信度高。校园拍卖的参与主体是本校学生和教职工,天然有实名制基础(学号、工号、校园卡)。这意味着系统不需要做复杂的芝麻信用、保证金冻结、身份视频认证——只需要对接学校的统一身份认证(CAS 或 OAUTH)就能解决信任问题。如果直接做一个公网系统,实名认证和账号安全的设计复杂度会高一个量级。

第二个是交易额度小、频率高。一本书、一个台灯、一个键盘,起拍价可能就10块钱。这种情况下,你不应该引入保证金、拍卖佣金、手续费之类的商业逻辑,否则用户参与门槛太高。我在第一版设计里加了一个“信用积分”功能,结果被学生吐槽多此一举,后来砍掉了。

第三个是时间窗口集中。真正的拍卖高峰是开学季、毕业季和期末前两周,日常使用量并不高。这决定了你的设计和部署策略:系统要在高峰期扛得住并发出价,平时则不需要庞大的集群,成本控制和弹性扩展的取舍要提前定好。

1.2 功能边界:用户端、管理端、核心链路

需求梳理之后,我把功能分成三块。

用户端(Vue 前端):浏览在拍商品、查看拍卖详情、参与出价、收藏关注、个人中心(我的发布、我的出价、我的订单)。

管理端(Vue 前端):商品审核、拍卖场次管理、分类管理、用户禁用/解禁、成交订单管理、系统公告。

核心链路:用户发布商品(管理员审核)→ 进入指定拍卖场次 → 竞拍者出价 → 拍卖时间结束产生最高价 → 生成订单 → 买卖双方确认完成。

有一条原则贯穿设计始终:拍卖系统的核心链路是“发布 - 出价 - 成交”,其他功能都是围绕这条链路做增强。我在实际开发中见过不少同学把系统设计成“电商+拍卖”的杂交体,首页、搜索、购物车、优惠券全都要,结果核心流程没做好,凑出来的功能自己也讲不清楚。做毕设也好、做真实项目也好,先保证核心链路稳,再谈扩展。

1.3 明确不做什么,反而让系统更清晰

我砍掉了支付对接。学校内部的拍卖系统,线下当面交易是最自然的结算方式,引入支付宝/微信支付意味着营业执照、对公账户、平台手续费一系列问题,对校园项目来说性价比极低。系统只记录订单和双方联系方式,成交后线下见面付款,这既合规又符合校园实际。

物流功能也不要。对于校园拍卖,大部分商品是同校当面拿,快递是极端个案。加物流就意味着要对接物流公司的查询API,还要处理运费模板,复杂度上升一个量级。

这套“做减法”的思路,让我把精力集中到了拍卖系统最有趣也最复杂的技术点上——并发出价和定时关拍。这两个点做好了,系统就立住了。

2. 技术选型:SpringBoot 3 + Vue 3 组合的底气在哪里

2.1 为什么选了这对组合而非其他

技术选型这件事,很多人只看“流行度”,但更重要的理由是生态成熟度和团队可维护性。

后端选 SpringBoot,理由是它把 Java 开发的很大一部分样板代码都收拾干净了。Spring Boot 自动装配的核心机制就是通过spring.factories或者AutoConfiguration.imports文件,在应用启动时自动加载配置类。你不需要手动配置数据源、MyBatis 的 SqlSessionFactory、Redis 连接工厂,只需要在application.yml里写上连接参数,SpringBoot 的自动装配机制会基于类路径上的依赖自动创建这些 Bean。这对快速迭代一个系统来说,开发效率提升非常明显。

前端选 Vue,看中的是它的组件化开发和渐进式学习曲线。Vue 3 的组合式 API(Composition API)让逻辑复用变得很干净,相比 Options API,组织出价、倒计时、WebSocket 连接这些相关逻辑时不会散落在各处。

另外这对组合还有一个非常大的现实优势——找人接手维护容易。高校里学 SpringBoot 和 Vue 的人太多了,毕设答辩完、或者项目交接给下一届学弟学妹,不需要从零做技术培训,这是学校场景下很实际的一个考量。

2.2 周边件清单:MySQL、Redis、MinIO、WebSocket

只靠 SpringBoot 和 Vue 做不出拍卖系统,需要周边件协同。

  • MySQL 8.x:系统主数据库,存储用户、商品、出价记录、订单等核心结构化数据。
  • Redis 7.x:三个用途,缓存热点商品数据、存储竞拍中的最高价信息、实现拍卖结束的延迟触发。
  • MinIO:对象存储服务,用来存商品图片和视频。为什么不用服务器本地磁盘?因为本地磁盘在项目拆成多实例部署时会遇到文件不一致问题,对象存储天生就是干这个的。
  • WebSocket:实现实时竞价推送——自己出价被超过、有新出价时,前端页面实时刷新状态,不给用户“卡了”的错觉。

部署方式是后端和前端分开部署:后端跑在 8080 端口,前端构建后由 Nginx 托管静态文件,同时 Nginx 反向代理/api请求到后端服务。开发环境则用 Vue CLI/Vite 的代理来解决跨域。

2.3 工程结构:后端分层 + 前端目录组织

后端采用经典的分层架构,但我在组织包结构时没有完全按controller/service/mapper这种纯技术分层,而是加入了一层action包——这是我在一个老项目里学到的经验,复杂业务(比如发布商品后同时创建拍卖场次、生成初始出价记录)如果散落在 service 里会很难跟踪。action包专门用来编排一次完整业务动作,调用多个 service,类似 DDD 里的应用服务层。

com.campus.auction ├── action # 业务动作编排(发布商品、提交出价、结束拍卖) ├── controller # 接收前端请求 ├── service # 核心业务逻辑 ├── mapper # MyBatis 数据库访问 ├── entity # 数据库实体 ├── dto # 接口传输对象 ├── config # 自动装配相关配置类 ├── util # 工具类 └── websocket # WebSocket 相关

前端目录结构采用了 Vue 3 工程化的标准组织:

src ├── api # 与后端接口对应的请求封装 ├── assets # 静态资源 ├── components # 通用组件(商品卡片、倒计时组件、图片轮播等) ├── router # 路由配置 ├── stores # Pinia 状态管理 ├── views # 页面视图 ├── composables # 组合式函数(WebSocket 连接、倒计时逻辑等) └── utils # 工具函数

3. 后端核心实现:并发出价与定时结束是最硬的两块骨头

3.1 出价接口的并发控制:从数据库锁到 Redis Lua 脚本

出价接口的并发控制是拍卖系统的灵魂。多个用户在同一秒对同一商品出价,系统必须保证最后一个有效出价是“当前最高价 + 最小加价幅度”,多一个人都不行。

我第一版实现用的是数据库行锁:

@Transactional public BidResult submitBid(Long itemId, Long userId, BigDecimal price) { // 对商品行加锁,防止并发修改 AuctionItem item = auctionItemMapper.selectByIdForUpdate(itemId); // 校验出价是否有效、拍卖是否结束... // 插入出价记录、更新商品当前最高价 }

selectByIdForUpdate在 MySQL 里是SELECT ... FOR UPDATE,会把这一行锁住直到事务结束。这种实现简单直观,但存在两个问题:一是数据库连接在高并发下被长时间占用,事务里还有插入操作和更新操作,持锁时间越长,锁等待越严重;二是当并发量大时,后续请求大量阻塞在行锁等待上,数据库连接池很容易被打满。

更优的方案是用 Redis 做出价前置校验,再加一个 Lua 脚本保证原子性。核心思路是:每件拍品的实时价格状态维护在 Redis 里,出价请求先打到 Redis,用 Lua 脚本原子地完成“校验当前价格、校验加价幅度、更新最高价、记录出价人”这一系列操作。成功后异步落库,并通知 WebSocket 给所有在线的竞拍者推送最新价格。

-- KEYS[1]: auction:item:{itemId}:price(当前最高价字符串) -- KEYS[2]: auction:item:{itemId}:winner(当前最高出价人) -- ARGV[1]: 用户出的价格 -- ARGV[2]: 用户ID -- ARGV[3]: 最小加价幅度 local currentPrice = tonumber(redis.call('GET', KEYS[1]) or '0') local newPrice = tonumber(ARGV[1]) local minStep = tonumber(ARGV[3]) if newPrice < (currentPrice + minStep) then return -1 -- 出价无效 end redis.call('SET', KEYS[1], newPrice) redis.call('SET', KEYS[2], ARGV[2]) return 1 -- 更新成功

Redis 执行 Lua 脚本是原子的,中间不会被其他命令插入,这就避免了“读取价格→计算新价→写回价格”三步之间被人插一脚的问题。落库这一步放在异步线程池里处理,即使数据库偶发抖动,Redis 里的价格状态也是准的,不影响用户看到的最新价。

这套方案上线后实测,单商品峰值出价 QPS(每秒请求数)能到 1000 以上,这在校园场景里是绰绰有余了。

3.2 拍卖定时关闭:不要用数据库轮询

拍卖系统有一个硬性需求:拍卖必须在规定时间精确结束,不能早一秒也不能晚一秒。

最简单的做法是每隔几秒扫描一次数据库,把截止时间小于当前时间的拍卖改成已结束状态。但我第一版就是这么做的,结果遇到了两个问题:一是扫描时间差导致结束时间有误差(扫描间隔 5 秒,表现就是商品已经到点了还在接受出价);二是随着拍品数量增加,全表扫描对数据库压力不小。

真正的做法是把“结束事件”也交给 Redis。利用 Redis 的过期键通知机制(Keyspace Notifications)——拍品在 Redis 里存一个带过期时间的 key,过期后 Redis 会发出一个事件,后端监听这个事件执行结束流程。

@Component public class AuctionExpireListener { private final String TOPIC = "__keyevent@*__:expired"; @Bean public MessageListenerAdapter auctionExpireAdapter() { return new MessageListenerAdapter(this, "handleExpiredMessage"); } public void handleExpiredMessage(String message, byte[] pattern) { if (!message.startsWith("auction:item:")) { return; } String itemId = message.replace("auction:item:", ""); // 异步执行拍卖结束逻辑:生成订单、通知买卖双方、更新状态 auctionCloseService.closeAuction(Long.valueOf(itemId)); } }

注意要开启 Redis 的 notify-keyspace-events 配置,否则收不到过期事件:

redis-cli config set notify-keyspace-events Ex

这套方案在实测中关闭时延基本都是毫秒级,比数据库轮询准确得多。如果前端对结束时间要求更精确,还可以结合程序内置的定时器做兜底——拍卖结束的前一分钟启动一个 JDK 定时任务,到期时主动触发结束,双保险。

3.3 最终一致性:Redis 状态如何平滑落到 MySQL

引入了 Redis 之后,你必须要面对一个问题:如果 Redis 和 MySQL 状态不一致怎么办?

在出价场景下,我的策略是“Redis 为准,MySQL 最终一致”。用户看到的价格是 Redis 里的实时价格。每次落库时有幂等控制——出价记录表加上(item_id, bid_price, user_id)的唯一索引,重复插入会被数据库挡掉。异步落库失败的订单会进入重试队列,由后台任务周期扫描补偿。

这里有一个值得注意的细节:如果系统在拍卖结束的那一刻宕机了,Redis 里的价格和 MySQL 里记录的价格可能不一致。因此结束流程中做了一个“对账”逻辑——结束流程启动时先读 Redis 的最终价格,如果 Redis 里已经没有这个 key 了,就以 MySQL 出价记录里最新的那条为准。两边都查不到的就抛异常进入人工处理,保证了极端情况下的可追踪性。

3.4 权限与安全设计:JWT + 防刷

校园系统的权限没那么复杂,但身份校验是必须的。我用 JWT 做无状态认证——登录成功后在 Token 里写入用户 ID、学号(工号)、角色(普通用户/管理员),前端请求带上Authorization: Bearer <token>,后端通过 Spring Boot 的拦截器或 Spring Security 过滤器解析 Token。

出价接口的防刷也很关键。竞拍场景天然有“冲动出价”和“恶意抬价”的可能性,我的防护策略有三层:

  • 登录用户的出价频率限制,同一个用户 3 秒内不能对同一商品连续出价两次;
  • 对可疑的自动出价行为做校验——比如两次出价的间隔时间规律性太强,会被限流;
  • 同一 IP 下的多个账号共享频率限制,防止有人注册多个小号来抬价。

管理员接口单独校验权限,非管理员的 Token 无法访问管理端接口,即便是手动拼接 URL 也不行。

4. 前端 Vue3 实战:路由、状态管理与组件复用

4.1 路由设计:静态路由 + 角色感知的动态路由

前端路由我这里做的是“静态为主,动态做权限收敛”的方案。

核心路由是公开的:首页拍品列表、拍卖详情页、登录注册页。需要登录的路由挂到/user下面,包括我的发布、我的出价、个人设置。管理端路由单独挂在/admin下面。

“动态路由”这块我用到的场景是:普通用户登录后不加载管理端的路由,管理员登录后才动态添加管理端路由,避免把管理功能暴露在前端路由表里。实现方式是登录后拿用户角色,在前端路由守卫里判断:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const userStore = useUserStore() if (to.path.startsWith('/admin')) { if (!token) { next('/login') } else if (userStore.role !== 'ADMIN') { next('/403') } else { next() } } else { next() } })

这里很多人会纠结“前端判断角色不也能绕过吗?”。其实前端做路由守卫只是交互层保护,真正的权限依然要靠后端接口校验——点赞这个思路的严谨性,但不能把安全寄托在前端。

4.2 状态管理:Pinia 组织全局数据和竞拍态

Vue 3 生态里我用 Pinia 管理全局状态,三个核心 store:

  • userStore:用户信息、Token、登录态、角色,登录登出方法。
  • auctionStore:当前正在参与拍卖的商品列表、我的出价记录、实时最高价。
  • noticeStore:WebSocket 推来的系统通知,如“你的出价已被超过”“拍卖即将结束”。

Pinia 相比 Vuex 最大好处是它天然支持组合式 API 写法,store 内部可以直接使用ref和computed,写起来更加贴近直觉。

4.3 用一个组件撑起三个场景:商品卡片 + 插槽设计

Vue 的插槽(slot)是复用组件的高阶手段。拍品卡片(ItemCard)是我组件复用的一个典型例子——同一个卡片组件要出现在首页拍品列表、我的出价列表、管理端待审核列表三个完全不同的交互场景里。

首页拍品列表:展示图片、当前价、倒计时、出价按钮。 我的出价列表:展示我的出价记录、当前状态标签,以及“出价被超过”的红色提示。 管理端列表:展示商品图片、发布人信息、审核按钮。

这三个场景共用同一套布局框架、图片展示和价格信息,但操作按钮和额外信息不同。我用插槽把动态部分留给调用方:

<template> <div class="item-card"> <img :src="item.coverUrl" /> <div class="info"> <h3>{{ item.title }}</h3> <div class="price">当前价:{{ item.nowPrice }}</div> <div class="countdown"> <CountDown :endTime="item.endTime" /> </div> </div> <div class="actions"> <slot name="actions"></slot> </div> </div> </template>

三个场景分别往actions插槽里传入不同的按钮,一个组件撑起了三种页面。这套设计让我在后期加需求时非常省事,比如后来要加“收藏”功能,只需要在首页场景里多传一个收藏按钮进插槽就行。

4.4 实时竞价:WebSocket 推送 + 断线重连

拍卖场景的体验核心是实时性。用户出价后,所有在线看这件商品的人应该立刻看到价格变化,而不是等手动刷新。

我选择用原生 WebSocket(SpringBoot 端的spring-boot-starter-websocket)而不是 SSE,因为 WebSocket 是双向通道,除了服务端推送“有人出价”之外,前端也可以发“订阅某件商品”的消息。

前端我把 WebSocket 连接逻辑封装成一个 composable,避免在每个页面重复写连接、断线、重连的代码:

// useAuctionSocket.js export function useAuctionSocket(itemId) { const connected = ref(false) const latestPrice = ref(0) function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' const ws = new WebSocket(`${protocol}://${location.host}/ws/auction/${itemId}`) ws.onopen = () => { connected.value = true } ws.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'PRICE_UPDATE') { latestPrice.value = data.price } } ws.onclose = () => { connected.value = false // 3秒后自动重连 setTimeout(connect, 3000) } } onMounted(connect) onUnmounted(() => ws.close()) return { connected, latestPrice } }

断线重连的细节特别重要。WebSocket 断开的原因很多——网络切换、服务器重启、Nginx 空闲超时。不重连的话,用户看到的页面数据就是过期的,他可能以为自己还在最高价,结果早就被别人超了。我这里是 3 秒重连一次,重连成功后前端会主动向服务端发送“同步最新价格”的请求,把断线期间漏掉的价格变化一次性补齐。

5. 文件存储与视频展示:MinIO + m3u8 播放方案

5.1 校园拍卖为什么需要视频验货

很多校园拍卖系统只做商品图片,但我强烈建议加上短视频。原因不复杂:二手物品的真实状态很难用静态图片体现——一个相机成色如何、一台电脑开机速度怎么样、一个键盘的按键手感如何,短视频的信息量远大于图片。

我在系统里规定发布拍品必须上传图片,视频是选填。但做了之后我发现,上传了视频的拍品,竞拍参与度明显更高。后来我甚至考虑过强制视频,但考虑到部分学生可能手头没有拍摄条件,最终保留为选填。

这一块涉及两个技术点:一是文件如何上传和存储,二是视频如何在线播放。

5.2 MinIO 接入 SpringBoot 的要点

选 MinIO 而不是直接用阿里云 OSS,原因很简单——内网环境可用、部署免费、兼容 AWS S3 API。校园项目往往没有申请外部云服务的预算,MinIO 装在服务器上就完事了。

MinIO 接入 SpringBoot 的核心依赖是minio-java,然后创建一个配置类:

@Configuration public class MinioConfig { @Value("${minio.endpoint}") private String endpoint; @Value("${minio.accessKey}") private String accessKey; @Value("${minio.secretKey}") private String secretKey; @Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }

文件上传接口的骨架逻辑:

@PostMapping("/upload") public ApiResult<String> upload(@RequestParam("file") MultipartFile file) { String objectName = UUID.randomUUID().toString() + "." + FilenameUtils.getExtension(file.getOriginalFilename()); minioClient.putObject(PutObjectArgs.builder() .bucket("auction-bucket") .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return ApiResult.success(minioClient.getPresignedObjectUrl(...)); }

有两个配置细节容易踩坑。第一,Bucket 的访问权限要设置为 public,否则生成的预签名 URL 会过期,前端图片、视频就加载不出来了。第二,上传时要把Content-Type显式传进去,不传的话 MinIO 默认按application/octet-stream处理,浏览器可能直接下载而不是播放视频。

5.3 m3u8 切片处理与 Vue 播放

视频播放这块,最初的方案是直接传一个 MP4 文件,前端<video>标签直接播放。但问题很明显:MP4 文件大,校园网络环境下加载慢、拖动进度条卡顿,流量消耗也高。

后来改成了 m3u8 切片方案。m3u8(HTTP Live Streaming)是苹果推的一套流媒体协议,核心思路是把一个完整视频切成若干小分片(通常是 10 秒一个 .ts 文件),播放器通过 m3u8 索引文件按需拉取分片实现“边下边播”,拖动进度条时也不需要从头加载整个文件。

切片操作我用的是 FFmpeg 命令行工具:

ffmpeg -i input.mp4 -codec copy -start_number 0 -hls_time 10 -hls_list_size 0 -f hls output.m3u8

这条命令的含义是:把input.mp4切成每个 10 秒的 ts 分片,生成output.m3u8索引文件,-codec copy表示不重新编码(速度快),前提是原视频的编码格式是 H.264/AAC,否则需要去掉这个参数让 FFmpeg 转码。

Vue 端播放 m3u8,最省事的方案是接入hls.js。这个库让不支持原生 HLS 的浏览器(尤其是 Chrome)也能播放 m3u8:

<template> <video ref="videoEl" controls></video> </template> <script setup> import Hls from 'hls.js' const props = defineProps({ src: { type: String, required: true } }) const videoEl = ref(null) onMounted(() => { if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(props.src) hls.attachMedia(videoEl.value) } else if (videoEl.value.canPlayType('application/vnd.apple.mpegurl')) { // Safari 原生支持 m3u8 videoEl.value.src = props.src } }) </script>

这里我踩过的一个坑是:MinIO 的桶策略里如果不设置正确的 Content-Type,浏览器会把.m3u8当成未知类型下载,而不是交给播放器。确保上传时对 .m3u8 文件设置application/vnd.apple.mpegurl,.ts 文件设置video/mp2t,才能正常在线播放。

5.4 大文件上传:分片与秒传的思路

校园用户手机拍出来的视频,现在动不动就一两百 MB,直接走普通上传不可靠。我做了分片上传——前端把文件切成 5MB 一片,每片独立上传,全部上传完成后后端合并。同时基于文件的 MD5 做秒传,同一份文件重复上传时直接返回已有地址,节省服务器带宽。

分片上传的后端接口设计大概是这样:

// 1. 创建分片上传任务,返回 uploadId @PostMapping("/video/create") public CreateUploadVO createUpload(@RequestBody CreateUploadParam param) // 2. 上传单个分片 @PostMapping("/video/upload-part") public UploadPartVO uploadPart(@RequestParam("uploadId") String uploadId, @RequestParam("index") Integer index, @RequestParam("file") MultipartFile file) // 3. 合并分片 @PostMapping("/video/complete") public ApiResult<String> completeUpload(@RequestBody CompleteUploadParam param)

前端用文件对象的slice方法切分,然后按顺序上传并记录每个分片的结果,支持失败重传。这套方案让 200MB 的视频也能在校内网络稳定传完。

6. 部署与一体化打包:Vue 打包进 SpringBoot 的实战记录

6.1 为什么要做一体化打包

项目部署我做了两套方案。一套是前后端分离部署,前端挂 Nginx、后端跑 Tomcat;另一套是把 Vue 构建产物直接打进 SpringBoot 的static目录,让 SpringBoot 既提供 API 服务又托管前端页面。

一体化打包方案在一个场景下特别有用——毕设答辩、课程验收、学校机房的离线演示环境。机房电脑可能没有联网、没有 Nginx,但你只需要一个java -jar auction-system.jar就能把整个系统跑起来,这对非技术背景的验收老师来说非常友好。

6.2 打包细节:publicPath 和 history 路由模式

Vue 项目默认的构建产物中,静态资源路径是相对路径,这在大多数情况下没问题。但如果你把 Vue 打包到 SpringBoot 里,需要关注两个配置。

第一是publicPath。因为应用是从域名:8080访问,没有额外的子路径,所以publicPath可以设为'./'或者'/'。但如果你的项目将来会部署到 Tomcat 的某个 context path 下(比如http://server:8080/auction/),那publicPath就要设为/auction/。

第二是路由模式。Vue 的 hash 模式(URL 里有#)在打进 SpringBoot 后完全不用额外配置,但看着不美观。如果要用 history 模式(干净 URL),必须处理刷新 404 的问题——因为刷新时浏览器会向服务器请求当前的路径,比如/detail/3,SpringBoot 找不到对应的 controller 就会返回 404。

解决办法是在后端加一个转发配置,把所有非/api的前端路由请求都转发回index.html:

@Controller public class ForwardController { @RequestMapping(value = {"/", "/detail/**", "/user/**", "/admin/**", "/403"}) public String forward() { return "forward:/index.html"; } }

注意不要覆盖/api下的接口请求。这层转发的规则是:带/api前缀的走后端接口,其他合法路由交给前端路由接管。

6.3 部署后的两个隐藏问题:缓存与长连接

一体化打包之后我发现两个容易忽略的点。

一是静态资源缓存。Vue 打包出来的 JS/CSS 文件名里带着 hash 指纹(如app.abc123.js),理想情况下文件名变了浏览器就会请求新版本。但如果 Nginx 或浏览器对index.html做了强缓存,用户就会一直看到旧页面。我当时的处理方式是:index.html的 Cache-Control 设为no-cache,JS/CSS 走max-age=31536000,这样发布新版本时入口文件重新请求,拿到新的带 hash 的资源。

二是 WebSocket 在 Nginx 下的配置。如果前端页面最终还是走 Nginx 托管,那 WebSocket 连接也需要在 Nginx 里做升级代理。配置文件里必须加这一段:

location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }

不加这个配置,WebSocket 握手会失败,浏览器端表现为连接一直进不了onopen。这个坑我在测试环境排查了很久,后来抓包才定位到是 Nginx 没有转发 Upgrade 头。另外proxy_read_timeout要设长一点,否则空闲一段时间后 Nginx 会主动断开 WebSocket 连接,你的重连逻辑就会频繁触发。

7. 真实排错记录:三个让我熬夜的问题

7.1 并发出价导致价格覆盖

第一版上线测试,用压测工具模拟 50 个并发用户对同一件商品出价,出现了严重的价格覆盖问题——事后看数据库,最高价记录里混进了明显低于最后成交价的记录,甚至出现了出价人 A 的 100 元覆盖了出价人 B 的 120 元的情况。

定位过程分了三步走。

第一步,看日志。发现大量请求几乎同时进入出价 service,每个请求都先查询当前价、再判断是否大于当前价、然后插入新出价。问题的根源就是经典的“读-改-写”竞态——多个线程读到的当前价是同一个值,都认为自己出价有效,然后依次写入数据库,后面的覆盖前面的。

第二步,尝试在 service 方法上加了@Transactional和行锁。压测结果比之前好一些,但并发一高,数据库行锁等待导致大量超时,Tomcat 连接池被占满。

第三步,最终切换到 Redis + Lua 脚本方案。Lua 脚本把“读当前价、比较新价、更新价格”作为一个原子操作,Redis 单线程执行的特性保证了不会出现并发穿插。压测结果正常,价格不再被覆盖。这就是我在 3.1 节写的方案的最终来源。

7.2 拍卖结束后用户仍能出价

线上测试时发现一个诡异的问题:拍卖页面显示还剩 30 秒,用户在最后一秒点“出价”,提示成功;但到了第 31 秒,拍卖已经结束了,数据库里却多了一条超出结束时间的出价记录。

排查后定位到两个原因。

第一个原因是前端倒计时和服务端时间不一致。用户本地的时钟比服务器晚了几秒,倒计时显示剩 30 秒,其实服务器端已经剩 25 秒了。更严重的是,如果用户本地时钟慢了 10 秒,他在倒计时还剩下 5 秒的时候点出价,服务端实际上已经结束,但前端还在出价倒计时界面,点出去就会打到已结束的拍卖上。

修复方式:前端竞拍页面上禁止依赖本地时间,倒计时统一由服务端返回的截止时间戳换算,并且以服务端时间为唯一标准。出价时后端再次校验当前时间是否在拍卖时间窗口内,过期直接拒绝。

第二个原因是出价校验用的事务隔离级别。在并发出价方案里,出价逻辑是“Redis 先校验 + 异步落库”,异步落库时没有再次判断拍卖是否结束,导致最后时刻的出价订单在关拍后落进数据库。修复方式是异步落库前同步查一次拍卖状态,增加兜底校验。

7.3 前端 history 路由刷新后 404

一体化打包部署到服务器,用户从首页点进拍卖详情正常,但在这个详情页按 F5 刷新,直接白屏 404。

原因是 history 模式路由刷新时,前端请求的是/detail/123这个地址,而后端没有对应的 GET 请求映射,SpringBoot 抛 404。

我当时的排查思路很直接:先确认是不是静态资源加载问题,打开浏览器控制台看到是文档请求本身返回 404,排除资源路径问题。然后在后端加了一个通用转发,把非/api、非静态资源的请求全转回index.html。修复后刷新恢复正常。

需要注意,这个转发只适用于前端路由的 path 集,如果以后加了新的前端页面路由,要留意是不是落在转发范围内,漏配的话又会碰到刷新 404。


做这类系统,我个人的体会是:刚上手时容易把注意力放在“把页面做漂亮”“把功能堆齐全”上,但真正有技术含金量、也最值得花时间的是核心链路上的那几个点。对拍卖系统来说,就是并发出价、定时关拍、实时竞价和文件处理。把这四个点吃透了,无论技术答辩还是实际交给学校用,都拿得出手。如果你也打算做类似的校园拍卖项目,建议先搭一个最小可行版本,把发布、出价、成交这条链路跑通,再回头补漂亮页面和管理后台——这个顺序能帮你省下大量返工的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询