毕业设计做到"无人自助台球厅管理系统"这个题目,这两年是真的不少。原因不复杂:这个题一面是真实的商业场景——共享经济、无人值守、物联网联动,都是答辩老师喜欢听的东西;另一面它的技术栈又非常"正统",Spring Boot + MyBatis + MySQL,加一个前端页面,难度可控、工作量清晰,认真做三个月完全能成型。我这次就把整套系统的设计思路、数据库结构、核心代码和踩坑记录完整捋一遍,给正在做这个题,或者准备选这个题但还没确定技术路线的同学,一条不用绕路的参考路线。这篇文章不写虚的,所有讲到的模块和问题,都是我在实际开发和调试中真正遇到过的,你照着做,至少能少踩一半的坑。
1. 这个课题到底在解决什么问题
1.1 无人自助台球厅的核心痛点
传统台球厅最大的成本不是房租和球桌,是人力。前台要有人收银,服务员要盯着客人离场、计算超时、清理桌面,夜场还得通宵轮班,一个月光工资就压得小店喘不过气。无人自助模式把这些动作全部交给系统和硬件:顾客扫码开台、系统自动计时计费、到点自动断电、线上支付结账,老板只需要远程看后台数据就行。
这个逻辑和共享自习室、共享茶室一模一样。它解决的问题不是"省一个服务员",而是让一家台球厅从"必须有人值守"变成"全天候无人也能营业",营业时长从12小时拉长到24小时,边际成本几乎为零。对毕设来说,这个业务背景非常容易讲清楚,答辩开场的"选题意义"部分基本不用发愁。
1.2 系统功能边界怎么划才合理
毕设最怕两件事:一是功能太少,答辩时说不出东西;二是功能堆太多,做到一半发现根本做不完。无人自助台球厅这个题目天然有个好处,它的功能边界非常清晰,按角色拆就够用了:
用户端(微信小程序或者H5,二选一即可)负责:注册登录、扫码查看球桌、预付费开台、查看实时计费、续费加钟、在线支付、充值、申请退款。
管理端(Vue + Element Plus的Web后台)负责:球桌管理(增删改查和状态维护)、计费规则配置(不同桌型、不同时段的价格)、订单管理(进行中订单、历史订单、异常订单处理)、会员管理、营业收入统计。
再加上一个可选加分项:硬件联动。通过智能电控插座控制球桌灯的电源,下单成功后自动通电,订单超时未续费自动断电。这个设计一放出来,整个系统的"无人"就名副其实了,答辩老师会觉得你不只做了个CRUD,而是真的思考过落地场景。
1.3 技术选型为什么是Spring Boot
我见过不少学生纠结框架,想用Go、想用Python FastAPI,甚至想上微服务。我的建议非常直接:毕设就老老实实用Spring Boot。原因有三个。
一是生态成熟。Spring Boot + MyBatis Plus + MySQL + Redis这套组合的教程、踩坑记录、面试题多到数不清,你遇到任何问题,搜一下基本都有现成答案,不像冷门框架,报错折腾三天没人理。
二是边界清晰。Spring Boot的自动配置和起步依赖把大部分基础设施工作做完了,你能把精力集中在业务逻辑上。对于"开台计费"这种核心业务,你需要的是写清楚状态机、算清楚金额,而不是跟框架本身死磕。
三是答辩安全。Java在后端领域的地位摆在那里,答辩老师几乎不可能对你选Spring Boot提出质疑,反而会顺着这个选型问你Spring Boot的自动配置原理、依赖注入、事务管理,这些都是有标准答案的,你准备一下就能应对。
2. 整体架构与数据库建模
2.1 前后端分离的总体架构
整个系统我采用的是前后端分离的结构:后端独立提供RESTful API,前端管理后台使用Vue3 + Element Plus,用户端使用微信小程序。这样做的好处是职责清晰,后端只做数据校验、业务逻辑和持久化,前端只管页面交互,两边通过JSON格式的数据通信,接口联调用Swagger文档对齐。
部署形态上,Spring Boot内置Tomcat,后端打一个jar包就能跑;前端构建完的静态文件由Nginx托管,也可以直接把dist目录扔到Spring Boot的static目录里,实现单端口部署,演示的时候最省事。
这里补充说明一下各层职责:Controller层只负责参数接收和结果封装,不写业务;Service层承载所有核心逻辑,比如开台、计费、结账,必须用事务管理;Mapper层用MyBatis Plus封装好的BaseMapper做基础CRUD,复杂的统计查询用XML或注解SQL完成。层与层之间单向依赖,这样的结构答辩时画架构图特别清晰。
2.2 数据库表设计:八张核心表一次说清
数据库设计是整个项目的地基,我实际设计的时候一共用了八张核心表,每张表的用途和关键字段我列一下:
用户表(sys_user):openid(微信用户唯一标识)、nickname、phone、balance(余额)、status(0正常/1冻结)、create_time。
桌型表(table_type):type_name(普通台/赛台/VIP包间)、is_vip、price_per_hour(标准小时价)、deposit(押金)。桌型独立建表而不是直接写在球桌表里,是为了后续调价和扩展新桌型方便。
球桌表(billiard_table):table_no(桌号,如A01)、table_type_id(关联桌型)、location(位置描述)、status(0空闲/1使用中/2维护中)。桌号建议用"区域+编号"的字符串格式,扫码时直接通过桌号定位。
订单表(billiard_order):order_no(业务订单号)、user_id、table_id、start_time(实际开台时间)、plan_end_time(预计结束时间)、actual_end_time(实际结束时间)、pre_amount(预收金额)、final_amount(实际应收金额)、refund_amount(退款金额)、status(0待支付/1进行中/2已完成/3已取消/4异常)。
支付流水表(payment_record):payment_no、order_id、pay_type(微信/余额)、amount、status(0待支付/1成功/2失败)、transaction_id(微信支付单号)、callback_time(回调时间)。支付流水独立建表,方便对账,后面讲幂等处理会用到。
充值记录表(recharge_record):user_id、amount、balance_before、balance_after、pay_type、create_time。
计费规则表(price_rule):table_type_id、period_start、period_end(时段区间)、price_per_hour(该时段小时单价)。有了这张表就可以实现"闲时8点前打折、周末上浮"这类灵活的计费策略,比死写单价高级得多。
管理员表(sys_admin):username、password(BCrypt加密存储)、role。管理端登录单独建表,和C端用户隔离,权限也更清晰。
2.3 核心业务的状态流转设计
这个系统里含金量最高的设计,是订单和球桌的状态流转。开台的完整流程是这样的:用户扫描球桌上的二维码,小程序拿到桌号,跳转到详情页;用户看到当前单价和押金,选择"按小时预付费"或者"先充值后结算";系统校验球桌状态为空闲后扣款,创建订单并把球桌状态改成使用中;紧接着调用智能插座接口给球桌灯通电,订单进入计费中状态。
计费过程中,定时任务每分钟扫描一次进行中的订单,随时可以算出当前费用,供用户端实时展示。到达预计结束时间前10分钟,系统通过微信订阅消息或小程序内轮询给用户推送续费提醒。用户可以选择加钟续费,也可以主动点击结束;如果一直没人操作,到点后系统自动断电、强制结账,把订单置为异常状态,等待管理员处理。
为什么一定要设计成"预付费+多退少补",而不是"玩完再结账"?因为无人场景下没有人能约束顾客离场,先收钱是唯一能跑通的商业闭环。用户提前结束时按实际时长结算并把剩余金额退回;超时未处理的,从押金里扣超时费。这套逻辑想清楚之后,整个系统的代码实现就顺了。
3. 核心模块的关键实现细节
3.1 用户认证与登录拦截
用户端我采用的是JWT无状态登录方案。微信小程序端先调用wx.login拿到code,后端再通过code换取openid,查到用户则直接签发JWT,查不到就自动注册一个账号再签发。管理端则是传统的用户名密码登录,密码用BCrypt加密,登录成功后也签发JWT。
关键点在于全局拦截器。我实现了一个HandlerInterceptor,在preHandle里解析请求头中的Authorization字段,校验JWT签名和有效期,然后把用户信息放进ThreadLocal,Controller里通过工具类直接拿当前登录用户。要注意放行路径白名单:小程序登录接口、支付回调接口、球桌列表这些不需要登录就能访问的接口,必须在拦截器里排除,否则会出现诡异的"未登录"报错。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或登录已过期"); } // 解析JWT,放入ThreadLocal String openid = JwtUtil.parseToken(token.replace("Bearer ", "")); UserContext.set(openid); return true; } }3.2 扫码开台的并发安全处理
开台是整个系统最核心的接口,因为并发场景最恶劣:两个人同时扫同一张桌子的码,如果代码不做并发控制,就会出现"双开"。我第一个版本就吃过这个亏,测试时两个账号同时点开台,数据库里插了两条进行中的订单,球桌状态也乱了。
解决办法是两层保护。第一层在数据库层面,用MyBatis Plus的乐观锁更新:执行UPDATE billiard_table SET status = 1 WHERE id = ? AND status = 0,这条SQL的影响行数为1才说明抢桌成功,为0就返回"手慢了,桌子已被占用"。这是原子操作,天然防并发。第二层在应用层,用Redis的setIfAbsent方法给tableId加一把分布式锁,防止同一毫秒内多条请求同时进入开台逻辑。
@Transactional(rollbackFor = Exception.class) public Result openTable(OpenTableDTO dto) { // 校验用户余额/押金是否足够 // 乐观锁抢占球桌 int update = billiardTableMapper.updateStatusById(dto.getTableId(), 0, 1); if (update == 0) { return Result.error("该球桌已被占用"); } // 创建订单,预扣金额 // 推送硬件指令给智能插座,通电 return Result.success(orderNo); }这里还有个容易遗漏的细节:锁定球桌、扣减用户余额、创建订单这三个操作必须在同一个事务里,任何一个失败都要回滚,否则会出现"扣了钱但没开台成功"或者"开台成功但没扣钱"的数据错乱。我建议在Service方法上加@Transactional(rollbackFor = Exception.class),注意默认只回滚RuntimeException,检查异常不回滚,所以还是显式指定一下更稳妥。
3.3 计费逻辑与定时任务
计费的核心是"实时金额计算"。最简单的实现方式是不落库,每次用户查进度的时候现算:用当前时间减去开始时间得到已用分钟数,再按对应的时段单价计算费用。但这里有一个业务歧义需要提前定清楚:时长按分钟精确计算,还是按最小计费单元算?
我采用的是"分钟级精确计费,首次开台最少1小时"。这样做的好处是用户端展示的金额和最终扣费一致,少了很多投诉和纠纷。计算时还要处理跨时段问题,比如18:59开台,到了19:30,就前1分钟按闲时单价、之后按高峰单价来算。实现上建议按分钟循环遍历,把每分钟归属到对应时段,累加即可,订单量不大的话性能完全没问题。
定时任务我用的是Spring自带的@Scheduled注解,每分钟跑一个扫描方法:找出所有status为进行中且plan_end_time小于当前时间的订单,调用硬件接口断电,然后把这些订单标记为待结算状态。这个方案比引入消息队列做延迟消息简单得多,对毕设完全够用,而且逻辑直观、好答辩。
@Scheduled(cron = "0 * * * * ?") public void autoSettleExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpiredRunningOrders(); for (Order order : expiredOrders) { // 1. 调用硬件接口断电 hardwareClient.powerOff(tableMapper.selectById(order.getTableId()).getDeviceCode()); // 2. 更新球桌状态为空闲 tableMapper.updateStatus(order.getTableId(), 1, 0); // 3. 计算超时费用,从押金中扣除,生成退款单 settleOrder(order); } }3.4 微信支付接入与回调幂等
微信支付是无人自助系统绕不开的一环。毕设阶段不需要真商户号,申请一个微信支付沙箱环境或者用第三方测试网关,甚至直接用余额支付模拟完整流程,都可以。但如果想完整实现,我建议用Native支付:系统生成二维码,用户扫码付款,支付成功后微信服务器向回调地址发起异步通知。
这里最重要的坑是回调幂等。微信支付为了保证通知可靠,会多次发送回调,如果你的代码收到一次回调就更新订单状态、加一次余额,必然会出现"充一百到账三百"的事故。处理方式是在支付流水表上建唯一索引(order_id + pay_type),处理回调时先查流水状态,如果已经是成功状态就直接返回"SUCCESS",不再重复更新。同时,修改订单状态SQL要带上状态条件,比如UPDATE payment_record SET status = 1 WHERE order_id = ? AND status = 0。
3.5 后台数据统计怎么做
管理端的营收统计是答辩时的加分项,但实现起来不需要很复杂。我使用了MyBatis Plus的聚合查询,配合Java 8 Stream做分组和汇总,就能输出日报、周报、桌台使用率这些基础指标。
比如统计今日营收,核心SQL就是按订单状态过滤后SUM(final_amount);
比如统计各球桌的使用时长排名,就是按table_id分组后SUM(TIMESTAMPDIFF(MINUTE, start_time, actual_end_time))。
图表展示随便用ECharts,柱状图、折线图、饼图各配两个页面,视觉上就很完整了。
4. 从源码到跑通全流程的实操经验
4.1 环境准备清单
动手之前先把环境一次性装好,省得后面被各种版本问题折磨。我个人推荐的组合是:JDK 1.8(不要一上来就装JDK 21,兼容性问题会让你怀疑人生)、Maven 3.6+、MySQL 8.0(5.7也行)、IDEA最新社区版足够。Redis如果不想装,项目里可以先用本地缓存放一边,但如果有余力还是建议装一个,后面讲锁和缓存都会用到。
数据库初始化不要手动逐条建表,太容易出错。我一般在项目里放一份sql脚本,包含建库语句和全部建表语句,再插入一些测试数据,比如三张球桌、两种桌型、一条闲时计费规则。这样别人拿到源码后,执行一遍sql脚本就能跑起来,也是源码交付的一部分。
4.2 项目结构与包名规划
一个清晰的包结构能让你写代码的时候少走很多弯路。我建议包名统一以com.xxx.billiard开头,内部按功能模块分包,而不是按三层架构分包。所谓按功能分包,就是每个业务模块建一个包,里面再放controller、service、mapper、entity。比如booking包放开台和订单相关的所有类,payment包放支付和退款相关的所有类。对毕设代码来说,这种组织方式比三层大平铺好理解得多,自己维护和答辩展示都方便。
4.3 核心配置文件的几个关键项
application.yml里除了常规的数据源配置,有三个地方容易出错,提醒一下。第一,spring.jackson.date-format要设置成yyyy-MM-dd HH:mm:ss,否则前端拿到的日期是带T的国际格式,看着非常奇怪。第二,mybatis-plus的map-underscore-to-camel-case默认是true,数据库下划线字段能自动映射到驼峰属性,别手贱改掉。第三,Redis配置的host和端口记得区分本地和服务器环境,我建议用application-dev.yml和application-prod.yml分环境管理。
4.4 本地联调与演示注意事项
联调阶段推荐用Swagger或者Knife4j,直接在浏览器里调试所有接口,不用前端写好才能测后端。前端和后端的联调方式,开发时可以通过Vite的proxy配置转发到localhost:8080,避免跨域问题。部署演示时,如果现场只有一台电脑,把前端dist文件拷到后端static目录,跑一个jar包就完事,网络配置少,出错的概率也低。
演示前的数据准备特别重要。我会在演示环境里预置几个测试账号,账上充好余额,并且有一张球桌保持"空闲"状态便于现场演示开台。我还习惯在演示前手动跑一遍核心流程,把订单清一清,避免现场打开后台发现一堆测试垃圾数据,观感很差。
5. 常见问题与避坑实录
5.1 并发开台导致的"一桌两单"
出现这个问题的根本原因是先查询后更新的非原子操作。很多人写的代码是:先select球桌状态,判断是0,再update成1。这个流程在并发下必然出问题,两个请求都查询到状态为0,然后双双更新成功。这就是为什么我在前面强调必须用UPDATE ... WHERE status = 0这种条件更新,而不是先查再改的原则。遇到这个问题,先检查所有状态变更是否都用了带条件更新的SQL,再检查是否有Redis锁兜底。
5.2 计费金额对不上账
金额不平的问题90%出在跨时段计费或者退款计算上。比如用户开台1小时,预付了高峰价,但实际打了45分钟就结账走人,中间跨越了闲时时段,退款金额怎么算?我踩过一次坑之后,总结出一套规则:创建订单时先按预计时段计算预收,按"整小时预收、押金补差"的方式;实际结算时重新按分钟计算一遍真实费用,多退少补。退款金额必须在事务里重算,绝不能简单用"预收减去固定单价乘时间"去算。
5.3 Spring Boot版本过高引发的连锁报错
这是我见过最多的新手问题。去网上搜教程,资料里用的是Spring Boot 2.x的写法,自己却装了Spring Boot 3.x,结果发现javax.servlet变成了jakarta.servlet,很多第三方starter不兼容,代码就是编译不过。我的建议非常明确:毕设老老实实用Spring Boot 2.7.x + JDK 1.8,这个组合的资料最多、兼容性最好、报错一搜全有答案。不要为了追求"新版本"给自己添堵,稳定跑通比版本号漂亮重要得多。
5.4 微信支付回调重复导致重复入账
前面讲过幂等,这里再补充一个排查技巧。如果你已经加了唯一索引但还是出现重复入账,很可能是没有处理全表约束,或者回调处理逻辑里先查了流水再发起了加余额操作,两步之间存在并发窗口。正确做法是把"查询流水状态 + 更新流水 + 更新订单/余额"全部包在一个事务里,并且数据库表加上唯一索引,双保险。我实际测试中,这种设计下即使回调重发十几次,数据也纹丝不动。
5.5 答辩高频问题怎么准备
根据我观察到的答辩现场,这个题的高频问题基本集中在这几个方向:为什么选择Spring Boot而不是其他框架(答自动配置、生态、适合快速开发);并发开台怎么解决的(答乐观锁+Redis锁,配合代码演示);用户一直不结账怎么办(答定时任务强制断电+押金扣款);退款怎么保证准确(答事务重算+支付流水记录);数据库表为什么这么设计(答第三范式,订单和流水分离方便对账)。这些问题提前准备好,答辩基本就稳了。
6. 个人体会与扩展建议
最后说一点我实际做完这个项目之后的体会。第一,系统的核心不是前端页面做得有多炫,而是业务闭环能不能完整走通,从扫码、开台、计费到结算,这个闭环里每一个环节的数据一致性都要认真对待。第二,源码交付时一定要附带一份清晰的README,写清楚环境要求、数据库初始化方式、启动步骤、测试账号,别让拿到源码的人对着报错猜半天。这是很多同学忽略的细节,但非常加分。
如果做完基础功能还有余力,我建议往这三个方向扩展一下:一是接入真实微信支付和真实智能插座,做一个可以真正试运营的版本;二是增加会员营销模块,比如充值满赠、优惠时段套餐,商业价值立刻提升;三是加一个基于ECharts的大屏数据看板,把实时订单、营收趋势、桌台利用率投到大厅屏幕上,整个项目的完成度会再上一个台阶。这个题目能做到这个深度,无论是答辩还是写进简历,都是拿得出手的。