
毕业季一到校园里二手交易的需求就爆了——有人在群里刷屏卖教材有人摆摊卖台灯还有人想把用过的相机挂在网上拍个好价钱。我去年帮学校做了一套基于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 的组合式 APIComposition 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 管理全局状态三个核心 storeuserStore用户信息、Token、登录态、角色登录登出方法。auctionStore当前正在参与拍卖的商品列表、我的出价记录、实时最高价。noticeStoreWebSocket 推来的系统通知如“你的出价已被超过”“拍卖即将结束”。Pinia 相比 Vuex 最大好处是它天然支持组合式 API 写法store 内部可以直接使用ref和computed写起来更加贴近直觉。4.3 用一个组件撑起三个场景商品卡片 插槽设计Vue 的插槽slot是复用组件的高阶手段。拍品卡片ItemCard是我组件复用的一个典型例子——同一个卡片组件要出现在首页拍品列表、我的出价列表、管理端待审核列表三个完全不同的交互场景里。首页拍品列表展示图片、当前价、倒计时、出价按钮。 我的出价列表展示我的出价记录、当前状态标签以及“出价被超过”的红色提示。 管理端列表展示商品图片、发布人信息、审核按钮。这三个场景共用同一套布局框架、图片展示和价格信息但操作按钮和额外信息不同。我用插槽把动态部分留给调用方template div classitem-card img :srcitem.coverUrl / div classinfo h3{{ item.title }}/h3 div classprice当前价{{ item.nowPrice }}/div div classcountdown CountDown :endTimeitem.endTime / /div /div div classactions slot nameactions/slot /div /div /template三个场景分别往actions插槽里传入不同的按钮一个组件撑起了三种页面。这套设计让我在后期加需求时非常省事比如后来要加“收藏”功能只需要在首页场景里多传一个收藏按钮进插槽就行。4.4 实时竞价WebSocket 推送 断线重连拍卖场景的体验核心是实时性。用户出价后所有在线看这件商品的人应该立刻看到价格变化而不是等手动刷新。我选择用原生 WebSocketSpringBoot 端的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 ApiResultString 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 切片方案。m3u8HTTP 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也能播放 m3u8template video refvideoEl 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 ApiResultString 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/3SpringBoot 找不到对应的 controller 就会返回 404。解决办法是在后端加一个转发配置把所有非/api的前端路由请求都转发回index.htmlController 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-cacheJS/CSS 走max-age31536000这样发布新版本时入口文件重新请求拿到新的带 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。做这类系统我个人的体会是刚上手时容易把注意力放在“把页面做漂亮”“把功能堆齐全”上但真正有技术含金量、也最值得花时间的是核心链路上的那几个点。对拍卖系统来说就是并发出价、定时关拍、实时竞价和文件处理。把这四个点吃透了无论技术答辩还是实际交给学校用都拿得出手。如果你也打算做类似的校园拍卖项目建议先搭一个最小可行版本把发布、出价、成交这条链路跑通再回头补漂亮页面和管理后台——这个顺序能帮你省下大量返工的时间。