如果你最近在找SpringBoot练手项目,或者正为课程设计、毕业设计选题发愁,这套基于SpringBoot+Vue的海滨体育馆管理系统会很对胃口。它不是一个简单的CRUD堆砌,而是围绕场地预约、订单管理、会员体系、财务统计串起来的完整业务闭环,正好覆盖Java后端、MySQL数据库、MyBatis持久层、Vue前端这几个主流技术栈。无论你是要找毕设源码做二次开发,还是想照着完整代码把全栈知识点捋一遍,都能从这套系统里拿到一份能跑、能讲、能扩展的参考样板。
我最初搭这套系统时,目标很明确:既要满足“管理后台”的典型功能,又要照顾“实际体育馆”的日常使用节奏——顾客能在线看场地、订时段、查订单,管理员能维护场地信息、审核超时订单、盯着每日营收。说白了就是线下人工排班的活儿,搬到线上变成自动化的状态流转。这篇文章就把我拆解这个项目时的设计思路、核心表结构、后端关键实现、前端联调细节,以及那些折腾到半夜的坑,一次性讲清楚。
1. 项目概述与核心需求解析
1.1 系统要解决什么问题
海滨体育馆这类场所,最让人头疼的不是场地本身,而是预约管理。一个体育馆通常有羽毛球馆、篮球馆、乒乓球台、健身区等多个场地,每个场地又按小时切分成时段,顾客什么时候订、哪个场子空着、订了之后临时取消,全靠一张Excel表和前台口头沟通,混乱几乎是必然的。
这套管理系统要解决的就是三个核心痛点:
- 场地状态不透明:顾客不知道当前哪些时段可约,只能打电话问,前台被反复骚扰。
- 预约冲突难避免:两个顾客同时口头预约同一个时段,最后到场才发现撞车,体验极差。
- 财务对账靠手工:每天的订单金额、取消退款、周末高峰营收,全靠人工统计,容易漏。
我在设计时把系统分成了“用户预约端”和“管理后台端”两个视角。用户端看重的是查询方便、操作简单;管理端看重的是数据完整、状态清晰、统计直观。
1.2 功能模块划分与角色设计
整套系统的角色就两类:普通用户和管理员。普通用户注册登录后,可以浏览场地列表、查看某一场地某一天的时段占用情况、在线提交预约订单、取消未开始的预约、查看自己的历史订单。管理员登录后台后,可以维护场地信息、设置每个场地每小时的价格、查看所有用户的预约订单、手动取消异常订单、统计每日营收和场地利用率。
模块清单大致是这样的:
| 模块 | 功能点 | 角色 |
|---|---|---|
| 用户注册登录 | 账号注册、密码加密存储、登录鉴权 | 普通用户 |
| 场地管理 | 场地增删改查、场地类型、每时段价格 | 管理员 |
| 场地预约 | 按日期查看空闲时段、选择场地、提交订单 | 普通用户 |
| 订单管理 | 订单列表、状态流转、取消预约 | 普通用户/管理员 |
| 财务统计 | 每日营收、订单量趋势、场地利用率 | 管理员 |
| 系统管理 | 操作日志、用户状态管理 | 管理员 |
可能有人会觉得,对于一个“体育馆系统”来说,这功能会不会太“标准”了?其实标准的背后是稳妥。课程设计和毕业设计最忌讳的恰恰是功能跑偏,核心业务逻辑都说不清楚。这套模块设计正好把“预约业务流程”走通,每一步都有数据支撑,答辩时也容易讲解。
1.3 为什么选定这套技术栈
SpringBoot + Vue + MyBatis + MySQL,这个组合可以说是Java全栈项目里的“黄金搭档”。选它不是因为追新,而是因为每一项都有明确的理由:
- SpringBoot:不用像SpringMVC时代那样写一堆XML配置,起步快、约定大于配置,内嵌Tomcat,打一个jar包就能部署。用它做后端接口服务非常顺手。
- Vue:前端用Vue-CLI搭建,组件化开发,配合Element UI能很快铺出后台管理界面。Vue的数据双向绑定让表单交互、列表渲染省掉大量DOM操作。
- MyBatis:相比JPA那种“全自动ORM”,MyBatis把SQL直接写在Mapper XML里,SQL执行过程透明可控。对于体育馆这种带有复杂查询(时段占用检查、营收统计)的业务,手写SQL反而更精准。
- MySQL:开源的、最主流的数据库,软件安装简单,学习资料多。场馆预约业务的数据量远没到需要上集群的程度,单库单表就能扛住。
这套技术栈的另一个好处是就业市场认可度高。在很多中小型公司的Java技术栈里,SpringBoot + MyBatis依然是主流。做完这个项目,写在简历上也不虚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与后端核心实现
2.1 库表设计与字段规划
数据库设计直接决定后面写代码的顺畅程度。我第一版为了“省事”,把订单表和订单明细揉成了一张表,结果后面统计时段冲突、做横向扩展时非常痛苦。第二版重新拆表,最终按下面的结构来设计:
-
core_user(用户表):id、username、password、real_name、phone、role(1管理员,2普通用户)、status、create_time。
-
core_venue(场地表):id、venue_name、venue_type(羽毛球馆/篮球馆/乒乓球台/健身区)、price_per_hour(每小时价格)、status(1启用,0停用)、img_url、remark。
-
venue_order_master(预约单主表):id、order_no(订单号)、user_id、venue_id、book_date(预约日期)、start_time、end_time、total_amount、status(0待支付,1已预约,2已完成,3已取消)、remark、create_time。
-
venue_order_detail(预约时段明细表):id、order_id、venue_id、book_date、start_time、end_time、price。虽然拆成主表和明细表后代码量多了,但好处很明显:以后如果要做“同一个订单包含多个场地、多个时段”,明细表直接就能扩展。
密码字段记得不要明文存储。我在代码里用BCrypt加密,注册时把加密后的密文写入库,登录时用BCrypt校验。这样哪怕数据库被人拖走,用户密码也不会直接暴露。
这里多提一句字段类型的细节。book_date用DATE类型,start_time和end_time用VARCHAR存“09:00”这种格式,比用TIMESTAMP省去一堆时区转换的麻烦。营业额相关的total_amount用DECIMAL(10,2),不要用FLOAT或DOUBLE,不然对账时会出现0.1+0.2不等于0.3的尴尬。
2.2 订单核心流程与事务控制
预约流程是整个系统的心脏。用户从前台选好场地、日期、时段,点击提交,后台到底应该做哪些校验?
- 判断当前用户是否登录。
- 判断场地是否存在、是否为启用状态。
- 判断所选时段是否已被人占用。
- 计算订单总金额。
- 生成唯一订单号。
- 保存订单主表数据。
我写完第一版后,被导师提了个问题:如果两个用户同时抢同一个时段,会不会产生超卖?当时愣住了,因为我的代码是先查询时段空闲,再插入订单,两步之间存在时间差——这正是经典的并发覆盖问题。
想了一下,最直接的解决思路有两种:一是在venue_order_master表上加一个联合唯一索引(venue_id + book_date + start_time),从数据库层面限制重复插入;二是在事务里先对core_venue表的那一行执行SELECT ... FOR UPDATE加排他锁,锁住场地行后,后续的插入操作就得排队。
如果你是做毕设,我推荐双保险:数据库唯一索引一定能拦住,代码里再加一层查询校验。具体后端代码的伪代码大致是这样的:
java复制@Transactional
public OrderVO createOrder(CreateOrderRequest req) {
Long venueId = req.getVenueId();@Jac
// 1. 锁场地行,防止并发同时预约同一时段
Venue venue = venueMapper.lockVenueById(venueId);
if (venue == null) {
throw new BizException("场地不存在");
}
// 2. 校验这个时间段是否已经被占用
int count = orderDetailMapper.countByVenueAndTime(venueId,
req.getBookDate(), req.getStartTime(), req.getEndTime());
if (count > 0) {
throw new BizException("该时段已被预约,请选择其他时段");
}
// 3. 生成订单号并计算金额
String orderNo = orderNoGenerator.generate(req.getUserId(), venueId);
BigDecimal amount = venue.getPricePerHour()
.multiply(BigDecimal.valueOf(req.getDurationHours()));
// 4. 插入主表 + 明细表
orderMasterMapper.insert(...);
orderDetailMapper.insert(...);
return buildVO(orderNo, amount);
}
注意这里用@Transactional把“校验+插入”包在同一个事务里,保证原子性。SpringBoot对事务的接管非常友好,只需要在Service方法上标注即可,前提是方法必须由Spring的代理对象调用。
订单状态机也是个值得多聊两句的点。我把状态定义成4个值:0待支付、1已预约、2已完成、3已取消。用户提交订单等于创建了“待支付”记录,支付成功或管理员后台确认后变成“已预约”,超过预约时间点后可以认为“已完成”,用户主动取消或者管理员干预后变成“已取消”。状态流转的校验我放在Service层,明确禁止“已取消”直接变“已完成”这样的非法跳转。
2.3 订单号生成与MyBatis分页技巧
订单号不能随便用自增ID,因为顾客投诉或财务对账时,一个看着有规则的订单号会友好很多。我用的生成规则是:"VO" + yyyyMMdd + 用户ID后三位 + 场地ID + 4位随机数。为什么要带这么多段?一是好看,二是排查问题时一眼能看出是哪天、哪个用户、哪个场地的订单。
java复制public String generate(Long userId, Long venueId) {
String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String userPart = String.format("%03d", userId % 1000);
String venuePart = String.format("%02d", venueId % 100);
int random = ThreadLocalRandom.current().nextInt(1000, 9999);
return "VO" + datePart + userPart + venuePart + random;
}
很多人问“订单号要不要用分布式ID”,我的回答是:体育馆校内项目老老实实生成可读订单号就行,非要上雪花算法反而不好在答辩时讲清楚。分清场景比堆技术更重要。
MyBatis这块,分页查询是后台管理列表绕不过去的需求。我直接集成了PageHelper这款分页插件,使用非常简单:
java复制PageHelper.startPage(pageNum, pageSize);
List<OrderVO> orderList = orderMapper.selectOrderPage(condition);
PageInfo<OrderVO> pageInfo = new PageInfo<>(orderList);
这里有一个踩过多次的坑:PageHelper.startPage()之后必须紧跟第一条执行的MyBatis查询,中间不能穿插其他SQL逻辑。我第一次写的时候在startPage和查询之间塞了一个日志插入操作,结果日志插入那条SQL被分页拦截了,页面数据直接乱套。另外,动态条件查询我习惯写在Mapper XML里,配合<where>和<if>标签来拼SQL,比如这样:
xml复制<select id="selectOrderPage" resultType="com.demo.entity.VenueOrder">
SELECT o.*, v.venue_name
FROM venue_order_master o
LEFT JOIN core_venue v ON o.venue_id = v.id
<where>
<if test="venueId != null and venueId != ''">
AND o.venue_id = #{venueId}
</if>
<if test="status != null and status != ''">
AND o.status = #{status}
</if>
<if test="bookDate != null and bookDate != ''">
AND o.book_date = #{bookDate}
</if>
</where>
ORDER BY o.create_time DESC
</select>
MyBatis的<if>判断明显比在Java代码里拼SQL干净得多,也避免了SQL注入风险。#{}预编译的写法是底线,千万不要用${}去拼字段值。
3. 前端Vue页面设计与联调细节
3.1 路由与组件结构
前端工程我用Vue CLI创建,默认生成src目录后,我自己按业务重新整理了目录:
- views/user:用户端页面(首页、场地列表、场地详情、预约下单、我的订单)。
- views/admin:管理端页面(场地管理、订单管理、用户管理、财务统计)。
- router/index.js:路由配置,按需懒加载。
- api/:按业务模块拆分的接口请求文件。
- store/:Vuex状态管理,存用户信息、token、登录状态。
路由设计上,我在前端做了一个简单的路由守卫:判断本地有没有token,没有token就强制跳转登录页。虽然真正的鉴权在后端接口,但前端先拦截能明显提升使用体验。
js复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next({ path: '/login' })
} else {
next()
}
})
3.2 Axios封装与常见拦截处理
前后端联调最烦的是重复代码。如果每个页面都写一遍axios请求,还要手动处理错误状态码,不仅代码冗余,还会漏掉很多边界情况。我习惯在api目录下统一封装一个request实例:
js复制import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = token
}
return config
})
request.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 200) {
Message.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg))
}
return res.data
},
error => {
Message.error(error.message)
return Promise.reject(error)
}
)
export default request
统一封装的最大好处有两个:第一,登录后的每个请求都自动携带token,不用每个页面重复写;第二,后端返回的code统一判断,遇到业务异常直接弹提示,前端代码里不需要再写一堆if (res.code === 500)。
3.3 场地预约页面的交互实现
场地预约页面是整个系统交互最复杂的部分。用户需要选择日期、选择场地、选择开始时间和结束时间,然后查看实时价格。前端这边的日历选择我用Element UI的DatePicker,时段选择则是从后端接口拉取“已经被占了的时间段”,然后前端渲染一个可点击的时段表格、把不可用的时段置灰。
这里要重点说一个业务逻辑的坑:用户在页面选好时段后,提交前时刻,这个时段很可能已经被别人抢了。所以前端置灰只是体验优化,真正的防冲突必须依赖后端在插入订单时再次校验。我在实际开发中碰到过用户连续点击三次提交按钮、生成了三条订单的情况。最终解决方案是:前端在点击提交后立即把按钮置为loading状态并禁用,防重复提交;后端在事务里再做唯一性约束。双管齐下才算稳。
财务统计页面,我用ECharts画了两个图表:每日营收折线图和场地利用率柱状图。前端只负责调用统计接口拿聚合数据,后端用GROUP BY语句把数据算好,两边配合起来很顺畅。
4. 常见问题与排查技巧实录
4.1 项目启动与依赖相关
端口被占用: SpringBoot默认端口8080经常被其他程序占用。启动报错日志里明显能看到“Port already in use”。解决方式很简单,要么在application.yml里改server.port,要么启动时用--server.port=8081参数覆盖。我习惯把端口配置放在application.yml,方便统一查看。
Lombok版本冲突: 网上很多项目源码用的是老版本Lombok,如果你的IDEA是新版本,可能出现java: 找不到符号 方法 builder()。一般去pom.xml里把Lombok版本换成较新的即可,比如1.18.30以上。这个坑我在导入Java课程设计源码时几乎必踩。
数据库连接乱码: 如果在插入中文数据后发现乱码,九成是数据库连接串没加编码参数。在jdbc.url上加上characterEncoding=utf8&serverTimezone=Asia/Shanghai基本能解决。同时确认数据库表本身的charset至少是utf8mb4。
4.2 前后端联调与权限问题
跨域请求被拦截: 前端跑在8081或5173,后端跑在8080,浏览器会拦截跨域请求。我在后端写了一个CorsFilter,直接把常见的前端端口放行:
java复制@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
这个配置听起来简单,但如果没有setAllowCredentials(true),前端在携带cookie或Authorization头时会被浏览器拦掉,报错信息还特别误导人,经常让人怀疑是后端接口写错了。
登录后接口返回401: 前端请求带上了token,但后端自定义拦截器校验时发现token为空或过期。我的排查顺序是:先用Postman直接测接口确认后端没问题,再看前端请求头里Authorization有没有带对、token在localStorage里存的是什么格式。九成的401都是这两个环节的问题。
4.3 MyBatis相关的反复横跳
分页插件不生效: PageHelper失效的原因我在前面提了一部分。还有一个高发场景:PageHelper.startPage()在循环里被反复调用,或者调用后执行了好几条SQL,分页信息被覆盖。解决方案是确保startPage后紧跟目标Mapper查询,并且不要在循环里分页。
MyBatis的<if test="">判断失效: 前端传了字符串类型的“0”和后端Integer类型的0,在OGNL表达式里判断结果不同。最典型的例子是状态参数status=0,用<if test="status != null and status != ''">时,Integer类型的0能正常进入条件,String类型的“0”会被当成空串的判断给挡住。解决办法是统一后端接收类型,或者用status != null和status.toString() != ''分开判断。
时间字段JSON序列化: LocalDateTime类型默认转成数组格式[2025, 3, 15, 10, 30, 0],前端拿到后一脸懵。在application.yml里加一段Jackson配置,统一日期格式:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
再加上jackson-datatype-jsr310依赖,LocalDateTime就能正常输出成可读字符串。
4.4 报表统计数据处理
一开始写“每日营收统计”接口时,我用的是遍历订单、在Java代码里累加的方式。数据量小看不出来问题,一旦订单量上千,接口响应明显变慢。后来优化为在MySQL里直接聚合,一条SQL搞定:
sql复制SELECT DATE_FORMAT(book_date, '%Y-%m-%d') AS day,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM venue_order_master
WHERE status IN (1, 2)
AND book_date BETWEEN #{startDate} AND #{endDate}
GROUP BY DATE_FORMAT(book_date, '%Y-%m-%d')
ORDER BY day
这里的经验是:能用SQL聚合的就别拿Java循环算,耗时直接从秒级降到毫秒级。后台统计页面的数据如果超过几秒钟还没出来,大概率是写法的问题,而不是数据库的问题。
5. 扩展方向与二次开发建议
整套系统跑通之后,如果你还有余力,或者想把项目做得更有竞争力,可以从下面几个点入手做扩展。
5.1 引入缓存优化高频查询
场地列表和场地时段占用是最典型的“读多写少”场景。把场馆信息和每天的预约时段缓存到Redis里,能大幅降低MySQL的压力。用户端查询时先查Redis,没有命中再回源到MySQL,并回填缓存。预约成功后,主动删除对应场地日期的缓存,保证数据一致性。
5.2 接入独立的支付模块
现在的订单状态里有个“待支付”状态,我建议你接一个真实的支付沙箱(比如微信支付沙箱或支付宝沙箱)。支付回调后更新订单状态,顺便还能在简历上写“对接第三方支付接口”。这个点是毕设和校招项目里很亮眼的加分项。
5.3 改造为多场馆或连锁模式
如果你觉得单馆管理太单薄,可以把venue相关的字段抽成独立的场馆表,再把项目里所有硬编码的“海滨体育馆”字样替换成系统参数。这样一套系统就能跑多个场馆、多块场地,变身为“体育馆SAAS管理系统”。改造核心难点是机票数据库中的归属字段和业务逻辑里对场馆ID的过滤。
5.4 引入权限管理框架
当前系统的权限控制是“管理员和普通用户”两套角色,用的还是简单的HandlerInterceptor。如果你想往Spring Security或Sa-Token方向升级,我建议优先考虑Sa-Token,原因是它上手曲线比Spring Security低不少,B站和官方文档的中文资料对新手更友好。权限认证的重要性在简历面试中一直是被重点考察的点。
写在最后的几点心得
做了这套系统三版迭代之后,我最深的感触是:毕设项目真正的价值不在“技术多新、功能多全”,而在“业务逻辑是否自洽、代码是否经得起推敲”。体育馆预约系统看着很普通,但订单状态流转、并发冲突、数据统计这些点,每一个都值得认真打磨。如果你是自己动手照着源码重写,别急着复制粘贴,建议至少手敲一遍核心的Service层和Mapper XML,这样在答辩时被问到“用户同时抢一个时段怎么办”“分页失效怎么排查”这类问题,你张嘴就能答出思路。对我来说,当看到预约页面真正能跑起来、订单状态正确流转、后台统计图表有数据出来的那一刻,前面熬夜改Bug的所有辛苦都值了。
