SSM+JSP健身房管理系统:从CRUD到并发控制的实战解析
2026/9/16 18:57:31 网站建设 项目流程

简介:一套基于SSM与JSP技术的健身房管理系统完整源码,尤其适合Java Web初学者、有课程设计或毕业设计需求的高校学生,以及需要快速搭建SSM项目框架的开发者。系统完整涵盖会员管理、课程安排、器材管理、教练信息等核心业务模块,能帮助理解SSM整合思路、JSP页面渲染与MySQL数据库表设计。资源包共包含1277个文件,其中包含120个Java源文件、109个JSP页面、146个CSS样式、364个JS脚本,以及数据库SQL脚本、Maven配置和各类前端静态资源,整个压缩包整体约15.74MB,目录结构清晰便于检索。已有113人学习下载。包内提供完整工程文件、数据库初始化脚本和必要的配置文件,导入IDEA或Eclipse后即可运行调试;同时可通过源码学习前后端数据交互、分页查询、登录权限等关键实现,为二次开发或毕业设计改造提供可参考的底稿。

1. 这个 SSM+JSP 健身房管理系统,值得读的其实不是 CRUD

如果只看表面,这套基于 Java SSM(Spring + Spring MVC + MyBatis)+ JSP 的健身房管理系统源码,和网上大量"xx 管理系统"看起来没什么两样:登录、增删改查、列表分页。但真正动手跑过一遍就会发现,这类项目的价值不在技术新,而在那条完整链路——从前端 JSP 页面发请求,到 Spring MVC 做参数绑定和校验,再到 Service 层处理业务规则、事务边界,最后 MyBatis 把结果集映射回页面。这个链路今天依然是 Java 面试八股文里经常被追问的东西,也是在中小型项目中依然最常见的交付形态。

健身房这个业务场景本身也比普通 CRUD 多了一层约束。会员卡有储值、时效、状态变化,私教课排期不能撞车,课程表要按周滚动。业务规则一旦多起来,写在哪一层、怎么保证事务、怎么避免并发把预约名额超卖,这几个问题才是这套源码真正的看点。不管你是把这套代码当作课设基础去二次开发,还是想借一个完整项目把 SSM+JSP 的整合路径彻底理顺,核心都是先看懂请求怎么进来、事务怎么控制、页面和控制器怎么对齐数据。

2. SSM 整合骨架与 Maven 依赖:先把三个框架装进同一个容器

SSM 不是一个框架,是三段式分工:Spring 管理对象和事务,Spring MVC 负责 Web 层的请求分发,MyBatis 管数据库访问。而 JSP 作为视图层挂在 Spring MVC 的视图解析器后面。整合的第一步不是写业务,而是把这三者的依赖版本和配置关系理顺。

2.1 Maven 依赖清单的核心取舍

以 Maven 工程为例,pom.xml 里最关键的不是把依赖全部罗列出来,而是保证几个容易打架的库版本一致。这里给一套经过验证的依赖集合,适用于 Servlet 3.1 容器(Tomcat 8.5/9):

<properties> <spring.version>5.3.39</spring.version> </properties> <dependencies> <!-- Spring core + mvc --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 官方 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- MySQL 驱动和连接池 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.23</version> </dependency> <!-- JSP 相关 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>

依赖这块最常踩的坑是mybatis-spring版本和 Spring 版本不匹配。mybatis-spring 2.x要求 Spring 5.x 以上,如果项目里还躺着 Spring 4.3 的旧依赖,启动时会出现NoSuchBeanDefinitionException或者BeanCreationException。另外一个值得注意的点:javax.servlet-api一定要用provided作用域,否则会和 Tomcat 自带的 Servlet 实现冲突,导致启动报ClassCastException。很多健身房管理系统源码帖子里会忽略这个细节,你在对比不同源码时可以先看 pom 里这一处。

2.2 Spring MVC 配置:视图解析器和静态资源放行

JSP 要能被 Spring MVC 正确渲染,核心是视图解析器。在spring-mvc.xml里:

<!-- 扫描 Controller 包 --> <context:component-scan base-package="com.gym.controller"/> <!-- 视图解析器:/WEB-INF/views/ 目录下找 .jsp 文件 --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> <!-- 放行静态资源:CSS/JS/图片不要走 Controller --> <mvc:resources mapping="/static/**" location="/static/"/> <mvc:annotation-driven/>

InternalResourceViewResolver把 Controller 返回的逻辑视图名(比如member/list)翻译成物理路径/WEB-INF/views/member/list.jsp/WEB-INF下的 JSP 不能被浏览器直接访问,只能通过 Controller 转发过去,这既是规范做法,也避免用户绕过登录直接打开页面——很多粗糙源码把 JSP 直接放在webapp根目录下,这个问题在后面部署章节细说。<mvc:annotation-driven/>必须保留,它负责注册@RequestMapping解析、JSON 消息转换器等基础组件,漏掉这行会出现"Controller 明明写了映射却 404"的诡异问题。

2.3 Spring + MyBatis 整合:事务与 Mapper 扫描

MyBatis 要接入 Spring 管理,需要SqlSessionFactory和数据源。Druid 连接池的参数直接影响数据库连接稳定性,initialSize是启动时建立的连接数,maxActive是峰值连接数,健身房管理系统在高峰期(晚上 7-9 点)会有会员集中查课表和预约,这个值建议设到 20 以上,否则并发一上来就报CannotGetJdbcConnectionException

<!-- spring-mybatis.xml --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/gym_db?useSSL=false&amp;serverTimezone=Asia/Shanghai&amp;characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="your_password"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- Mapper XML 文件的位置 --> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <!-- 实体类别名包,XML 里可以用短类名 --> <property name="typeAliasesPackage" value="com.gym.entity"/> <!-- 下划线自动转驼峰 --> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <!-- 扫描 Mapper 接口 --> <mybatis:scan base-package="com.gym.mapper"/> <!-- 事务管理器 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

mapUnderscoreToCamelCase设为true是一个极其省事的配置:数据库字段card_type可以自动映射到实体类的cardType属性,不用每个字段都写resultMapmybatis:scan做的是接口扫描,它会把com.gym.mapper包下所有接口生成代理对象注入到 Service 中,这在 MyBatis 源码里是 MapperRegistry 的工作机制——理解这一点,后面看动态 SQL 和参数绑定才不会懵。

3. 健身房管理系统的表结构设计:卡、课、人是三个核心域

没有一张合理设计的表结构,业务代码写得再漂亮也撑不住。健身房管理系统一般围绕三个核心域建模:会员与卡、课程与教练、预约与订单。表结构是这套源码运行时最容易暴露问题的地方,尤其时间字段和状态字段的类型约定。

3.1 核心表清单与字段要点

常见的设计是六到八张表,这里列出最小可用集及其设计用意:

表名核心字段设计要点
memberid, name, phone, gender, birthday, create_timephone 建唯一索引,作为登录账号
cardid, member_id, card_type, balance, start_date, end_date, statuscard_type 用枚举值:1 次卡 / 2 月卡 / 3 年卡
recharge_recordid, card_id, amount, pay_method, op_time储值流水,只增不改
courseid, name, trainer_id, max_people, duration, price课程基础信息
course_scheduleid, course_id, class_date, start_time, end_time, room, booked_count排期表,是预约冲突判断的关键
appointmentid, member_id, schedule_id, status, create_time唯一约束 (member_id, schedule_id)
trainerid, name, phone, specialty, avatar教练信息,一对多关联 course

card表建议单独建而不是把卡字段挂在 member 表上。虽然会员可能只有一张卡,但实际业务里"换卡、续卡、补卡"都会导致原卡挂失,单独的 card 表能把历史卡保留下来。course_schedule表设计时要注意,一个课程有多个排期(一周三次),当天同一时段不能在不同教室重复排同一门课,这个约束既可以用 SQL 唯一索引,也可以在 Service 层代码里加校验——后面第 4 章会给出校验写法。

3.2 关键表的建表 SQL 与索引策略

CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '会员ID', name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女', birthday DATE, remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE card ( id INT PRIMARY KEY AUTO_INCREMENT, member_id INT NOT NULL, card_type TINYINT NOT NULL COMMENT '1次卡 2月卡 3年卡', balance DECIMAL(10,2) DEFAULT 0.00, start_date DATE NOT NULL, end_date DATE NOT NULL, status TINYINT DEFAULT 1 COMMENT '1正常 2挂失 3过期', KEY idx_member (member_id), CONSTRAINT fk_card_member FOREIGN KEY (member_id) REFERENCES member(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DECIMAL(10,2)是金额字段的标准选择,这里有不少现成源码用double存金额,到后面算总账时会有精度问题。时间字段用DATETIME而不是TIMESTAMP,因为TIMESTAMP在 2038 年会有溢出风险,而且受时区影响。外键约束在代码层不一定会用,但表结构里保留FOREIGN KEY可以在误删会员时让数据库帮你挡住,倒是能少写不少防御代码。

4. Service 层才是业务核心:办卡、预约、防止超卖

SSM 项目里,Controller 往往是薄薄一层,真正的业务规则在 Service 里。健身房管理系统最有代表性的三个业务场景是:办卡(涉及卡状态与流水同时变更)、预约课程(涉及名额校验)、查询课表(涉及多表关联与分页)。这三个场景分别对应事务、并发和查询优化。

4.1 办卡的事务边界:同一笔操作涉及三张表

办卡时要做两件事:在 card 表插入新卡记录,在 recharge_record 表插入首充流水。任何一个失败,另一个都不能落库。这是@Transactional的典型使用场景:

@Service public class CardServiceImpl implements CardService { @Autowired private CardMapper cardMapper; @Autowired private RechargeRecordMapper rechargeRecordMapper; @Transactional(rollbackFor = Exception.class) public void openCard(OpenCardDTO dto) { // 1. 校验会员是否存在 Member member = memberMapper.selectById(dto.getMemberId()); if (member == null) { throw new BusinessException("会员不存在"); } // 2. 校验是否已有未过期的同类型卡 Card activeCard = cardMapper.selectActiveCard(dto.getMemberId(), dto.getCardType()); if (activeCard != null) { throw new BusinessException("该会员已有未过期的同类会员卡"); } // 3. 插入新卡 Card card = new Card(); card.setMemberId(dto.getMemberId()); card.setCardType(dto.getCardType()); card.setBalance(dto.getRechargeAmount()); card.setStartDate(new Date()); card.setEndDate(calculateEndDate(dto.getCardType())); card.setStatus(1); cardMapper.insert(card); // 4. 插入充值流水 RechargeRecord record = new RechargeRecord(); record.setCardId(card.getId()); record.setAmount(dto.getRechargeAmount()); record.setPayMethod(dto.getPayMethod()); rechargeRecordMapper.insert(record); } }

@Transactional的回滚条件是关键参数。默认情况下 Spring 只对RuntimeException回滚,如果 Service 里抛的是Exception子类(比如throws Exception的 checked exception),事务不会回滚。写成rollbackFor = Exception.class是明确告诉 Spring:只要是Exception及其子类都触发回滚。还有一个细节是calculateEndDate(cardType),月卡加 30 天、年卡加 365 天,这个计算要单独抽方法,不要散落在业务代码里,因为次年二月份的天数差异会导致年卡到期日提前一天。

4.2 预约课程的防超卖:状态校验要放在事务和锁之内

预约的逻辑是:用户点了某节排期课,先查booked_count < max_people,然后把booked_count加一。但两个会员同时点击,会同时读到booked_count=19max_people=20,都通过校验,都执行 UPDATE,最终超卖。

<!-- AppointmentMapper.xml --> <update id="increaseBookedCount" parameterType="int"> UPDATE course_schedule SET booked_count = booked_count + 1 WHERE id = #{scheduleId} AND booked_count &lt; max_people </update>
@Transactional public void bookCourse(Long memberId, Integer scheduleId) { CourseSchedule schedule = scheduleMapper.selectByIdForUpdate(scheduleId); if (schedule == null) { throw new BusinessException("排期不存在"); } int rows = scheduleMapper.increaseBookedCount(scheduleId); if (rows == 0) { throw new BusinessException("该课程名额已满"); } Appointment appointment = new Appointment(); appointment.setMemberId(memberId); appointment.setScheduleId(scheduleId); appointment.setStatus(1); appointmentMapper.insert(appointment); }

WHERE booked_count < max_people是数据库层面的原子校验:UPDATE 执行时会对该行加锁,两个并发请求只有一个能更新成功,另一个 UPDATE 影响行数为 0。这是最直接的防超卖手段,比先 SELECT 再 UPDATE 可靠得多。之前不少源码里只有selectById然后if (bookedCount < maxPeople)update,那个写法在并发下必然出问题——这也是面试时会被追问"预约系统怎么防超卖"的标准答案素材。

4.3 查询课表时的动态 SQL 与分页

课表查询往往带着多个过滤条件:按课程名、按教练、按日期范围。MyBatis 的<where>标签能自动处理多余的AND,是这套源码里最值得借用的写法:

<select id="searchSchedule" resultType="com.gym.entity.CourseSchedule"> SELECT cs.*, c.name AS course_name, t.name AS trainer_name FROM course_schedule cs LEFT JOIN course c ON cs.course_id = c.id LEFT JOIN trainer t ON c.trainer_id = t.id <where> <if test="courseName != null and courseName != ''"> AND c.name LIKE CONCAT('%', #{courseName}, '%') </if> <if test="trainerId != null"> AND c.trainer_id = #{trainerId} </if> <if test="startDate != null"> AND cs.class_date &gt;= #{startDate} </if> <if test="endDate != null"> AND cs.class_date &lt;= #{endDate} </if> </where> ORDER BY cs.class_date, cs.start_time </select>

<where>标签会自动去掉第一个条件前的AND,这是我在很多项目里推荐优先使用的写法,比写<trim prefix="WHERE" prefixOverrides="AND">直观得多。CONCAT('%', #{courseName}, '%')是 MySQL 的模糊查询写法,注意不要写成'%${courseName}%'${}是字符串拼接,会产生 SQL 注入风险;#{}是预编译参数,健身房管理系统的搜索框中用户输入的内容不可信,必须用#{}

5. JSP 与 Controller 联动:分页、搜索、回显的一个都不会少

Service 层把业务做完,剩下的问题是:JSP 页面到底怎么把数据展示出来,用户提交的表单怎么回显校验信息,分页和搜索参数怎么在页面与 Controller 之间来回传递。这一层用不好,页面就会写满 Java 小脚本(scriptlet),项目维护起来非常痛苦。

5.1 会员列表页的典型 Controller 写法

一个带搜索和分页的会员列表,Controller 接收参数后传给 Service,返回 ModelAndView 并转发到 JSP:

@Controller @RequestMapping("/member") public class MemberController { @Autowired private MemberService memberService; @RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, String keyword, Model model) { PageResult<Member> page = memberService.pageQuery(pageNum, pageSize, keyword); model.addAttribute("page", page); model.addAttribute("keyword", keyword); return "member/list"; } }

@RequestParam(defaultValue = "1")处理了用户第一次进入页面时pageNum为空的问题。如果去掉defaultValue,没有传pageNum的请求会直接报 400 参数缺失错误。Model里除了放分页数据,还要把keyword原样放回页面——这样用户搜索"张三"后点第二页,搜索条件不会丢。这是一个特别常见但特别容易被忽略的点,不少源码在分页跳转时把搜索关键词丢了,用户翻页后列表变成了全量数据。

5.2 JSP 页面用 JSTL 和 EL 渲染,而不是 Java 代码

列表页的分页区域用c:forEach渲染页码,用c:if控制上一页下一页的可用状态:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <table class="table table-bordered"> <thead> <tr> <th>ID</th> <th>姓名</th> <th>手机号</th> <th>开卡时间</th> <th>状态</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${page.list}" var="m"> <tr> <td>${m.id}</td> <td>${m.name}</td> <td>${m.phone}</td> <td><fmt:formatDate value="${m.createTime}" pattern="yyyy-MM-dd HH:mm"/></td> <td> <c:choose> <c:when test="${m.cardStatus == 1}">正常</c:when> <c:when test="${m.cardStatus == 2}">挂失</c:when> <c:otherwise>过期</c:otherwise> </c:choose> </td> <td> <a href="${pageContext.request.contextPath}/member/detail/${m.id}">查看</a> <a href="${pageContext.request.contextPath}/member/edit/${m.id}">编辑</a> </td> </tr> </c:forEach> </tbody> </table>

${page.list}取的是Modelpage对象的getList()方法返回值,EL 表达式在这里访问的是属性而不是字段。日期格式化必须用<fmt:formatDate>,如果直接输出Date对象,页面上显示的是Tue Jan 21 10:22:33 CST 2025这种格式,这就是很多 JSP 页面时间显示成英文的原因。${pageContext.request.contextPath}是获取项目上下文路径,在 JSP 里写链接时建议都加上,否则部署到带项目名的路径下(比如http://localhost:8080/gym/)时,所有链接都会 404。

5.3 表单提交后的数据回显和校验

新增会员时,如果用户手机号已存在或格式不对,页面要能保留用户已填的内容,而不是清空后让用户重填。常见做法是失败后return "member/add",把数据带回原来表单:

<form action="${pageContext.request.contextPath}/member/save" method="post"> <input type="text" name="name" value="${member.name}" class="form-control"/> <input type="text" name="phone" value="${member.phone}" class="form-control"/> <c:if test="${not empty errorMsg}"> <div class="alert alert-danger">${errorMsg}</div> </c:if> <button type="submit" class="btn btn-primary">保存</button> </form>

Controller 里校验失败时把错误信息放进 Model,同时把表单对象也放进去:

@RequestMapping("/save") public String save(Member member, BindingResult bindingResult, Model model) { if (memberService.isPhoneExists(member.getPhone())) { model.addAttribute("errorMsg", "手机号已存在"); model.addAttribute("member", member); return "member/add"; } memberService.save(member); return "redirect:/member/list"; }

这里有一个重要的状态处理原则:新增成功用redirect重定向而不是直接return "member/list"。直接转发的话,用户按 F5 刷新浏览器会再次提交表单,导致重复插入同一条数据——这就是 PRG 模式(Post/Redirect/Get)要解决的问题。BindingResult参数必须紧跟Member member之后,中间不能插其他参数,这是 Spring MVC 参数绑定的硬性约定。

5.4 列表导出 Excel:一个可以直接抄走的扩展点

健身房的月报、会员名单经常需要导出 Excel。params:JSP 页面里放一个导出按钮,Controller 返回一个流而不是视图。

@RequestMapping("/export") public void export(HttpServletResponse response) throws IOException { List<Member> members = memberService.listAll(); response.setContentType("application/vnd.ms-excel"); response.setHeader("Content-Disposition", "attachment;filename=member_" + System.currentTimeMillis() + ".xls"); // 使用 Apache POI 生成 Excel 文件流 Workbook workbook = new HSSFWorkbook(); Sheet sheet = workbook.createSheet("会员列表"); // ... 填充数据 ... workbook.write(response.getOutputStream()); workbook.close(); }

Content-Disposition里的attachment告诉浏览器这是一个下载文件而不是内联打开的页面;filename加了时间戳可以避免同名文件覆盖。注意 JSP 页面里这个功能的入口只是一个普通的<a href="${pageContext.request.contextPath}/member/export">导出</a>,后端返回的是二进制流,页面不需要跳转。POI 写入的每个单元格的类型这里不展开,但如果你打算在现成源码上扩展这个功能,从"把数据库 List 变成 Excel 行"这一步开始写,比从零搭建 POI 环境容易得多。

6. 部署、验证与常见坑:把整套源码在你的机器上跑起来的几个关键动作

拿到源码后先别急着看代码,先确认能不能在自己机器上跑起来。部署顺序一般是:导入 MySQL 脚本、改 Druid 配置、用 Maven 打成 WAR 包、丢进 Tomcat webapps。以下三个问题占了炸机现场的八成以上。

405/JSP 编译路径的坑:Tomcat 会把 JSP 编译成 class 文件,存放位置在tomcat/work/Catalina/localhost/项目名/org/apache/jsp。如果页面改了但浏览器看不到变化,去这个目录看编译时间,确认是不是服务器用的还是旧 class。JSP 在开发阶段一般开着热部署,但新增的 JSP 文件不一定能热生效,最常见做法是改完 JSP 后重启 Tomcat 一次,而不是反复用 Ctrl+F5 刷新。

404 的排查顺序:先看访问路径有没有带上项目上下文路径,再看 Controller 的@RequestMapping有没有拼错,再看视图解析器的 prefix 指向的目录里是不是真有对应 JSP 文件。在浏览器里直接访问/WEB-INF/下的 JSP 会返回 404,这是正常的,不是 bug。

中文乱码:数据库连接 URL 里带characterEncoding=utf8,JSP 页面顶部有<%@ page contentType="text/html;charset=UTF-8" language="java" %>,再加上 Spring MVC 的CharacterEncodingFilter三步都做到我还没遇到乱码问题。

验证时手动走通一条链路:超级管理员登录、录入一个新会员、给会员开一张月卡、新增一个课程排期、用新会员账号预约这节课程、到列表确认预约记录。这条链路覆盖了本章前面讲的事务、并发、多表查询、详情展示全部核心代码,全部走通基本说明这个系统可以持续迭代下去。验证预约冲突时,可以开两个浏览器窗口同时点预约,如果只有一单成功、另一单报"已满",恭喜你,权限和防超卖逻辑都可以透传给下一个接手的人了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询