ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue健身房管理系统:从数据库设计到预约并发控制

SpringBoot+Vue健身房管理系统:从数据库设计到预约并发控制 要做一套健身房管理系统第一反应往往是“不就一张会员表的事”。真正动手才发现会员卡、私教课、团体课、预约排课、续费、体测记录这些业务全堆在一块的时候乱的不是代码而是数据关系。我做的这套基于SpringBootVue的健身房管理系统前后端分离Java负责后端接口MySQL负责存储MyBatis作为持久层框架正好把这类业务里最麻烦的部分理清楚。这套系统的核心价值不在于界面多炫而在于三个字可复用。健身房前台办卡、教练排课、会员约课、上课签到、订单统计这些流程在不同的场馆里几乎长得一模一样。你只要把这套骨架搭好换掉logo、改掉定价规则、加上场馆自己的字段就能快速复制到下一家。所以这篇内容适合两类人看一类是在校生拿它当毕业设计想搞清楚SpringBoot和Vue到底是怎么协作的另一类是刚工作不久的Java开发想参考一个完整业务链路里数据库怎么设计、接口怎么组织、常见的坑怎么避开。下面我按自己实际做项目的顺序来拆不绕弯子。1. 项目整体设计与技术选型思路1.1 为什么偏偏是SpringBootVue如果只看“管理数据”这件事用老一点的JSPServlet也能做用PHP也能做甚至用Excel手工登记也能撑一阵子。但系统一旦要同时服务前台、教练、会员三个角色还要处理预约和订单就必须考虑工程化开发效率。SpringBoot赢在起步成本低。它把Tomcat内嵌进来配置项收敛到application.yml一个文件里不需要像传统SSM那样折腾一堆XML。加上自带健康检查、参数校验、事务管理等开箱即用的能力对中小型业务系统来说非常合适。Vue这边则解决了页面交互的复杂度会员查课程、选教练、看自己的预约记录这些操作如果用传统模板渲染刷新一下整页都在转圈体验很差。有人会问为什么不选更轻的FastAPI或者更重的微服务健身房这种体量的系统用户并发一般也就几十到几百单体应用完全够用。FastAPI对前端数据格式支持很好但Java生态里成熟的权限框架、报表工具、运维资料更多招人也好招。微服务是给多团队、大并发场景准备的拿到这个项目里只会把问题复杂化。我用个不太恰当但好懂的比喻SpringBoot像毛坯房墙、水电、门窗都给你拉好了线你只需要隔房间、贴瓷砖。Vue像一组成品家具哪里要桌子、哪里要柜子拼上去就行。MyBatis则是那根最关键的下水管数据怎么从MySQL流到Java对象里全看它接得顺不顺。1.2 健身房的业务边界到底在哪系统设计前我习惯把业务边界画清楚不然做出来的东西要么不够用要么过度设计。普通健身房的日常运营核心逃不出这七件事会员管理办卡、续费、会员资料维护、会员卡状态冻结/恢复。教练管理教练资料、擅长课程、排课时间。课程管理团体课、私教课的分类、时间、人数上限、价格。预约与签到会员选课预约、到场核销、取消预约。订单与支付办卡订单、私教课订单、支付状态记录。数据统计营业额、会员增长、课程预约率、教练上课量。权限控制前台、教练、会员三个角色看到的数据和操作完全不一样。这套系统的设计我完全围绕这七个边界来展开没有引入多余的库存管理、固定资产管理避免初期模糊不清。但是表结构设计时我会刻意留出扩展字段比如办卡记录里加备注、课程表里预留场地字段这样以后想加功能不用改表重建。1.3 前后端分离的分层协作方式这套系统没有采用传统模板渲染的那套思路而是前端Vue单独跑后端SpringBoot只提供JSON接口。浏览器的请求链路是Vue页面发起axios请求到达SpringBoot的Controller层Controller接收参数后交给Service层处理业务逻辑Service再通过MyBatis的Mapper接口操作MySQL结果逐层返回。很多人第一次接触前后端分离会问数据到底归谁管我的经验是数据库结构和接口返回格式一定以阿后端为核心页面怎么展示、状态怎么交互由前端负责。后端接口要保证“一次返回完整数据”前端不要拼了三个接口才凑齐一个页面需要的信息否则后期维护想哭。为了不让前端开发时干等后端我把接口文档在项目初期就固定下来用Swagger自动生成前端照着文档Mock数据联调。这样两边并行推进整个系统开发周期能缩短三分之一。2. 数据库设计核心表结构与字段规划2.1 用户、会员、教练的账号设计健身房系统里有一类很容易踩坑的设计把会员和教练各建一张独立的账号表。短时间看无所谓一旦同一个场馆的人既办卡又当团课教练数据就乱套了。我这套系统做得比较稳的方式是单独建一张user表里面放login_name、password、role_type三个关键字段role_type用数字区分1代表会员2代表教练3代表管理员/前台。真正的会员扩展资料放在member_profile表教练扩展资料放在coach_profile表两张表都用user_id关联主账号。这样登录时只需要查user表取到角色后再决定去查哪张资料表。带来的好处是权限认证统一不至于会员登录走一套校验、教练登录又走另一套。user表的核心字段大概是这样的字段名类型说明idbigint主键自增login_namevarchar(32)登录名手机号passwordvarchar(100)BCrypt加密后的密码role_typetinyint1会员2教练3管理员statustinyint0禁用1正常create_timedatetime注册时间update_timedatetime资料更新时间手机号这里我做了唯一索引因为绝大多数健身房都直接用手机号登录哪怕不做系统里也不允许两个账号共用一个手机号否则前台录入会员时会找不到人。2.2 会员卡、课程、预约的核心业务表设计再往下是业务的核心我把表拆成四张card_type、member_card、course、course_reservation。card_type是卡种表定义年卡、季卡、月卡、次卡这几种类型保存卡名称、时长天数、次数、价格。member_card是会员卡实例表记录哪个会员买了哪张卡、开卡时间、到期时间、剩余次数。之所以要把卡种和会员卡分开是因为同一个年卡可能有不同的售价也方便以后做促销改价只改卡种表不影响已有会员的数据。course表是课程表字段包括coach_id、course_name、course_type、start_time、end_time、max_students、current_count、price。course_type区分团体课和私教课。current_count是用来做课程人数控制的冗余字段这个字段在预约时会用数据库锁来更新。course_reservation是预约记录表核心字段有reservation_id、member_id、course_id、reservation_time、status。status分三种预约成功、已取消、已签到。为什么要单独立表而不是在course表里加个预约名单字段因为一个会员可能预约多节课程多个会员之间互相独立关系型数据库里就该用关联表表达用字符串拼ID列表的方式查起来极痛苦。2.3 数据库脚本片段与设计要点建核心表时有两个很容易被忽略的细节我先写出来。一是所有金额字段统一用decimal(10,2)不要用double不然算总营业额的时候会出现小数尾巴二是时间字段尽量都带上update_time后面做数据对账和排查问题时能省很多事。这里摘一段course表的建表SQL里面包含了我说的课程预约人数控制字段CREATE TABLE course ( id bigint NOT NULL AUTO_INCREMENT, coach_id bigint NOT NULL COMMENT 教练ID, course_name varchar(64) NOT NULL COMMENT 课程名称, course_type tinyint NOT NULL DEFAULT 1 COMMENT 1团体课2私教课, start_time datetime NOT NULL COMMENT 开课时间, end_time datetime NOT NULL COMMENT 结束时间, max_students int NOT NULL DEFAULT 10 COMMENT 人数上限, current_count int NOT NULL DEFAULT 0 COMMENT 已预约人数, price decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 课程价格, status tinyint NOT NULL DEFAULT 1 COMMENT 1可预约0已停课, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_start_time (start_time), KEY idx_coach_id (coach_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表;我做索引时有一个习惯查询条件里的字段才建索引不要无脑给所有字段加。比如start_time和coach_id就是高频查询条件一定要建course_name撑死做个模糊搜索加索引没意义还会拖慢插入速度。会员卡到期日期我在设计时没有用函数计算而是办卡开卡时就计算好存进member_card表。这样做的好处非常明显会员列表展示到期时间的时候一秒都不需要额外查询定时任务扫表看哪些卡快到期也只需要一条简单的SQL。3. 后端实现SpringBoot连接MyBatis的关键环节3.1 工程结构与依赖配置后端工程我按标准分层来建controller、service、mapper、entity、common、config。entity里放数据库表对应的实体类mapper是接口和XML文件的位置service写业务逻辑controller尽量保持薄只做参数接收和结果封装。这样做的目的很直接业务复杂时Controller太厚连排查问题都得多翻几个文件。pom.xml里最关键的是这几个依赖spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok。lombok我强烈建议加上它能自动生成getter、setter、toString实体类的代码量直接砍掉一半。application.yml里有一个配置我曾经忽略过导致系统上线第一天时间就乱掉了spring: datasource: url: jdbc:mysql://localhost:3306/gym?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.gym.entity configuration: map-underscore-to-camel-case: true server: port: 8080serverTimezoneAsia/Shanghai这个参数特别重要不加的话MySQL返回的时间会比本地时间少8个小时查出来的预约记录全是前一天排查起来莫名其妙。另外useSSLfalse一定要加不然高版本的MySQL连接器会尝试做SSL握手本地连不上还会报错。map-underscore-to-camel-case同样重要。因为数据库字段是create_time风格Java属性是createTime风格不加这个配置MyBatis映射结果集时所有下划线字段全是null。这是很多新手踩得最狠的坑。3.2 登录鉴权与权限控制这套系统的登录方案用的是JWT流程是前端把账号密码提交到后端后端校验通过后签发一个token前端保存token并在每次请求时放在请求头里后端通过拦截器统一校验。比起SessionJWT在前后端分离场景下更自然后端不需要维护Session前端拿到token就能在多端使用。用户登录密码我做了BCrypt加密存储在user表里BCrypt的特点是每次加密结果都不同即使两个账号密码一样密文也不一样而且内置盐值。用它的原因很简单SHA256这类算法加密后可以用彩虹表破解BCrypt对暴力破解的抵抗力明显强很多。登录接口的核心逻辑大概是public LoginResult login(String loginName, String password) { User user userMapper.findByLoginName(loginName); if (user null || !BCrypt.checkpw(password, user.getPassword())) { throw new BusinessException(账号或密码错误); } if (user.getStatus() 0) { throw new BusinessException(账号已被禁用); } String token JwtUtil.createToken(user.getId(), user.getRoleType()); return new LoginResult(token, user.getRoleType()); }权限管理我用了一个简单但不简陋的方案写一个拦截器解析token后把userId和roleType放进ThreadLocal然后在Controller层拦截需要特定角色的接口。比如会员管理接口要求roleType是3管理员教练排课接口要求roleType是2教练或3预约接口则三个角色都能访问。粒度按角色来区分对这类系统足够用不用引入Spring Security那么重的权限框架。3.3 核心业务流程办卡、预约、上课签到办卡流程是整个系统最简单但最容易出错的环节。流程是会员选择卡种生成订单支付成功后给会员卡增加一条记录。这里面有一个事务问题需要特别注意生成订单和给会员卡开卡不能分开提交否则会出现订单支付成功但会员卡没到账的情况。我用的方式是加Transactional注解把创建订单、写member_card、更新会员状态三步绑定在同一个事务里。如果中间任何一步出错数据库自动回滚。预约课程的并发控制是这个系统最值得写的内容。设想一个场景团体课人数上限8人第9个人点击预约时刚好赶上第8个人也点击预约如果没有做并发控制两个请求都读到当前人数7人判断都小于8然后都执行加1最后课程的人数会变成9人超卖。我当时处理并发用的是乐观锁思路在预约前先执行一条更新语句给课程的current_count加1但条件里带上current_count max_students。MySQL更新时会自动加行锁同一行记录只能有一个事务更新成功。这个方案在人数不多的场景下简单可靠代码里是这么写的update idincreaseCount UPDATE course SET current_count current_count 1, update_time NOW() WHERE id #{courseId} AND current_count lt; max_students /update执行完这条更新如果受影响行数是0说明人数已经满了直接返回“课程已满”的提示。更新成功后再插入course_reservation记录整个过程也包在事务里。这样做简历上写出来好看实际用起来也不容易出现超卖问题。上课签到就简单多了。教练或前台通过会员手机号或会员卡号查到预约记录把status从“预约成功”改成“已签到”。签到记录还可以顺带记录一个签到时间用来做上课出勤统计。3.4 MyBatis写SQL的四个经验MyBatis用多了以后我总结了几条很实用的经验。第一能用XML写复杂SQL就不要硬堆注解。注解适合简单的CRUD但像多表联查、动态条件拼装这种逻辑XML的 、 标签比在Java里拼字符串可读性好太多。加上MyBatis的XML自带转义处理不会出现SQL注入问题。第二传参有讲究。#{}是预编译占位符会处理成?防止SQL注入${}是字符串拼接虽然能直接嵌入字段名、表名但很容易被注入。所有用户输入的值一律用#{}只有像动态排序列名这种少数场景才用${}而且得提前做好白名单校验。第三查询结果映射时一定要考虑列名和属性名的对应。 可以精确控制每一列的映射关系特别适合关联查询。字段多的表建议都显式定义resultMap不要依赖自动映射否则哪天动了数据库字段排查半天才发现是映射丢了。第四MyBatis缓存不要乱开。默认的一级缓存是SqlSession级别的二级缓存是namespace级别的如果两个表之间有关联关系缓存命中后很容易读到旧数据。我这套系统里就默认关闭了二级缓存靠MySQL自身的查询缓存和业务层按需设计Redis缓存避免脏读问题。4. 前端Vue实现页面、路由与后端对接4.1 Vue工程搭建与路由设计前端工程用Vite构建开发时跑在5173端口后端接口跑在8080端口。开发环境用代理解决跨域把Vite配置里的proxy指向后端地址这样前端请求/api开头的接口时Vite会把请求转发给8080浏览器感知不到跨域。路由设计上我把三个角色的页面分开。登录后根据角色跳转到不同的首页会员看到的是课程列表和我的预约教练看到的是我的课程和上课记录管理员看到的是会员管理、课程管理、订单管理这些完整后台页面。虽然页面数量不少但大部分是列表页加表单Vue的组件复用能省很多工作量。Vue Router在配置时我用了路由懒加载每个页面只有在访问时才会加载对应组件首屏速度提升明显。程序员之间的默契是能用懒加载就不要一次性打包否则一个几十个页面的管理系统首屏加载要几秒。4.2 axios请求封装与Token携带axios封装是前后端对接最需要统一的一层。我花了比较多心思在拦截器上。请求拦截器负责把本地存的token塞进请求头响应拦截器负责统一处理错误码。常见的情况是token过期时后端返回401拦截器检测到401后清空本地登录状态直接跳回登录页用户重新登录即可不需要每个页面都写一遍判断逻辑。封装后的请求代码大概是import axios from axios const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )统一超时时间设置成15秒避免接口慢的时候页面一直白屏。接口报错时在响应拦截器里直接弹出统一的提示信息减少每个页面都写try...catch的重复劳动。4.3 核心页面的实现思路会员管理页面是典型的后台CRUD页面用Element Plus的表格组件加表单弹窗就能实现。但有两个细节值得注意一是分页逻辑不要自己造轮子Element Plus的分页组件配合后端的分页查询接口pageNum和pageSize两个参数传过去返回的total用来渲染总页数二是会员状态、卡类型这种固定值前端用映射表显示不要直接显示数字。课程预约页面前端做了一些额外处理。会员点击预约前先检查当前时间是否晚于开课时间检查自己是否已经预约过了再将请求发给后端。这两个校验前端做是为了减少无效请求真正的并发控制仍然在后端前端校验起的只是体验优化作用。上课签到页面用的是手机号或会员卡号搜索。这个页面在健身房前台使用频率极高所以我把输入框设计成大字号、大点击区域方便前台工作人员快速操作。这个细节让健身房老板评价很不错因为她们每天要刷几百次会员信息。4.4 Vue打包与SpringBoot的整合方式前端开发完之后的打包有两种常见方式一种是前端打包后放在nginx里单独部署另一种是把dist目录下的静态文件复制到SpringBoot的src/main/resources/static目录打成一个jar包一起运行。我这套系统两种方式都支持但单纯只想快速演示时常采用第二种。需要注意的点是Vue Router如果使用history模式打包后放进SpringBoot里刷新某个子页面会出现404。因为SpringBoot默认只把根路径映射到index.html子路由没有对应的Controller。解决办法有两个一是前端改成hash模式URL里会多一个#号但不影响功能二是在SpringBoot里写一个WebMvcConfigurer把非静态资源的路径全部转发到index.html。我当时为了演示方便直接用hash模式部署时用nginx反向代理配history模式兼顾了好看和可靠。5. 常见问题与排查技巧实录做这个项目的过程中我把最容易耽误时间的几类问题整理成了一个速查表很多问题不是业务复杂而是环境配置或框架使用层面的疏忽。现象根本原因解决方案启动报SSL连接错误MySQL连接URL没加useSSLfalseURL加useSSLfalseserverTimezoneAsia/Shanghai查询结果时间少8小时时区未指定GMT8serverTimezone设为Asia/Shanghai所有下划线字段为nullMyBatis没有开启驼峰映射mybatis.configuration.map-underscore-to-camel-casetrue前端请求接口跨域前后端分属不同端口开发环境Vite代理生产环境nginx转发打包后刷新页面404Vue history模式未配回退hash模式或后端配置转发index.html课程人数超卖先查再改的普通流程使用update...where current_countmax乐观锁金额小数位异常用了double保存金额统一改用decimal数据库连接耗尽频繁创建连接未使用连接池SpringBoot默认HikariCP注意配置最大连接数接口返回慢列表查询没有索引查询条件字段建立联合索引动态SQL拼接错误大量使用字符串拼接MyBatis XML里使用if和where标签数据库连接池这块值得单独说一句SpringBoot默认的HikariCP非常快但我见过有的公司把它误换成了DBCP性能差异很大。除非有特别明确的原因否则别动默认连接池。另外有两个我在实际项目中碰到的偏门问题在这里也分享出来。第一个是MyBatis的mapper接口和XML文件绑定不上报Invalid bound statement错误。这种情况八成是mapper-locations配置路径不对或者XML文件的namespace没有按接口全限定名填写。直接检查配置和namespace两个地方问题很快能定位。第二个是Vue打包后图片资源引用404原因是打包配置里的publicPath路径不对部署在子目录时需要设置成相对路径或对应目录前缀。关于打印SQL日志这是我调试项目时必不可少的一步。在application.yml里加上mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl就能在控制台看到MyBatis生成的SQL和传入的参数对排查参数传错、SQL写错这类问题效率极高。不过上线后建议关掉这个日志否则SQL全暴露在日志文件里占用空间还不安全。6. 部署打包与后续扩展方向6.1 Maven打包与Java运行后端打包用Maven命令mvn clean package -DskipTests打出来的jar包可以直接放在服务器上运行。之前看到有的把源码直接丢到服务器上再用命令编译多了一步不说依赖冲突还不容易发现。运行命令可以加上一个隐藏参数来指定环境配置比如java -jar gym-system.jar --spring.profiles.activeprod。我的习惯是准备三份配置application-dev.yml给本地开发application-test.yml给测试环境application-prod.yml给生产环境。数据库密码、日志级别、连接池参数分别配置避免开发环境的数据和生产环境混在一起。前端打包后的静态文件如果打算和SpringBoot打成一个包直接在resources/static目录下放dist内容即可。如果分开部署把前端资源放到nginx的html目录下配置反向代理把/api开头的请求转发到SpringBoot的8080端口页面请求直接由nginx处理。两种方式我都实际用过小项目单jar包的运维成本低稍大的项目用nginx容易做负载均衡和缓存。6.2 这个系统还能怎么扩展健身房管理系统做完以后可扩展的方向其实不少。最常见的两个是私教预约和体测数据管理。私教预约可以基于现有课程预约逻辑扩展按教练时间排期体测数据可以记录体重、体脂率、肌肉量用图表展示变化趋势这些都不需要推翻现有表结构。如果门店数量多起来还可以考虑加一个场馆表和员工表实现多门店数据隔离。前端按角色配菜单权限后端按门店ID过滤数据核心表结构加tenant_id字段即可。加字段趁早加等数据量大了再改表浪费的时间远多于设计时多想五分钟。这套系统的数据库备份也很重要我会每天定时用mysqldump备份一次。健身房系统数据量不大全量备份也就几十兆多配几次自动备份比事后找数据恢复工具靠谱得多。个人体会最深的一点是这类信息管理系统技术本身并不难难的是把看似简单的业务规则转换成清晰的数据结构和稳定的接口逻辑。前期多花半天把表结构设计清楚、把并发更新想清楚后面写代码、调试、维护的时间能省出好几倍。健身房管理系统做完之后再去看类似的预约类系统比如驾校预约、会议室预约很多设计的思路都是可以直接平移的。不过那是下一个项目要聊的事情了。
返回列表