1. 项目概述与核心需求拆解
1.1 为什么需要一套足球场地预约系统
先说个实际场景。我接过不少体育场馆类的管理系统,大多数场馆还在用"微信群报名+电话确认+Excel表格记录"的传统方式,管理员每天要反复核对哪个时段已被占用,用户也经常遇到时间冲突、到场没场地的情况。琪华体育中心的情况也类似,几片足球场平时对外出租,热门时段比如周末下午,经常因为多人同时口头预定导致场地撞车,管理成本高,用户体验也差。
所以这套基于Spring Boot + Vue的足球场地预约系统,核心要解决的就是三个问题:场地状态实时可见、预约流程线上化、订单管理自动化。用户打开网页就能看到今天哪些时段空闲、哪些已被预定,选定时间后直接下单;管理员在后台可以维护场地、审核订单、查看统计报表。整个流程不用再靠人肉协调,所有数据落库,有据可查。
1.2 技术选型:为什么是Spring Boot + Vue
这个组合在目前的企业级应用中非常主流。后端用Spring Boot,是因为它把Spring生态的配置简化到了极致,内嵌Tomcat,打成一个Jar包就能跑,配合Spring Data JPA或者MyBatis Plus操作数据库非常顺手,尤其适合做这类管理系统的RESTful API。前端用Vue,是因为它的渐进式设计对中小型项目非常友好,组件化开发让页面复用起来很轻松,配合Vue Router做页面跳转、Vuex或Pinia做全局状态管理,整套开发体验很顺畅。
更重要的是,前后端分离架构让分工变得清晰。后端只提供JSON接口,不关心页面长什么样;前端只负责渲染和交互,不关心数据存在哪个表里。部署的时候前端打包成静态文件扔到Nginx,后端单独起一个服务,互相通过HTTP通信,后期想扩展小程序端或者App端,直接复用现有API就行。
1.3 系统整体功能地图
在动手写代码之前,我习惯先把功能模块画清楚。这套预约系统大致分为以下几个核心模块:
- 用户端:注册登录、场地浏览、按日期/时段查询、在线预约、我的订单、取消预约
- 管理端:场地信息管理、场地类型维护、订单审核/确认、预约时段管理、用户管理、数据统计
- 通用能力:验证码、统一异常处理、跨域配置、定时任务清理过期订单
这里我多说一句,很多同学做毕设或者练习项目时一上来就写代码,写到后面发现表结构不合理、接口设计混乱,返工成本很高。建议先花半天时间把角色、功能、数据流转路径梳理清楚,后面能省下大量时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后端核心模块设计与关键实现
2.1 数据库表结构设计
预约系统的核心表我设计为五张:用户表、场地表、场地时段表、订单表、操作日志表。下面逐一说明设计思路。
用户表针对用户和管理员两种角色设计,用role字段区分。密码存储使用BCrypt加密,不能明文存放。常用的字段包括用户名、密码、手机号、真实姓名、角色、创建时间等。
场地表存储球场的基本信息,比如场地名称、位置、类型(五人制/七人制/标准十一人制)、每小时价格、状态(开放/维护中)、备注。这里要单独提一下,场地状态和管理状态最好分开,场地本身有一个"是否开放预约"的开关,这样遇到草坪维护时可以直接关闭预约,不需要去修改每一条时段数据。
场地时段表是核心表,需要重点设计。我用的是"日期 + 场地 + 时间段"三维组合的方式。每个时段代表某天某场地某个小时是否可预约,包含场地ID、日期、开始时间、结束时间、状态(空闲/已预约/锁定)、版本号。版本号字段是防止并发超卖用的,后面会详细说。
订单表记录用户的下单信息,包含订单编号、用户ID、场地ID、场地名称、预约日期、开始时间、结束时间、总金额、状态(待支付/已支付/已取消/已完成/已过期)、创建时间、支付时间。设计时要注意订单编号用时间戳加随机数生成,避免用户从订单号推测出订单数量。
操作日志表用于记录关键操作行为,包含操作人、操作类型、操作内容、操作时间、IP地址。主要便于排查纠纷,比如用户说自己提交了订单但管理员说没看到,查日志就能还原现场。
2.2 Spring Boot后端工程结构
工程结构我习惯按业务模块分包,而不是按技术层次分包。按技术层次分包会显得很规整,但业务扩展后找代码很麻烦。我推荐的结构是:
code复制com.qihua.sports
├── common // 公共类、统一返回、异常处理
├── config // 配置类
├── controller // 接口控制层
├── service // 业务逻辑层
├── mapper // 数据访问层
├── entity // 数据库实体
├── dto // 数据传输对象
├── vo // 视图对象
└── utils // 工具类
这里有个经验点,DTO和VO要分开。有的同学为了省事,直接拿实体类返回给前端,这种做法短期内没问题,但实体类中有密码、数据库字段等敏感信息,一旦忘记加@JsonIgnore就会出现数据泄露。另外前端需要的数据结构和数据库表结构往往不是一一对应的,比如前端需要显示一个"可用时段列表",后端如果直接暴露数据库记录,前端要做很多拼装工作。正确的做法是后端把数据组好装进VO再返回。
2.3 统一返回结果与异常处理
接口返回格式不统一,是前后端联调时最头疼的问题之一。有的接口返回成功时直接返回数据,失败时返回错误提示,前端每个请求都要单独判断,代码写得很冗长。我在项目里定义了一个统一的返回类:
java复制public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
}
同时配合全局异常处理器,业务层只需要抛出自定义的业务异常,由统一处理器负责转换返回格式。这样做的好处是前端只用判断code是否为200,其他情况统一弹错误消息,极大简化了联调成本。
2.4 预约核心接口的并发安全处理
这是整系统最关键的地方,必须单独拿出来说。预约场景有一个典型的并发问题:多个用户同时抢订同一个时段,如果直接用普通的查询再插入逻辑,很容易出现超卖。试想一下这样的代码:
java复制// 错误示例,存在并发问题
if (timeSlot.getStatus() == 0) {
timeSlot.setStatus(1);
timeSlotMapper.updateById(timeSlot);
orderMapper.insert(order);
}
两个请求同时查到时段状态是0,同时进入if内部,同时更新状态,同时插入订单,最终结果是只有一块场地却生成了两笔订单。
解决这个问题有几种方案。最简单可靠的是使用数据库的乐观锁。在时段表添加版本号字段,更新时带上版本号作为条件:
java复制// 正确示例,使用乐观锁
boolean success = updateSlotStatus(slotId, 0, currentVersion);
if (success) {
orderMapper.insert(order);
} else {
throw new BusinessException("该时段刚刚被预约,请重新选择");
}
对应的SQL是:
sql复制UPDATE time_slot SET status = 1, version = version + 1 WHERE id = #{slotId} AND status = 0 AND version = #{version}
受影响行数为1表示更新成功,为0说明时段已经被别人抢走。这里的细节是必须把status = 0也放在WHERE条件中,从数据库层面保证只有空闲时段才能被锁定。如果项目并发量更大,还可以用Redis分布式锁或者乐观锁配合数据库唯一索引的方式,但考虑到体育中心场地的规模,乐观锁已经完全够用。
2.5 JWT登录认证与权限控制
前后端分离项目的登录认证,最常用的是JWT方案。用户登录成功之后,后端生成一个包含用户信息的Token返回给前端,前端后续请求都在请求头中携带这个Token,后端通过过滤器校验Token并解析用户身份。
我实现这个功能时踩过一个坑,这里特意提醒一下:JWT的白名单问题。有的同学只做JWT签发和解析,没有做服务端主动失效的能力,导致用户修改密码后旧Token还能用,管理员封禁用户后该用户依然能继续操作。解决方法是引入Token黑名单机制,把注销的Token加入Redis并设置过期时间,校验时先查黑名单,存在则拒绝访问。
权限控制方面,我使用的是Spring Security配合自定义权限注解。管理员接口加上@PreAuthorize("hasAuthority('admin')"),普通用户接口只要求登录即可访问。这样在接口层面就可以做权限隔离,防止普通用户通过直接修改URL调用管理接口。
3. 前端页面与交互实现
3.1 Vue工程搭建与目录结构
前端我用Vue 3 + Vite构建。相比Vue 2时代的Webpack全家桶,Vite的开发服务器启动速度真的是质的飞跃,尤其在项目代码量比较大的时候,Webpack冷启动可能要十几秒甚至更久,Vite几乎是秒开。创建项目的命令很简单:
bash复制# 创建Vue 3项目
npm create vite@latest sports-frontend -- --template vue
# 进入项目目录并安装依赖
cd sports-frontend
npm install
在工程结构上,我按页面模块划分目录,同时把通用请求、路由守卫、状态管理单独抽离:
code复制src
├── api // 所有接口请求封装
├── assets // 静态资源
├── components // 通用组件
├── router // 路由配置
├── store // 全局状态管理
├── utils // 工具函数
└── views // 页面组件
├── admin // 管理端页面
└── user // 用户端页面
3.2 前端路由与权限拦截
路由设计考虑用户和管理员两套页面,通过路由守卫实现访问控制。在router/index.js中配置路由元信息,标记哪些页面需要登录、哪些需要管理员权限:
javascript复制const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: HomePage,
children: [
{ path: '', component: FieldList },
{ path: 'reserve', component: ReservePage, meta: { requiresAuth: true } },
{ path: 'my-orders', component: MyOrders, meta: { requiresAuth: true } }
]
},
{
path: '/admin',
component: AdminLayout,
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{ path: 'fields', component: FieldManage },
{ path: 'orders', component: OrderManage },
{ path: 'stats', component: StatDashboard }
]
},
// 登录页、注册页不需要守卫
{ path: '/login', component: LoginPage }
]
})
路由守卫的逻辑是:每次跳转前先从Store或localStorage获取Token,判断当前访问的路由是否需要登录,如果需要但没登录则跳转到登录页并携带重定向地址。管理员页面还要额外判断当前用户的角色。这里有一个细节推荐处理:不要只在前端做权限控制,后端接口必须有相同级别的权限校验,否则用户可以直接调用接口绕过页面限制。
3.3 场地预约页面的核心交互
预约页面是整个前端工程里交互最复杂的部分。用户首先选择日期,然后系统展示该日期所有场地的时段状态,用户点击某个空闲时段,右侧展示预约信息确认卡片,确认后下单。
页面布局我采用了左侧日期选择器、中间场地时段网格、右侧订单摘要的结构。时段网格按场地分区块展示,每个时段格子有三种状态:绿色空闲、红色已预约、灰色维护中。这些颜色状态由后端返回的status字段控制,前端通过计算属性映射CSS类名。
预约操作的核心逻辑是用户在点击"立即预约"按钮后进入确认弹窗,确认后调用后端接口创建订单。创建成功后引导用户去支付页面。这里需要注意一个交互上的细节:时段状态在用户浏览期间可能被其他用户抢订,所以提交时必须再把时段状态传给后端,由后端做最终校验。同时前端在提交时要有loading状态,防止用户重复点击导致重复提交。
3.4 前端接口封装与状态管理
接口请求我统一封装在utils/request.js中,基于axios实现。主要做三件事情:设置基础URL、请求拦截器统一携带Token、响应拦截器统一处理错误码。
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:附加token
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一处理业务错误
request.interceptors.response.use(
response => {
const res = response.data
if (res.code === 200) {
return res.data
}
ElMessage.error(res.message)
return Promise.reject(new Error(res.message))
},
error => {
if (error.response && error.response.status === 401) {
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
router.push('/login')
}
return Promise.reject(error)
}
)
export default request
全局状态管理我用的Pinia。相比Vuex,Pinia的API更简洁,没有mutations概念,直接在store中定义state、getters、actions即可。我的设计中把用户信息存储在Pinia中,刷新页面后通过用户信息接口重新获取,避免刷新后状态丢失的问题。
3.5 Vue项目打包与部署
前端开发完成后使用npm run build进行打包,生成dist目录。构建过程有个细节需要注意:如果项目中引用了较大的第三方库,可以通过Vite的构建配置拆包,避免把所有JS打进一个文件导致首屏加载太慢。我在vite.config.js中做了如下配置:
javascript复制import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue-vendor': ['vue', 'vue-router', 'pinia']
}
}
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
开发环境中配置的proxy代理,解决了前后端分离下的跨域问题。生产环境我把dist目录部署到Nginx,由Nginx统一反向代理到后端服务。网上有些人说跨域可以用CORS解决,但生产环境更稳妥的做法是用Nginx配置反向代理,让前端请求和后端接口看起来同源,从根源上消除跨域问题。
4. 预约核心流程的完整实现
4.1 从场地查询到订单生成的完整链路
一个完整的预约流程涉及多个接口的协同工作。我这里把时序逻辑梳理出来:
第一步,用户选择日期后,前端调用查询空闲时段接口,后端根据日期从场地时段表中查询当天所有场地的所有时段,返回给前端渲染。
第二步,用户点击某个空闲时段,确认预约,前端调用创建订单接口,传入场地ID、日期、开始时间和结束时间。后端先校验用户登录状态,再校验时段是否空闲,校验通过则锁定时段并创建订单,订单状态为待支付。
第三步,用户支付成功后,前端调用支付回调接口,后端将订单状态更新为已支付。预约正式生效。
第四步,管理员在后台查看订单列表,对于需要人工确认的订单手动点击确认,可选通过短信或邮件通知用户预约成功。
这里的支付环节,体育中心这类系统通常对接微信或支付宝的扫码支付,需要用到第三方支付接口。如果是个人练习项目,不需要真实对接支付平台,可以把支付按钮做成"模拟支付",直接在后端把订单状态置为已支付。做毕设时这个模拟逻辑是可以接受的,但要在文档中说明实际生产环境需要接入真实支付网关。
4.2 时段数据生成与预初始化
场地时段的数据不需要手工逐条插入,我写了一个定时任务,每天凌晨自动生成未来7天的所有场地时段。代码如下:
java复制@Component
public class TimeSlotGenerateTask {
@Scheduled(cron = "0 0 1 * * ?")
public void generateTimeSlots() {
List<Field> fields = fieldMapper.selectAll();
LocalDate today = LocalDate.now();
for (int i = 1; i <= 7; i++) {
LocalDate date = today.plusDays(i);
// 每天9点到22点,每整点一个时段
for (LocalTime start = LocalTime.of(9, 0);
start.isBefore(LocalTime.of(22, 0));
start = start.plusHours(1)) {
for (Field field : fields) {
TimeSlot slot = new TimeSlot();
slot.setFieldId(field.getId());
slot.setDate(date);
slot.setStartTime(start);
slot.setEndTime(start.plusHours(1));
slot.setStatus(0);
slot.setVersion(0);
timeSlotMapper.insert(slot);
}
}
}
}
}
这里要注意Spring Boot启动类上要加@EnableScheduling注解,定时任务才能生效。另外,如果需要支持更灵活的营业时间配置,比如周五晚上营业到23点,可以在场地表中添加营业开始时间、结束时间、可提前预约天数等字段,定时任务读取这些配置动态生成时段。
4.3 订单状态机管理
订单状态是一个典型的状态机,状态流转必须严格限制,防止用户通过特殊操作把订单带入非法状态。我在项目中定义了如下状态流转规则:
- 待支付:用户创建订单后的初始状态,可以取消,可以支付
- 已支付:支付成功后的状态,可以申请退款,可以被管理员确认
- 已取消:用户主动取消,或超时未支付系统自动取消
- 已完成:场地使用结束后的终态
- 已退款:生成已支付订单退款后的终态
- 已过期:订单超过预约日期仍未使用的状态
状态变更必须调用统一的方法,不能随手写update语句。我在OrderService中定义了一个状态流转方法,内部用switch判断当前状态和目标状态是否合法,非法则抛出异常。同时配合一张订单状态变更记录表,记录每次变更的前后状态、操作人和时间,方便审计追溯。
4.4 过期订单自动处理
用户创建订单后如果不支付,时段就一直是"锁定"状态,其他用户无法预约,会造成资源浪费。我写了另一个定时任务:每5分钟扫描一次待支付订单,将创建时间超过15分钟仍未支付的订单自动取消,同时把对应的时段状态恢复为空闲。
java复制@Component
public class OrderTimeoutTask {
@Scheduled(fixedRate = 300000)
public void cancelTimeoutOrders() {
LocalDateTime deadline = LocalDateTime.now().minusMinutes(15);
List<Order> timeoutOrders = orderMapper.selectTimeoutOrders(deadline);
for (Order order : timeoutOrders) {
// 取消订单并释放时段
orderService.cancelOrderBySystem(order.getId());
}
}
}
写这个定时任务时有几个细节值得注意:第一,必须使用乐观锁或事务防止与用户手动取消操作并发冲突;第二,订单取消后要释放时段,两个操作需要在一个事务中完成;第三,定时任务的执行频率不宜太高,5分钟一次足够,避免对数据库造成不必要的压力。
5. 系统部署与实践经验总结
5.1 开发环境搭建与常见配置
我使用的开发环境是JDK 8 + Spring Boot 2.x,前端Node.js 16以上。Spring Boot的版本选择上建议稳定优先,不要盲目追求最新版,有些新版本对JDK版本有硬性要求,换了环境容易出问题。很多同学做完项目换一台电脑就跑不起来了,绝大多数原因是版本不一致。
数据库我使用MySQL 8.0。在配置数据库连接时,一个容易踩坑的细节是时区设置。JDBC连接串需要加上serverTimezone=Asia/Shanghai,否则本地时间比数据库时间差8小时,订单时间显示错乱。字符集也需要指定为utf8mb4,支持中文和表情符号。
Redis在项目中承担了多个职责:验证码缓存、Token黑名单、时段状态缓存。连接配置集中在application.yml中管理。实际使用中发现,Redis的序列化器一定要配置好,否则存进去的是乱码或者反序列化时报错。我用的是GenericJackson2JsonRedisSerializer,比较稳。
5.2 部署流程梳理
部署流程概括为三步:后端打包、前端构建、Nginx反向代理配置。后端使用Maven打包成Jar包后通过java -jar命令启动,推荐使用systemd服务管理,实现开机自启和崩溃自动重启。
Nginx配置中核心的两段配置如下:
nginx复制# 前端静态资源
server {
listen 80;
server_name sports.example.com;
# 前端页面
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# 反向代理后端接口
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
这里需要注意proxy_pass http://127.0.0.1:8080/;末尾的斜杠,它会把请求路径中的/api前缀去掉后转发给后端。如果忘记斜杠,后端接收到的路径会变成/api/user/login,而Controller中定义的是/user/login,就会出现404。
Vue Router如果使用history模式,Nginx必须配置try_files,否则刷新页面时Nginx找不到对应的文件路径,会直接返回404。如果不想配置try_files,可以改用hash模式,但URL中带#号,美观度差一些。我建议直接配好try_files使用history模式。
5.3 系统性能与安全问题
这套系统在正式上线前,建议从几个方面做检查。性能方面,场地时段表的数据量会随着天数和场地数量增长,如果每片场地每天有10个时段,30片场地30天就是9000条记录,查询压力不算大,但要给时间字段建立联合索引。高峰期多个用户可以同时查询,组件之间没有复杂的计算,后端接口响应时间控制在200毫秒以内问题不大。
安全方面最容易被忽视的是越权问题。我在测试阶段专门写了一个脚本,逐个尝试用普通用户的Token调用管理员接口,果然发现两处接口没有校验用户角色,被我一一定位修复。预约系统的用户数据不涉及高敏感性内容,但手机号属于个人隐私,接口返回值中涉及个人信息的功能要提醒前端做脱敏处理,比如只显示手机号前三位和后四位。
5.4 我在实际开发中踩过的坑
整个项目做下来,踩过不少坑,挑几个典型的分享出来,这些坑常规教程里一般不会写。
第一个坑是时区问题。项目一开始没有在数据库连接串配置serverTimezone,运行时发现前端显示的预约时间和实际时间差8个小时。排查了很长时间才确认是时区问题。如果项目部署在云服务器上,并且设置了UTC时区,这个问题更容易出现。后来的解决方法是统一使用亚洲上海时区,数据库连接串也显式指定时区。
第二个坑是乐观锁的字段丢失。使用MyBatis Plus的乐观锁插件时,更新操作会自动带上版本号条件,但前提是实体类中的@Version注解必须正确标注,而且更新时前端必须把版本号一起传回来。有一次前端没有传递版本号,导致所有更新操作都失败。排查了好久才发现是这个原因。
第三个坑是跨域问题在联调阶段反复出现。开发时前端运行在8080端口,后端运行在8081端口,前端请求后端接口必然跨域。解决方法是配置了Vite的代理。但有个细节,代理配置改了之后需要重启前端开发服务器才能生效,Vite的配置热更新并不包含代理配置。
第四个坑是部署到Nginx时资源路径问题。前端项目默认的base路径是根目录,但我最初想把前端部署在Nginx的/sports/子路径下,资源加载一直报404。后来修改了Vite的base配置为'/sports/'才解决。如果项目部署在域名根路径,则不需要配置。
5.5 项目后续可扩展的方向
这套系统完成之后,我给它规划了几个扩展方向。第一,增加会员模块,支持会员等级、积分和折扣,提高用户粘性。第二,增加团体预约功能,足球运动通常是多人组队,支持一个账号为多人预约不同类型的场地。第三,对接真实支付网关,实现微信、支付宝在线支付。第四,增加数据可视化大屏,在大厅屏幕或管理后台展示当日预约情况、场地使用率、营业额等关键数据。第五,考虑开发小程序端,微信小程序的使用场景更适合场馆预约这种轻量级操作。
最后再说一点个人体会。做管理系统类项目,技术上其实不算特别难,真正的难点在于把业务逻辑想清楚,把边界情况处理完整。比如预约超时释放、并发抢订、状态流转限制,这些细节才是一个系统能不能真正落地使用的关键。如果你也在做类似的预约系统,建议在动手前把这些问题全部在纸上想清楚,实现起来就会顺很多。
