做体育馆预约系统这个项目,我前后折腾了大概三周时间,从最开始的需求梳理到最终部署上线,踩了不少坑,也积累了一些还算实用的经验。正好最近有不少人问起这套基于 SpringBoot + Vue3 + MyBatis 的前后端分离方案,我就把整个实现过程、核心设计思路和实际遇到的问题整理成这篇总结,希望能给正在做类似管理系统的朋友一些参考。
这类预约系统本身不算复杂,但麻雀虽小五脏俱全,涉及到用户认证、场地管理、预约时间冲突检测、后台管理等典型业务模块。而且体育馆预约还有一个比较特殊的点:场地资源是有限的,同一个时间段不能被重复预约,这就需要在业务逻辑层面做好并发控制。除此之外,前后端分离架构下的接口设计、跨域处理、权限控制,以及 MyBatis 多表关联查询这些细节,都是实际开发中绕不开的坎。
这篇文章适合谁看?如果你正在学习 Java 全栈开发,或者毕业设计、个人项目想做一个类似的预约平台,又或者公司内部需要一套简单的场地预订管理系统,那这篇文章的内容应该会对你有直接帮助。
1. 项目全貌与技术选型思路
1.1 核心需求拆解
开始写代码之前,先把需求理清楚。体育馆预约平台,最核心的诉求就三个:用户能查场地、能订场地、管理员能管场地。围绕这三个核心点,再延伸出用户注册登录、场地分类管理、预约订单管理、个人预约记录等功能。
我把系统拆成了两个端:用户端和管理端。用户端面向普通使用者,功能包括注册登录、浏览场地列表、查看场地详情、发起预约、查看自己的预约记录、取消预约。管理端面向场馆运营人员,功能包括场地信息维护、预约订单审核或管理、用户管理、数据统计(比如场地使用率)。
这里有一个容易被忽视的需求:场地是有类型的,比如篮球场、羽毛球场、乒乓球室。不同类型的场地计费方式和时间规则可能不一样,所以场地表需要设计类型字段,并且预约逻辑要能适配不同场地的规则。我最初设计时把场地类型硬编码在代码里,后来发现每加一种场地类型就要改代码、重新部署,非常被动。改造后把场地类型独立成一张字典表,新增类型只需要在后台维护数据,代码完全不用动。
1.2 为什么选这套技术栈
后端选 SpringBoot 是因为它确实是 Java 领域最适合快速搭建业务系统的框架,内嵌 Tomcat、自动配置、起步依赖这些特性让开发效率提升很明显。MyBatis 作为持久层框架,SQL 由自己掌控,面对预约系统这种关联查询比较多的业务场景很灵活。前端选 Vue3 是因为组合式 API 和响应式系统让组件复用和状态管理比 Vue2 时代顺手太多,而且现在前端生态基本都已经切换到 Vue3,相关组件库的兼容性也成熟了。
前后端分离架构在这个项目里的收益很直接:用户端和管理端虽然功能不同,但可以共用一套后端接口;前端团队和后端团队可以并行开发;后期如果要出小程序端或者移动端,后端接口可以直接复用,只需要开发新的前端展示层就行了。
MySQL 作为数据库存储核心业务数据,选它主要是看重事务能力和稳定性。预约操作涉及创建订单、锁定场地时间等步骤,必须保证原子性,MySQL 的 InnoDB 引擎在这方面表现可靠。
提示:如果你的项目预约量极大,比如大型体育场馆每天几千单,那后续可能需要引入 Redis 做分布式锁和缓存。但对大多数场景来说,MySQL 配合正确的事务和锁机制已经足够,过早引入分布式组件反而增加复杂度。
2. 数据库设计与核心表结构
2.1 设计原则:先理清业务关系
数据库设计这一步决定后面写代码的顺畅程度,我建议动手建表之前,先把实体关系图画清楚。这个系统里的核心实体包括:用户、场地、场地类型、预约订单。关系很简单:一个用户可以有多条预约记录,一个场地可以被多条预约记录引用,预约记录关联到具体场次和时间段。
在设计时我遵循了几个原则:公共字段统一(创建时间、更新时间、逻辑删除标记),金额用 decimal 不用 float,状态字段用 tinyint 并写清楚每个值的含义,所有表的主键用自增 id 保持简单。
2.2 用户表
用户表承载认证和基础信息。我保留了用户名、密码、手机号、角色等字段,角色字段我用的是role,用 0 表示普通用户、1 表示管理员。密码存的是 BCrypt 加密后的密文,这一点尤其重要,千万不要明文存储密码。
CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `role` tinyint NOT NULL DEFAULT '0' COMMENT '角色 0-普通用户 1-管理员', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态 1-正常 0-禁用', `create_time` datetime NOT NULL COMMENT '创建时间', `update_time` datetime NOT NULL COMMENT '更新时间', `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除 1-已删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';用逻辑删除字段deleted是我个人的习惯,业务数据不要物理删除,后期排查问题、统计数据都方便得多。查询的时候需要留意,几乎所有业务查询都要带上deleted = 0条件,否则会查出脏数据。
2.3 场地表与场地类型表
场地表我命名为venue。字段除了名称、位置、描述这些基础信息外,比较关键的是type_id(关联场地类型表)、price(每小时价格)、status(场地状态)。场地状态我做了两个层面的区分:一个是场地本身的启用状态(比如维修中应该不能预约),另一个是场地是否被占用,这个是动态的、要在预约逻辑里去判断,不适合放在场地表里。
场地类型表venue_type就简单了,就是类型名称和描述。为什么单独拆表?因为在真实运营中,场馆会不断调整可以预约的场地类型,拆出来之后管理端增加类型很方便,不需要动代码。
CREATE TABLE `venue` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '场地名称', `type_id` int NOT NULL COMMENT '场地类型id', `location` varchar(200) DEFAULT NULL COMMENT '位置描述', `description` text COMMENT '场地介绍', `price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '每小时价格', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态 1-启用 0-停用', `create_time` datetime NOT NULL COMMENT '创建时间', `update_time` datetime NOT NULL COMMENT '更新时间', `deleted` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_type_id` (`type_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地表';price字段用 decimal(10,2),因为涉及金额计算,float 的精度问题在这种场景下会带来麻烦,这属于不该省的细节。
2.4 预约订单表
预约订单表是整个系统的业务核心,设计上要重点考虑时间冲突检测和状态流转。
CREATE TABLE `reservation` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` int NOT NULL COMMENT '预约用户id', `venue_id` int NOT NULL COMMENT '场地id', `reserve_date` date NOT NULL COMMENT '预约日期', `start_time` time NOT NULL COMMENT '开始时间', `end_time` time NOT NULL COMMENT '结束时间', `total_price` decimal(10,2) NOT NULL COMMENT '总金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态 0-待确认 1-已确认 2-已取消 3-已完成', `remark` varchar(500) DEFAULT NULL COMMENT '备注', `create_time` datetime NOT NULL COMMENT '创建时间', `update_time` datetime NOT NULL COMMENT '更新时间', `deleted` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_venue_date_time` (`venue_id`, `reserve_date`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约订单表';订单编号order_no我建议自己生成,格式可以是"日期+时间戳+随机数",不要依赖数据库自增 id 对外暴露。因为订单号会展示给用户,用自增 id 很容易让人家猜到你的业务量,而且一旦做分库分表自增 id 还会有冲突问题,提前用自定义订单号,后续扩展省心很多。
联合索引idx_venue_date_time是我在实践中最满意的一个设计。因为预约查询最频繁的 WHERE 条件就是查某个场地在某天某个时间段是否空闲,这个索引能大幅加快冲突检测的查询速度。刚起步数据量小可能感觉不明显,但数据积累到几万条之后,效果差异就有了。
2.5 索引设计的经验之谈
索引不是越多越好,这一点我深有体会。前期开发时为了省事,所有查询条件的字段都加了索引,结果数据量小的时候看不出问题,一旦数据量上来,每次插入都要维护多个索引,写性能明显下降。索引设计的核心原则是:用 EXPLAIN 看实际查询的执行计划,让索引为实际业务查询服务,而不是为所有可能的查询服务。
3. 后端接口设计与核心业务实现
3.1 工程分层与包结构
后端这块我用的是标准的分层架构:Controller 层负责接收请求和参数校验、Service 层负责业务逻辑、Mapper 层负责数据库操作。另外抽了一层 DTO 和 VO,DTO 用于接收前端传来的参数,VO 用于返回前端展示的数据。一开始图省事直接用实体类接收参数、返回实体类,结果接口给前端的数据不是多了字段就是少了字段,最后还是要加 DTO/VO,反而折腾了两遍。
包结构大致是这样:
com.example.gym ├── controller │ ├── AuthController.java │ ├── VenueController.java │ └── ReservationController.java ├── service │ ├── UserService.java │ ├── VenueService.java │ └── ReservationService.java ├── mapper │ ├── UserMapper.java │ ├── VenueMapper.java │ └── ReservationMapper.java ├── entity │ ├── User.java │ ├── Venue.java │ └── Reservation.java ├── dto ├── vo ├── common │ ├── Result.java │ ├── ResultCode.java │ └── GlobalExceptionHandler.java └── config ├── WebConfig.java └── SecurityConfig.java统一的返回结果Result类是前后端协作的基石。我定义的返回结构包括code、message和data三个字段,code 为 200 表示成功,其他为业务错误码。这个结构虽然简单,但保证前后端沟通时的协议一致性非常关键。
3.2 认证与权限控制的实现方案
认证方案我没有引入 Spring Security 全家桶,对这类中小型项目来说,JWT + 拦截器是更轻量、更直观的解法。用户登录成功后,后端签发一个 JWT 令牌,前端后续请求在请求头里带上Authorization: Bearer <token>,后端通过拦截器解析令牌识别用户身份。
JWT 的结构分三部分:Header、Payload、Signature。Payload 里我放了用户 id、用户名、角色这些非敏感信息。密钥放在配置文件里,实际项目一定要用足够强度的密钥,并且不要在代码里硬编码。
拦截器校验的逻辑比较直接:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口等白名单路径 String path = request.getRequestURI(); if (path.contains("/api/auth/login") || path.contains("/api/auth/register")) { return true; } // 从请求头获取token并校验 String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { // 解析失败,返回401 } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }拦截器里放行路径用白名单方式管理,比如登录、注册、场地列表查询这些公开接口。管理端的接口,比如新增场地、修改场地,还需要校验角色是否为管理员,这一步我在 Service 层里通过工具方法获取当前登录用户信息再判断角色。
3.3 预约冲突检测的核心逻辑
预约系统的灵魂就是冲突检测。判断逻辑其实不复杂:查一下同一个场地在同一个日期下,有没有时间段和当前预约时间段重叠的记录,如果存在且状态不是取消和已过期,就判定为冲突。
SQL 写法我调了几版。最初我写过很复杂的时间段比较 SQL,后来发现最干净的做法是这样:
<select id="selectConflictCount" resultType="int"> SELECT COUNT(*) FROM reservation WHERE venue_id = #{venueId} AND reserve_date = #{reserveDate} AND status IN (0, 1, 3) AND deleted = 0 AND start_time < #{endTime} AND end_time > #{startTime} </select>这个 SQL 的思路是:两条预约如果时间段有重叠,必然满足"新预约的开始时间小于已存在的结束时间,且新预约的结束时间大于已存在的开始时间"。这个判断比我一开始用的start_time BETWEEN那种写法要严谨,因为BETWEEN只判断了单边,漏掉了"已存在的预约完全包含新预约"这种情况。这里用严格小于和严格大于,边界情况比如一段预约的结束时间恰好等于另一段的开始时间,按业务规则这种不算冲突,是正确的。
除了业务层面的冲突检测,数据库层面我还要说几句。如果你对并发要求比较高,比如高峰期很多人同时抢同一个场地,光靠业务层先查询再插入是有问题的,并发情况下可能两个请求都查到"没有冲突",然后都插入成功,就产生数据脏读。解决办法是用数据库锁:在插入前对场地记录加锁,或者用唯一约束辅助。对体育馆预约来说,最直接可靠的是在插入预约记录时使用SELECT ... FOR UPDATE锁住场地记录,保证同一时间只有一个事务在操作同一场地的预约。
但FOR UPDATE要谨慎用,锁的粒度控制不好容易导致死锁。我的做法是:锁的获取顺序必须统一,所有事务都先锁定场地表记录,再插入预约记录,这样就能避免死锁问题的发生。
3.4 MyBatis 多表关联查询的实践
预约列表页面通常要展示场地名称、场地类型、用户信息等关联数据,这就涉及多表查询。MyBatis 里有两种思路:一种是JOIN查出所有字段然后手动映射到 VO,另一种是分步查询。我项目里用的是 JOIN 方案,因为数据量不大,一次查出来效率更高,代码也更简洁。
<select id="selectReservationDetailList" resultType="com.example.gym.vo.ReservationVO"> SELECT r.id, r.order_no, r.reserve_date, r.start_time, r.end_time, r.total_price, r.status, v.name AS venue_name, vt.name AS venue_type_name, u.username AS user_name FROM reservation r LEFT JOIN venue v ON r.venue_id = v.id LEFT JOIN venue_type vt ON v.type_id = vt.id LEFT JOIN user u ON r.user_id = u.id WHERE r.deleted = 0 ORDER BY r.create_time DESC </select>这里用LEFT JOIN而不是INNER JOIN,是因为某些关联数据可能被逻辑删除或者不存在,但仍然要把预约记录展示出来。resultType直接映射到 VO,VO 里的字段加了别名之后与查询列名称对应,省掉了写resultMap的繁琐。不过要注意,如果查询列多、字段映射关系复杂,写resultMap更清晰,这个要看实际情况权衡。
4. 前端 Vue3 的实现要点
4.1 项目结构与关键依赖
前端我用 Vite 构建工具创建 Vue3 项目,状态管理用的 Pinia,路由用的 Vue Router,UI 组件库用的 Element Plus,HTTP 请求用的 Axios。这套组合是目前 Vue3 生态比较主流的搭配,资料多,遇到问题好查。
项目结构大致如下:
src/ ├── api/ // 接口请求封装 │ ├── auth.js │ ├── venue.js │ └── reservation.js ├── assets/ ├── components/ │ └── VenueCalendar.vue // 场地日历组件 ├── router/ │ └── index.js ├── stores/ │ ├── user.js // 用户状态 │ └── venue.js // 场地状态 ├── views/ │ ├── Login.vue │ ├── Register.vue │ ├── venue/ │ │ ├── VenueList.vue │ │ └── VenueDetail.vue │ ├── reservation/ │ │ ├── ReservationList.vue │ │ └── ReservationCreate.vue │ └── admin/ │ ├── AdminVenue.vue │ └── AdminReservation.vue └── utils/ └── request.js // Axios 实例封装request.js里做 Axios 封装是值得用心的地方。我在里面配置了基础 URL、超时时间、请求拦截器(加 token)、响应拦截器(统一处理业务错误码和 HTTP 401 状态)。
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 = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )统一错误处理放到拦截器里,业务代码就不用到处写try catch和ElMessage.error了,代码干净很多。
4.2 预约时间选择组件的设计
场地预约页面最核心的交互是选日期、选时间段。我封装了一个VenueCalendar组件,展示 7 天内的场地空闲时段。这个组件要做的事是:接收场地 id,根据所选日期从后端获取该场地当天已经被预约的时间段列表,然后渲染时间格子,已预约的格子置灰不可选。
后端返回已预约时间段列表的接口是这样的:
@GetMapping("/reserved") public Result getReservedTimes(@RequestParam Long venueId, @RequestParam String date) { return success(reservationService.getReservedTimes(venueId, date)); }前端拿到时间段列表后,生成一天的可用时间格子。假设场地开放时间是 9:00 到 21:00,按每小时一个格子来分,就有 12 个格子。遍历书时间段,把与预约时间重叠的格子标记为不可用。这里要注意时间比较的操作,JavaScript 中字符串格式的时间不能直接比较,要先转成Date对象或者统一转成当天的时间戳。
用户选择一个开始时间格子后,系统默认预约一小时。如果想让用户自己选择时长,就需要做连续多选或者下拉选择时长,然后计算结束时间,同时要把跨整点的场景考虑进去。我做的时候发现这个逻辑不难,但边界情况比较多,比如选择了 17:30 开始、时长 2 小时,结束时间是 19:30,而预约记录里的时间段不是整点,后面的冲突检测 SQL 是支持任意时间段的,这个没问题。
4.3 路由守卫与权限控制
前端也要做路由级别的权限控制。用户在未登录状态下直接访问预约页面,应该被重定向到登录页。我用 Vue Router 的全局前置守卫实现:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.meta.role && to.meta.role !== 'admin') { const user = JSON.parse(localStorage.getItem('userInfo') || '{}') if (user.role !== 1) { next('/') return } } next() })路由守卫只是一个前端交互层面的限制,真正防止越权还要靠后端接口的角色校验,这一点要时刻记住。前端守卫影响体验,后端校验保证安全,两层各司其职。
4.4 跨域问题的环境配置
前后端分离开发时,跨域是最基础但也最容易卡住新手的问题。我提供两种方案,二选一即可。第一种是后端全局配置跨域,在 SpringBoot 中写一个配置类实现WebMvcConfigurer的addCorsMappings方法,允许所有来源和方法。第二种更推荐:前端用 Vite 的代理功能,在vite.config.js中配置:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }生产环境再把前端的/api请求通过 Nginx 反向代理到后端服务,这样前后端用的是同源地址,省去跨域处理,也更安全。开发环境用 Vite 代理、生产环境用 Nginx 代理,全程不需要在后端单独配置跨域,是我比较推荐的做法。
5. 数据库设计、并发控制与部署上线
5.1 数据库初始化与种子数据
数据库表结构我用一个init.sql脚本统一管理,里面除了建表语句,还会插入基础数据:管理员账号(密码用 BCrypt 加密)、场地类型数据、几块演示场地。这看起来是个小细节,实际作用很大。团队成员拉下来代码后,第一步执行脚本就能跑起来完整环境,不用一个个手工插入数据。脚本一定要用 Git 管理,每次表结构变更都同步维护,这样能保证所有环境的表结构一致。我在这个项目上吃过亏,开发环境改了一个字段,测试环境没同步,联调时查了半天才发现是表结构不一致。
5.2 模拟高并发预约场景的测试
我在本地用 JMeter 模拟了 100 个并发请求同时预约同一场地同一个时段,目的是验证冲突检测是否可靠。第一版代码没有加数据库锁,果然出现了重复预约成功的情况。加了SELECT ... FOR UPDATE之后重新测试,就没有这个问题了。这个测试过程很值得做,因为直觉上觉得逻辑没问题,但真正并发场景下经常暴露问题。
测试时需要注意,本地机器模拟并发的效果有限。有条件的话在服务器上做压测更准确。我在本地测的时候发现 MySQL 的并发连接数默认是 151,100 个并发请求有些会被排队,这是正常现象,不代表系统有问题。
5.3 打包与部署要点
后端打包用 Maven 的package命令,生成 jar 包后,用java -jar启动。我在部署时用了一个application-prod.yml配置文件,里面设置生产环境的数据库连接串、JWT 密钥和日志级别。
spring: datasource: url: jdbc:mysql://localhost:3306/gym_reservation?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: xxx driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai jwt: secret: your-strong-secret-key-here expire-hours: 24我踩过一个时区的坑。服务器上配置数据库连接时如果serverTimezone没设置或者设置了跟业务时区不一致的时间,从数据库查出来的时间可能会差 8 个小时。排查半天发现是时区配置问题,所以建议这里明确用Asia/Shanghai,不要依赖系统默认时区。
前端打包就是npm run build,产物是dist目录下的静态文件,部署到 Nginx 就完事。Nginx 配置需要做几件事:托管静态文件、把/api开头的请求反向代理到后端 8080 端口、配置 history 路由模式的 fallback(不然刷新页面会 404)。
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { 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; } }5.4 常见问题与排查思路
我把自己做这个系统时遇到的典型问题整理了一下,方便你参考。
| 问题现象 | 可能原因 | 排查方式 |
|---|---|---|
| 前端请求接口一直 404 | Vite 代理配置错误,或者路径拼接不对 | 先在浏览器直接访问后端接口确认后端通,再检查代理路径和请求路径 |
| 查询出来的时间与数据库实际存储相差 8 小时 | 数据库连接串缺少 serverTimezone 参数 | 统一在 JDBC URL 中指定 Asia/Shanghai,并检查应用服务时区 |
| 登录后请求接口返回 401 | token 未正确传递或者过期 | 检查请求拦截器是否在 headers 中携带了 Authorization |
| 并发预约同一时间段都成功 | 业务层查询后插入没有加锁 | 使用SELECT ... FOR UPDATE锁住场地记录后再插入预约 |
| 前端刷新页面 404 | Nginx 未配置 history 模式 fallback | 增加try_files $uri $uri/ /index.html |
| 数据库中文乱码 | 表或连接字符集不是 utf8mb4 | 建库时指定 utf8mb4,并在 JDBC URL 中加入 characterEncoding=utf8 |
| Element Plus 组件样式不生效 | 组件库版本跟 Vue 版本不匹配 | 检查 package.json 中 vue 和 element-plus 的版本兼容性 |
5.5 上线前需要检查的安全清单
功能做完只是第一步,上线前我按这个清单过了一遍:密码是否全部 BCrypt 加密存储;JWT 密钥强度是否足够;管理端鉴权是否在每个写操作都校验了角色;SQL 是否都用了预编译参数,没有字符串拼接;敏感配置如数据库密码是否写入了外部配置文件而不是代码仓库;日志中是否可能输出 token 或密码信息。这些点不复杂,但漏掉任何一个都可能在生产环境出安全问题。
6. 从做项目的角度聊几句经验
这套系统从零到上线完整走了一遍之后,我最大的感受是:预约类项目业务模型看似简单,实际做起来最难的不是某个技术点,而是时间逻辑的严谨性和并发场景的可靠性。时间冲突检测需要考虑边界情况,数据库锁需要理解事务原理,前端时间组件需要处理格式化转换,每一环单独拿出来都不难,串起来就很容易在细节上翻车。
我自己做完这个项目后,继续扩展了几个顺手的功能点:增加邮件通知预约结果、场地使用率统计报表、日历视图的月模式展示。这些扩展再次证明了一件事,当初表结构设计时预留的冗余字段和合理的关联关系,让后续迭代轻松了很多。如果你也准备做类似的系统,我建议动手前在表设计上多花点时间,这个投入的性价比是最高的。