ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue预约管理系统实战:数据库设计与并发库存扣减

SpringBoot+Vue预约管理系统实战:数据库设计与并发库存扣减 简介SpringBootVue博物馆(预约)管理系统是一套面向计算机类专业毕业设计场景的Java Web前后端分离项目整体围绕博物馆参观预约业务展开覆盖用户端展品信息浏览、预约时段选择、预约记录提交以及管理端场馆与预约审核等流程。资源以zip压缩包形式发布包体大小约54.65MB文件总数与类型明细因上游未提供此处暂不罗列。目前已有30人学习下载适合准备毕业设计、需要参考SpringBoot与Vue整合实现的开发者也适合初步接触前后端分离架构的学习者。通过该资源读者可学习SpringBoot后端服务与Vue前端页面的接口联调方式理解预约类系统中数据建模、状态流转、权限控制等关键设计同时可借鉴项目中的目录结构与代码组织习惯将其用于快速搭建同类管理系统有效缩短从选题到实现的开发周期。这套系统可作为毕业设计或课程设计的主要参考也可作为学习前后端协作的实战示例。1. 一套 SpringBootVue 的博物馆预约管理系统先弄清楚它在解决什么博物馆预约管理系统核心是把「线下排队、电话登记」的到馆方式迁移成「线上选时段、按场次入馆」的预约制。通过 SpringBoot 提供后端接口与事务能力通过 Vue 搭建前端交互界面把展馆信息、场次库存、用户预约、后台管理完整地串成一条业务闭环。它解决的是分时段人流可控的问题——同时段能进多少人、哪个场次还有余票、预约记录怎么核销和追溯都有据可查。适合快速交付的毕设课题、中小型场馆的数字化改造以及需要一套可演示业务闭环的前后端分离脚手架。2. 先拆功能再定技术栈预约系统要解决的不只是「预约」两个字2.1 预约管理的四个功能域展馆、场次、预约单、用户权限头部系统容易犯的错是把「预约」当成一个单点功能设计。实际上预约只是中间动作它的前后分别是「展示什么可约」和「约完之后怎么办」。拆功能域时按一套最小可运行的闭环来拆展馆信息展示、场次排期管理、预约单流转、用户与权限。四个域之间是层层引用的关系不是平行菜单。展馆域管的是「可预约的对象」展馆/展区的名称、地址、开放时间、简介和上架状态。这个域本身没有复杂逻辑但它是后面所有时间计算的基准。场次域管的是「什么时间可以约」一次选定的展馆、一个日期、一段开始和结束时间以及该场次的总容量和剩余名额。预约单域管的是「约完之后的状态」预约单号、预约人、联系电话、所属场次、当前状态待参观、已完成、已取消。用户与权限域则是把「谁在约」和「谁能维护」分开普通用户只能看到前端页面和自己名下的记录管理员才能创建场次、核销预约。2.2 SpringBootVue 的选型理由与边界这套组合擅长什么选技术栈之前先明确一点这是一个典型的管理型系统业务是 CRUD 加状态流转不是高并发秒杀。SpringBoot 的价值在于让后端工程「零配置起步」——内嵌 Tomcat、自动化配置、Starter 生态解决了数据库连接、Web 框架、参数校验这些重复劳动搭配 MyBatis 系的数据访问层写预约这类 CRUD 接口的效率比纯 Java EE 高不少。Vue 这边响应式数据和单文件组件对「表单 → 列表 → 详情」这类页面非常友好配上 Element Plus 这类现成组件库后台管理界面能在很短时间内成型。项目结构上前后端彻底分离前端工程管路由和页面后端工程管接口和事务两边通过 JSON 通信这种形态也是现在团队协作最常见的分工方式。但组合的边界也要说清楚。这套技术栈不适合把预约做成「秒杀级」的流量场景——那种情况需要 MQ 削峰、分布式锁甚至独立的库存服务。博物馆预约的日活往往是几千到几万人一个数据库事务加条件更新足够稳定不必引入额外中间件。如果需要对接票务平台、第三方支付或人脸识别入场则要额外扩展接口层SpringBoot 的生态也支持但复杂度会明显上升。2.3 前后端分离骨架与核心名词模块、目录、接口约定落地时我会把工程拆成 backend 和 frontend 两个根目录保持两头独立。后端按 controller/service/mapper/entity 分层前端按 views/router/api/components 分目录。这个骨架看起来简单但它是隔离变更的关键controller 只做参数接收和结果包装service 层管业务和事务mapper 层只碰 SQL这样预约取消后要加「余量回补」逻辑时改动点集中在 service 层不会波及接口层。接口约定上所有业务接口统一挂/api前缀返回结构固定为{ code, message, data }。前端请求封装时把code的判断收敛到一处而不是每个页面各自处理。这样后端报错时前端统一弹提示不会出现「接口崩了页面还傻站着」的尴尬。3. 数据库设计与后端预约逻辑把预约做成一条状态机流水3.1 三张核心表怎么设计把时段库存「拆」到场次里预约系统的数据库设计关键在于库存放哪。常见做法是把「库存」挂在场次表上而不是挂在展馆表上。这样「周三上午 10:00 这个场次剩 5 个名额」是一个可以直接读取和更新的独立数据而不是在展馆列表里做复杂的子查询。CREATE TABLE museum_venue ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 展馆名称, address VARCHAR(255) COMMENT 展馆地址, open_time TIME NOT NULL COMMENT 开馆时间, close_time TIME NOT NULL COMMENT 闭馆时间, description TEXT COMMENT 展馆简介, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-开放 0-关闭, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT展馆表; CREATE TABLE museum_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, venue_id BIGINT NOT NULL COMMENT 展馆ID, session_date DATE NOT NULL COMMENT 场次日期, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, total_capacity INT NOT NULL COMMENT 总容量, available_capacity INT NOT NULL COMMENT 剩余容量, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-可预约 0-不可预约, UNIQUE KEY uk_venue_date_time (venue_id, session_date, start_time, end_time), KEY idx_date (session_date) ) COMMENT场次表; CREATE TABLE museum_booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, booking_no VARCHAR(32) NOT NULL COMMENT 预约单号, user_id BIGINT NOT NULL COMMENT 用户ID, session_id BIGINT NOT NULL COMMENT 场次ID, visitor_name VARCHAR(50) NOT NULL COMMENT 参观人姓名, visitor_phone VARCHAR(20) NOT NULL COMMENT 联系电话, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-待参观 2-已完成 3-已取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_booking_no (booking_no), UNIQUE KEY uk_user_session (user_id, session_id, status), KEY idx_session_status (session_id, status) ) COMMENT预约单表;三张表的职责分工很明确venue 表记录场馆主数据session 表把「某天某时段」拆成一条独立记录并持有库存booking 表则是一条流转状态流水。uk_venue_date_time这个唯一索引是防止管理员重复为同一场馆同一时段建场次uk_user_session用于兜底同一个用户对同一个场次的重复预约。3.2 预约扣减的并发控制条件更新 唯一索引的搭配在线预约最容易翻车的地方是两个人同时提交最后一个名额的请求。如果代码先查available_capacity看到是 1再执行更新两个请求都可能通过预检查最后把余量更新成 -1。解决思路是让数据库自己判断「余量还够不够」而不是在应用层先读再写。!-- SessionMapper.xml -- update iddecreaseAvailable UPDATE museum_session SET available_capacity available_capacity - 1 WHERE id #{sessionId} AND available_capacity 0 AND status 1 /update这段 SQL 是预约系统的核心防线。available_capacity 0直接把判断和扣减合并成一个原子操作数据库的行锁保证同一时刻只有一个请求能成功把余量从 1 扣到 0另一个请求的更新会命中 0 行。配合唯一的uk_user_session索引还能挡住同一用户在同一场次上重复创建预约记录。前端按钮做 loading 只是体验问题这两个约束才是业务正确性的保证。3.3 SpringBoot 预约下单与取消接口从 DTO 到事务边界后端预约接口的完整流程是校验场次、检查重复预约、调用条件扣减、插入预约单。这四步必须在一个事务里完成否则会出现「扣减成功但预约单没插进去」的数据错乱。Transactional(rollbackFor Exception.class) public BookingResult createBooking(BookingCreateReq req, Long userId) { // 1. 校验场次是否存在且处于可预约状态 SessionDO session sessionMapper.selectById(req.getSessionId()); if (session null || session.getStatus() ! 1) { throw new BusinessException(场次不存在或不可预约); } LocalDate sessionDate session.getSessionDate(); if (sessionDate.isBefore(LocalDate.now())) { throw new BusinessException(该场次已过期); } // 2. 业务层先检查用户是否已约过该场次 int exists bookingMapper.countActiveBooking(userId, req.getSessionId()); if (exists 0) { throw new BusinessException(您已预约过该场次请勿重复预约); } // 3. 条件扣减受影响行数为 0 说明名额已满 int rows sessionMapper.decreaseAvailable(req.getSessionId()); if (rows 0) { throw new BusinessException(该场次名额已满); } // 4. 插入预约单事务提交前所有操作一起生效 BookingDO booking new BookingDO(); booking.setBookingNo(BookingNoGenerator.next()); booking.setUserId(userId); booking.setSessionId(req.getSessionId()); booking.setVisitorName(req.getVisitorName()); booking.setVisitorPhone(req.getVisitorPhone()); booking.setStatus(1); bookingMapper.insert(booking); return BookingResult.ok(booking.getBookingNo()); }这段代码有两个关键参数需要留意。rollbackFor Exception.class是事务生效的正确写法只写Transactional默认只回滚 RuntimeException业务异常如果继承自 Exception 就会漏回滚。countActiveBooking配合唯一索引是双保险索引兜底并发下的极端情况业务判断则能给出「您已预约过」这种清晰的用户提示。扣减和插入的顺序也很关键——先扣减再插入插入失败时事务回滚余量自动恢复。取消预约则是反向操作要把状态翻转和余量回补放在同一个事务中。Transactional(rollbackFor Exception.class) public void cancelBooking(Long bookingId, Long userId) { BookingDO booking bookingMapper.selectById(bookingId); if (booking null) { throw new BusinessException(预约记录不存在); } if (!booking.getUserId().equals(userId)) { throw new BusinessException(无权操作他人预约); } if (booking.getStatus() 3) { throw new BusinessException(该预约已取消); } // 状态翻转 bookingMapper.updateStatus(bookingId, 3); // 余量回补 sessionMapper.increaseAvailable(booking.getSessionId()); }increaseAvailable的回补逻辑同样要带条件只对「待参观」状态的预约生效已完成或已过期的预约取消时不应该回补否则余量会被错误放大。这个判断可以放在 service 层先查状态再处理也可以把「当前状态1」写进 update 的条件里后者更稳妥。4. Vue 前端从页面到联调路由、请求封装与跨域配置4.1 Vue Router 与动态路由前台预约和后台管理的权限分流前端路由的设计要跟着页面角色走。普通用户访问的是展馆列表、场次详情、我的预约这几个页面管理员进的是场次管理、预约核销、展馆维护。这两种页面不应该混在一个平铺路由表里而是用嵌套路由把后台管理隔离到/admin下。// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(/views/HomeView.vue), meta: { title: 展馆列表 } }, { path: /sessions/:id, name: sessionDetail, component: () import(/views/SessionDetail.vue), meta: { title: 场次详情 } }, { path: /my, name: myBookings, component: () import(/views/MyBookings.vue), meta: { requiresAuth: true } }, { path: /login, name: login, component: () import(/views/LoginView.vue) }, { path: /admin, component: () import(/views/admin/AdminLayout.vue), meta: { requiresAuth: true, role: admin }, children: [ { path: , redirect: /admin/sessions }, { path: sessions, component: () import(/views/admin/SessionManage.vue) }, { path: bookings, component: () import(/views/admin/BookingManage.vue) } ] } ] const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) const requiresAuth to.matched.some(record record.meta.requiresAuth) if (requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } next() })这个方案用requiresAuth元信息配合全局守卫实现的是「登录才能进」而不是「角色控制」对小型系统已经够用。to.matched.some的作用是判断嵌套路由的父级是否有鉴权要求/admin下所有子路由都会被这条规则拦住。redirect参数是给用户登录后自动跳回原页面的体验上很重要。如果要做动态路由——登录后根据用户角色用router.addRoute追加后台路由思路一样只是把静态的/admin改成登录后再挂载适合角色种类更多的系统。4.2 axios 请求封装Token 注入与 401 统一处理前端和 SpringBoot 后端通信用的是 axios。如果不做封装每个页面都要写一遍「取 token、塞 header、处理错误」代码会散得没法维护。封装的核心就是把 Token 注入和 401 处理收敛到统一拦截器里。// src/api/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { localStorage.removeItem(token) window.location.href /login } else { ElMessage.error(error.response?.data?.message || 请求失败) } return Promise.reject(error) } )这里有两个设计点值得注意。baseURL写相对路径/api而不是http://localhost:8080是为了让前端代码在不同环境不用改——开发环境由 devServer 的代理转发到后端生产环境由同源部署或网关转发。响应拦截器里直接return response.data是因为后端统一返回结构是{ code, message, data }页面里直接拿data不用再层层解包。401 的处理全局统一跳登录并清掉本地 token避免每个页面重复写。export const createBooking (data) request.post(/booking, data) export const cancelBooking (id) request.delete(/booking/${id}) export const getSessions (params) request.get(/session/list, { params })接口定义单独放在src/api目录下每个函数对应后端一个接口。页面里调用时只关心业务返回不知道也不关心 axios 的细节后期后端接口路径变更时只改这一个文件。4.3 跨域与代理开发环境代理和 SpringBoot 的 CORS 配置前后端分离项目联调时绕不开跨域。前端跑在 8081后端跑在 8080浏览器会拦截不同源的 ajax 请求。最快的解法是让前端开发服务器把/api开头的请求转发到后端也就是代理模式。// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这段配置里changeOrigin: true是关键它让代理转发请求时把请求头里的 Host 改成目标地址后端 SpringBoot 才能正确识别。开发环境走代理后浏览器看到的请求是同源的跨域不再发生。注意这里不要加pathRewrite因为后端接口本身就带/api前缀加了对不上路径。生产环境如果前后端不在同一个域则需要在 SpringBoot 侧配置 CORS。常见做法是加一个配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(http://localhost:8081) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里最容易踩坑的是allowedOriginPatterns和allowCredentials(true)的组合。如果allowedOrigins配成*就不能开启allowCredentials因为携带凭证的跨域请求不允许使用通配符来源。OPTIONS方法一定要放行否则预检请求直接 403。生产环境更推荐的做法是 Nginx 层做转发让前后端对外处于同一个域这样 CORS 配置都能省掉。5. 博物馆预约系统避坑指南现象、原因与解决方案5.1 库存超卖两个用户同时约最后一个名额两个都成功现象并发测试时用两个用户抢同一个场次的最后一个名额两条预约单都创建成功库里available_capacity变成 -1。原因接口里先select余量判断大于 0 再update这个「查」和「改」之间存在时间窗口两个请求都能通过判断。解决把扣减改成条件更新 SQLWHERE available_capacity 0数据库行锁保证只有一个请求能扣成功。验证方法是用 JMeter 或 Postman 并发发 50 个请求抢 10 个名额断言成功数等于 10 且余量为 0。5.2 跨域玄学本地联调报错配置看着一点没错现象前端请求/api/session/list浏览器控制台报 CORS error但 SpringBoot 的 CORS 配置类写得没问题。原因往往是前端配置了Proxy但没生效——改了vue.config.js后 devServer 没有重启或者浏览器缓存了旧的预检结果。另一个典型原因是allowCredentials和allowedOrigins(*)冲突。解决改完配置文件务必重启前端 devServer确认浏览器 Network 里请求的是http://localhost:8081/api/...而不是直连 8080直接用curl -X OPTIONS ... -H Origin: http://localhost:8081验证后端预检响应头对不对。5.3 重复预约与状态混乱同一个人约了两次同一场次现象前端防抖没拦住双击同一个用户在同一个场次里出现两条状态为「待参观」的预约记录。原因只靠前端按钮 loading 挡并发不可靠后端没有做去重校验。解决数据库加复合唯一索引uk_user_session(user_id, session_id, status)后端 service 层插入前再查一次有效记录双保险。注意状态机设计同一用户同一场次「待参观」只有一条有效记录取消后状态变 3余量回补再次预约则允许插入新记录这是预约系统里必须接受的状态流转。5.4 过期场次与闭馆日预约单挂在已过期的场次上现象用户预约了周二的场次管理员在周一发现周二临时闭馆但前端列表还能看到周二场次并可预约。原因场次创建时没有校验闭馆日状态字段没有联动。解决场次创建接口里校验当天是否为周一闭馆日或节假日节假日做成配置表而不是硬编码闭馆时批量把museum_session.status置为 0前端列表直接过滤status1的场次预约单处理按期核销已过期未参观的预约单通过定时任务批量置为「已完成」或「已过期不计数」。日期边界用session_date.isBefore(LocalDate.now())判断不要用字符串比较避免格式不一致导致误判。6. 打包部署与最小验证用例让系统真正跑起来6.1 前端打包进 SpringBoot两种常见方式与资源路径坑演示和交付时最省事的部署方式是把前端构建产物放进 SpringBoot 的静态资源目录一个java -jar就同时提供页面和接口。cd frontend npm run build rm -rf ../backend/src/main/resources/static/* cp -r dist/* ../backend/src/main/resources/static/ cd ../backend mvn clean package -DskipTests java -jar target/museum-system.jarSpringBoot 的默认静态资源路径之一是classpath:/static/前端构建产物拷贝到这里浏览器访问http://localhost:8080就能打开页面。这里有两个坑要提前避开。第一前端如果用的是 history 路由刷新/admin/sessions这类二级路径会 404因为后端没有对应路由解决方法是把 fallback 配置加上让非/api的请求都转发到index.html或者前端改用 hash 路由。第二构建时的base路径要和部署路径一致部署在根路径就用默认/部署在二级路径则要改base否则 JS 和 CSS 资源全部 404。另一种是后端独立、前端单独用 Nginx 托管的多进程方式动静分离更彻底适合部署在已有 Nginx 服务器的环境。两种方式没有绝对好坏单体演示用第一种线上多服务用第二种。6.2 最小验证用例照着这条路径检查系统是否可用系统交付前我会按用户主路径走一遍最小验证用例能走通基本可以交差。这套用例也适合你自己开发时自查。步骤操作预期结果1注册新用户并登录拿到 Token跳转首页2打开展馆列表能看到已开放场次闭馆日场次不出现3选择场次提交预约弹出成功提示我的预约出现待参观记录4重复提交同一场次后端返回「您已预约过该场次」5取消预约预约状态变为已取消可再次预约6管理员登录创建新场次前端列表实时出现新场次7并发预约验证50 个并发抢 10 个名额成功数等于 10这套系统我做过不止一次最早一次为了图省事库存扣减用的是先查再改上线当天就被并发预约打穿余量变成负数。后来把所有扣减改成条件更新加事务把唯一索引补齐才彻底消停下来。你现在照着这个顺序做大概率比我少踩一半的坑。希望帮到你。本文还有配套的精品资源点击获取
返回列表