news 2026/9/19 2:03:39

SpringBoot+Vue台球厅系统:状态机、并发控制与计费设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue台球厅系统:状态机、并发控制与计费设计

简介:基于 Java+SpringBoot+Vue 的校园台球厅人员与设备管理系统答辩 PPT 已整理为单文件演示稿,面向计算机相关专业毕业设计答辩、项目汇报等场景,针对传统人工管理效率低、信息分散等问题给出系统化解决方案。PPT 围绕课题背景与意义、系统功能实现、研究现状与未来展望逐步展开,清晰呈现用户管理、会员充值、球桌信息、会员预约、普通预约、留言反馈等模块的设计思路,有助于快速梳理答辩主线并应对评委提问。压缩包内共 1 个 pptx 文件,大小仅 1.7MB,内容结构完整、页面编排紧凑,可在展演或答辩前直接参考。已有 65 人学习该资源。通过这套演示稿,读者可以了解基于 B/S 结构的管理系统从需求分析到功能划分的完整过程,同时获取管理员与用户双角色权限设计、数据库连接调试等方面的经验总结,为完成类似毕业设计提供可借鉴的表述框架与制作范本。

1. 校园台球厅系统答辩:从增删改查到状态机与计费

很多同学交上来的台球厅管理系统,界面花哨,表格齐全,但答辩老师一句“你这里最难的技术点是什么”就卡住了。原因很简单:纯增删改查不是系统,是一张 Excel 的 Web 皮。校园台球厅真正难的地方不在“添加一条预约记录”,而在两个容易被忽视的地方——台位状态的并发变更,以及按时计费的准确性。前者决定两个人同时抢最后一张台时会不会出现超卖,后者决定你结算时多算一分钟会不会被用户投诉。

这篇内容围绕 SpringBoot + Vue 的选型展开,把人员管理、设备管理、预约、计时计费、设备维护这一条线怎么建模、怎么落地、怎么在答辩时讲清楚,逐层拆开。适合正在做毕设或课设的 Java 方向学生,也适合准备 SpringBoot 面试题的人在项目层面补一轮实战认知:状态机怎么设计、分布式锁在单体应用里怎么用、条件更新 SQL 为什么比先查后改靠谱。

2. 人员与设备管理的数据骨架:六张表和 RBAC 权限模型

2.1 六张表把台球厅业务立住:从会员到球台维护单

校园台球厅的业务实体比想象中多。只盯着“用户”和“球台”两张表,后面预约、计时、结算、报修全都会卡住。我一般建议至少拆六张表:用户表、球台表、球具表、预约表、结算单表、设备维护表。

用户表不只是存学号和姓名,还要存余额、会员类型(普通、月卡、小时卡)和角色。角色这里有三类:student 学生、staff 值班员、admin 管理员。值班员能开台、结账、登记报修;管理员在此基础上还能维护台价、查看财务报表、管理球具;学生只能预约和查询。

球台表是核心。每张台要有台号、类型(中式八球、斯诺克、九球)、时价(元/小时)、当前状态。状态字段用整数或字符串枚举,我推荐用字符串枚举:FREE空闲、RESERVED已预约、IN_USE使用中、MAINTENANCE维护中。用字符串可读性好,日志排错时一眼能看懂。

球具表容易被忽视。台球厅里球杆、公杆、巧克粉都是消耗品,学生用完随手一放,球杆弯了不知道找谁。球具表要记录编号、类型、当前状态(可借/已借/损坏)、上次维护时间。这张表在答辩时价值很高——它把“设备管理”从静态登记变成了有生命周期的跟踪

预约表记录谁在什么时间预定了哪张台,状态包括待签到、已取消、已完成、超时未到。结算单表在开台时创建,结束计费时写入时长和金额。设备维护表记录报修人、故障描述、处理人、处理状态。这六张表的关系是:隐私保护上注意学号不要明文存储,展示时脱敏即可。

CREATE TABLE `reservation` ( `id` bigint NOT NULL AUTO_INCREMENT, `student_id` bigint NOT NULL COMMENT '预约人用户ID', `table_id` bigint NOT NULL COMMENT '球台ID', `start_time` datetime NOT NULL, `end_time` datetime NOT NULL, `status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING待签到/CANCELLED已取消/FINISHED已完成/TIMEOUT超时', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_table_start` (`table_id`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表';

这段 DDL 里最值得说的是idx_table_start这个联合索引:查某张台在某个时间段是否被预约,是系统里查询频率最高的 SQL 之一,不加索引会随着数据量上升变慢,加了它还能为后续的防并发唯一约束留基础。索引设计在答辩里是能加分的点,多数人只会写主键。

2.2 RBAC 权限模型:接口注解加前端路由双控

权限控制不建议搞复杂的 Spring Security 全套。校园场景下,用 RBAC 最简模型就够了——用户在user表里一个role字段,后端接口用自定义注解校验,前端路由用角色判断是否渲染。

后端我一般这样落地:写一个@RequireRole("admin")注解,配合拦截器在HandlerInterceptor里取当前登录用户的角色做比对。管理员才能调用的设备管理接口、值班员才能调用的开台接口,分别打上注解。注意拦截器要排除登录接口和静态资源路径,否则会出现“登录接口本身要登录”的循环。

前端侧,Vue Router 里给每条路由的metaroles数组。菜单渲染时根据当前用户角色过滤,但这只是体验层,不是安全层——所有真正的权限判断必须回到后端。答辩时被问到“前端隐藏了菜单,那用户直接访问 URL 怎么办”,这个回答就是答案。

// 路由守卫中用角色拦截 router.beforeEach((to, from, next) => { const user = useUserStore() if (to.meta.roles && !to.meta.roles.includes(user.role)) { next('/403') return } next() })

这里有个容易忽略的细节:useUserStore()必须放在守卫回调内部调用,不能放在模块顶层。原因是 Pinia 的 store 要在 Vue 应用实例创建后才能使用,顶层调用拿不到activePinia。这类坑在 vue 面试题里出现频率不低,写法本身也是答辩时说明你“踩过坑”的素材。

3. SpringBoot 后端:预约防并发、计时计费与 Redis 锁的落地

3.1 为什么“查一下有没有空闲再插入”会出问题

预约模块最经典的业务逻辑是:用户选台 → 查时间段冲突 → 没冲突就插入预约。单看每一行代码都没问题,但并发环境下两个请求同时查,会发现同一张台同一时段都是空闲的,然后各自插入成功。这就是典型的超卖问题,和秒杀里库存超卖是同一种东西。

在 SpringBoot 单体应用里,解决方式按强度分三档。最低档是给预约表加唯一约束,比如(table_id, start_time)建唯一索引,数据库层面兜底。中间档是使用 SELECT FOR UPDATE 对台位行加锁。最高档是 Redis 分布式锁——即使未来拆成多实例部署,锁依然有效。

对于答辩项目,我推荐两档叠加:应用层用 Redis 锁防并发,数据库层用唯一索引兜底。理由是:Redis 锁在讲方案时能引出 setnx、原子性、过期时间设计这些关键词,恰好都是 springboot 面试题里的高频内容;唯一索引兜底则展示了“即使锁失效也有最后防线”的工程思维。

public boolean tryLock(Long tableId, LocalDateTime startTime, LocalDateTime endTime) { String lockKey = "table:book:" + tableId + ":" + startTime + ":" + endTime; // setIfAbsent 在 key 不存在时才写入,等价于 SETNX Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); return Boolean.TRUE.equals(locked); }

这里有两个必须说清楚的点。第一,setIfAbsent必须同时传过期时间,不能在拿到锁之后单独调expire,否则 Redis 进程崩溃的瞬间锁就没有过期时间了,变成死锁。第二,过期时间 10 秒要大于业务执行时间,预约插入通常几十毫秒,10 秒足够;但如果套了外部接口调用导致执行变慢,就要考虑锁续期,引入看门狗机制——答辩时能说出这层就算真懂。

3.2 计费逻辑:用时间戳差值而不是累加

计时计费是台球厅系统里最容易被写错的模块。常见错误写法是前端每秒发一次请求,后端把时长字段加一。这样写至少有三个问题:网络抖动丢包导致少计、页面切后台定时器被浏览器挂起导致漏计、多端登录时两个设备重复累加。

正确做法是结算单表里只存start_timeend_time两个时间戳,时长永远由后端计算end_time - start_time。前端展示倒计时可以每秒刷,但后端只认快照时间。

public BigDecimal calcAmount(LocalDateTime start, LocalDateTime end, BigDecimal pricePerHour) { Duration duration = Duration.between(start, end); long minutes = duration.toMinutes(); if (duration.getSeconds() % 60 > 0) { minutes++; // 不满一分钟按一分钟计 } return pricePerHour.multiply(BigDecimal.valueOf(minutes)) .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP); }

注意BigDecimal的计算要指定精度和舍入模式。金额计算用double会出现 0.1 + 0.2 不等于 0.3 的问题,这一点在 java 基础面试题里属于必考项,写在代码里说明你真正理解。时长上,校园台球厅一般按分钟计费,不满一分钟按一分钟是行规;有的球厅按秒计费,那就把toMinutes换成getSeconds,参数表里加一个billing_unit配置项即可。

3.3 参数表:这些配置别写死在代码里

台价、预约提前窗口、取消时限、超时释放时间、最小计费单位,这些都应该放配置表,而不是在代码里if (minutes < 60)写死。原因有两个:答辩时演示改价格要重启项目,体验很差;运营方希望能自己调整。

我建议在系统里加一张sys_config表,key-value 结构,后端启动时加载到本地缓存,配置修改后手动清缓存或设置 60 秒自动刷新。核心参数如下。

配置项建议默认值说明
booking_advance_hours24只允许预约未来 24 小时内的台位
cancel_deadline_minutes120开台前 2 小时可免费取消
no_show_release_minutes15已锁台但超过 15 分钟未签到自动释放
billing_unitMINUTE计费单位:MINUTE 或 SECOND
reservation_deposit0预约押金,校园场景一般不需要

配置表单独拎出来讲,答辩时能体现你对业务边界有思考。比如“超时释放”这个字段,是谁触发释放?不能等下一次有人预约时才检查,需要一个定时任务每分钟扫描一次待签到且超过释放时间的预约,把台位状态从RESERVED改回FREE。定时任务用 Spring 的@Scheduled就能实现,cron 表达式0 * * * * *每分钟执行一次,量级很小不会对数据库造成压力。

4. Vue 前端:动态路由、长轮询倒计时与台位状态看板

4.1 按角色生成动态路由:刷新页面不丢菜单

前端单页应用的一个经典问题是权限路由。如果所有路由一次性注册,学生也能在地址栏直接输/admin/devices访问管理页。虽然后端会拦截,但体验很怪。

动态路由的标准做法是:登录成功后,后端返回当前角色可访问的菜单列表,前端用router.addRoute()动态注册,同时把菜单存到 Pinia 里驱动侧边栏渲染。这里有个大坑:刷新页面后 Pinia 数据清空,动态路由也跟着没了,结果就是用户一按 F5 就直接跳登录页。

解法是项目初始化时就恢复路由。我在main.ts里先调用一个initDynamicRoutes()函数,从 localStorage 取出登录时存的角色和菜单列表,重新执行一次addRoute,再挂载应用。注意 localStorage 里只存角色和菜单标识,不存 token 的明文副本——token 存内存,刷新后通过刷新接口换新 token,安全性更好。

// 登录成功后动态注册路由 const menuRoutes = generateRoutesByRole(role) // 根据角色生成路由表 menuRoutes.forEach(route => router.addRoute(route)) userStore.setMenus(menuRoutes)

generateRoutesByRole内部其实是对一份完整路由表做 filter,按meta.roles过滤。这个方案在 vue 路由相关的面试题里能讲出三个点:路由守卫的触发时机、动态路由持久化、以及 SPA 刷新后路由恢复和首屏加载顺序。深度够了。

4.2 台位状态看板:用递归 setTimeout 代替 setInterval

台球厅管理系统的首页通常是一个台位分布图,每张台一张卡片,颜色区分空闲、预约、使用中、维护。前端定时轮询后端接口刷新状态,是这里最常见的技术动作。

很多初学者直接写setInterval(fetchTableStatus, 5000),但有两个隐患:一是接口响应时间超过 5 秒时,下一次请求可能和上一次重叠,造成数据展示错乱;二是浏览器对后台标签页的 setInterval 会降频甚至挂起,用户切走再切回来,状态还是旧的。

我习惯用“递归 setTimeout”模式,每次接口返回后再启动下一次定时。这样请求天然不会重叠,切回标签页后也能立即恢复轮询。

function pollTableStatus() { fetch('/api/table/status') .then(res => res.json()) .then(data => { tableList.value = data }) .finally(() => { // 上一次请求结束后,再排下一次,避免请求叠加 timer.value = setTimeout(pollTableStatus, 5000) }) }

倒计时部分同理。球台显示剩余时间时,不依赖后端每次返回值,而是本地基于end_time时间戳计算剩余秒数,每秒减一。本地倒计时偶尔偏差一秒钟没问题,真正结算时以后端end_time为准。前端做体验,后端做权威,这个原则要贯穿整个系统。

4.3 状态管理选 Pinia 还是 Vuex

新项目我直接用 Pinia,不选 Vuex。理由就一条:Pinia 的 composition API 写法让 store 像普通函数一样定义和使用,省掉 Vuex 的 mutations 那层样板代码。团队里来了新人,看一个 store 文件三分钟就能上手。Vuex 的严格模式、模块化命名空间,在这个项目规模下都是用不上的复杂度。

Pinia 里存当前用户、角色权限、菜单列表、当前选中的台位信息。注意不要在 store 里塞后端返回的整个大对象,比如把用户的所有历史订单都塞进去——那是数据缓存,不是前端全局状态,存在组件里用onMounted拉取就够了。

5. 打通预约-签到-计时-结算闭环:状态迁移与防脏写 SQL

5.1 一张状态迁移表讲清楚整个流程

台球厅业务的主线是一条状态机:台位FREE→ 用户提交预约 →RESERVED→ 到店签到 →IN_USE→ 点击结束计费 → 结算并生成订单 → 回到FREE。中间还有两个分支:预约后超时未签到 → 台位从RESERVED回到FREE;使用中发现台子故障 → 台位从IN_USE变为MAINTENANCE,维修完成后回FREE

当前状态操作结果状态触发人
FREE提交预约RESERVED学生
RESERVED超时未签到FREE定时任务
RESERVED签到开台IN_USE值班员
IN_USE结束计费FREE值班员
IN_USE报修MAINTENANCE值班员/学生
MAINTENANCE维修完成FREE管理员

这条状态机的价值在于:每个状态的变更都对应一个明确的领域动作,而不是随意 UPDATE。答辩时把这图画在黑板上或者 PPT 里,老师一眼就能看出系统不是“一堆表的增删改查”,而是有业务设计的。

5.2 结束计费时为什么不先 SELECT 再 UPDATE

结算环节有个隐蔽的并发问题:值班员点了“结束计费”,弹窗还在确认,另一个值班员在同一台机器上又点了一次。如果代码写成先查单子状态、判断是使用中、再更新为已结算,两步之间会有时间差,两次请求都读到“使用中”,都执行更新,金额就重复计算了。

解法是条件更新,把状态判断放进 UPDATE 的 WHERE 里,让数据库保证原子性。

UPDATE play_order SET status = 'FINISHED', end_time = NOW(), actual_amount = #{amount} WHERE id = #{orderId} AND status = 'IN_USE' AND end_time IS NULL

Java 侧这样判断结果:

int updated = orderMapper.finishOrder(orderId, amount); if (updated != 1) { // 单子已被更新过或状态不对,拒绝本次操作 throw new BizException("该订单已结算,请勿重复操作"); }

受影响行数是 1 才说明结算成功,是 0 就说明单子状态已经被改过。这样写不需要额外加锁,也不会有 DB 锁等待,代码还最短。同样的技巧可以用在预约时的“锁台”上:UPDATE device SET status='RESERVED' WHERE id=? AND status='FREE',返回值是 1 才继续创建预约记录,否则直接提示台位已被他人预约。

5.3 定时任务释放超时台位与跨天计时

超时未签到的释放需要定时任务配合,但定时任务要防止和用户操作并发打架。比如定时任务刚扫到一条待签到的预约准备释放,学生恰好在这一秒手工点击了签到——两条路同时更新台位状态,可能会冲突。

处理方式是在待办任务里也走条件更新,更新reservationstatus时带上WHERE status='PENDING',谁先更新成功谁生效,后执行的自然更新 0 行。这样就算定时任务和学生操作同时发生,结果也是确定的:要么签到成功释放失败,要么释放成功签到失败,绝不会出现台位已释放但预约单还是待签到的中间态。

跨天计时也是容易埋雷的地方。如果学生的预约是 22:30 到 23:30,但值班员 23:45 才点结束计费,那么结算时长到底按预约时间算还是按实际结束时间算?校园球厅的惯例是按实际时间算,因为可能延时多打了几局。所以end_time必须由结算时数据库的NOW()写入,而不是预约表里的计划结束时间。预约表的时间只是“预计占位”,不影响计费。这一条在答辩时顺着“系统里哪些时间是权威来源”问下去,能答出“结算时间以后端实际动作为准,不以前端展示为准”,就算是想明白了。

6. 答辩 PPT 的三层结构与现场追问的回答策略

6.1 三层故事线:需求痛点、设计决策、验证结果

答辩 PPT 的常见败笔是放一堆界面截图和数据库表结构,讲完页面就结束了。我建议用三层结构组织内容,对应 PPT 只留 12 到 15 页。

第一层是“为什么做这个系统”,用具体场景切入:校园台球厅原来靠黑板手写排班,高峰期抢台靠嗓门,坏了的球杆没记录,结算靠计算器。这一层放一张“手工排班表”照片或复刻图,比任何文字都有冲击力。

第二层是“我的系统怎么解决”,挑两个最硬核的设计讲透:预约时的 Redis 锁防超卖、结算时的条件更新防重复。代码不要整段贴,贴关键方法签名加 5 到 8 行核心逻辑即可,旁边配一句话注释。状态迁移表放一页,说明你已经把业务抽象成了状态机。

第三层是“验证与复盘”,放一次简单的压测结果——我用 JMeter 模拟 50 个学生同时抢同一时段的两张台,最终只有两条预约成功,其余全部收到“已满”提示,无一条脏数据。数据有说服力是因为它是真实跑出来的,不是截图模板。

6.2 老师最可能追问的五个问题

追问回答要点
你这个并发量有多高,为什么要用 Redis 锁高峰期一个时间段约 10 人争抢 6 张台,虽然绝对量不大,但同一秒内的写冲突如果不用锁就会出现超卖;Redis 锁是单体阶段成本最低的方案,以后拆集群不换代码
锁的 key 怎么设计的,过期时间多少key 由台号加时间段拼接,过期时间 10 秒,大于业务执行时间;说明如果执行变慢需要看门狗续期
如果 Redis 挂了,预约还能用吗数据库唯一索引兜底,预约仍会成功但可能返回稍慢;另外 Redis 挂了我会降级为数据库乐观锁,保证不出现两台预约同一个人
权限是前端控制还是后端控制双控,前端控制体验(隐藏菜单、路由守卫),后端控制安全(接口注解 + 拦截器);前端只是方便用户,不是安全边界
你的计费跨天怎么处理统一用 LocalDateTime 存本地时间,不存时间戳字符串;结算时长按开始结束时间戳差值计算,与预约表的计划时间无关

最后的建议是:答辩前把这三个数字背熟——锁的有效期 10 秒、条件更新影响行数 1、超时释放时间 15 分钟。每个数字背后都是一处设计,老师问任何一个,你要能把“为什么定这个值、改大了会怎样、改小了会怎样”讲出来。数据留在代码里,判断留在脑子里,页面上只留最关键的亮点。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 2:02:29

VSCode插件开发实战:在代码注释里看小说

1. 为什么要做一个在代码注释里看小说的插件1.1 这个插件的核心玩法与解决的真实痛点先说清楚这个插件到底干了什么&#xff1a;装进VSCode之后&#xff0c;它能把你指定的一部小说文本&#xff0c;以代码注释的形式“植入”当前打开的代码文件里。你正常写代码、看代码的时候&…

作者头像 李华
网站建设 2026/9/19 2:02:24

React Native面试复习指南:从Bridge到Fabric新架构核心机制解析

1. RN面试复习的底层逻辑与知识框架1.1 为什么RN面试和纯前端面试完全不是一回事很多人准备React Native面试的时候&#xff0c;习惯性地拿Web前端的八股文去套&#xff0c;结果一面就挂。我面过不少候选人&#xff0c;简历上写着“精通React Native”&#xff0c;一问到原生模…

作者头像 李华
网站建设 2026/9/19 2:01:55

装了 mattpocock/skills 的 Claude Code,Key 走 TaoToken 行不行?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 2:01:43

harness 补视觉能力,TaoToken 管 DeepSeek 的文本消耗

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 2:01:15

Packet Tracer 8.2物联网实战:MQTT智能家居原型搭建

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/19 2:00:32

aarch64上Qt5.14.2静态编译实战:交叉编译与部署指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华