简介:一套面向高校毕设与Java Web初学者的SSM+JSP健身房管理系统完整源码。后端采用Spring、SpringMVC、MyBatis,前端基于JSP与Bootstrap,数据库为MySQL5.7+,可在IDEA或Eclipse中直接导入运行。系统涵盖会员管理、课程预约、器材管理等典型业务模块,适合用于课程设计、毕业设计或SSM框架整合练习。源码包共1277个文件,压缩包大小15.74MB,其中包含120个Java源文件、109个JSP页面、364个JS脚本及146个CSS样式,另有图片、字体、XML配置、SQL脚本等资源,目录结构完整,数据库脚本和项目配置文件均已提供,便于快速搭建环境。目前已有113人学习下载,适合需要参考真实项目结构、理解SSM整合流程并完成健身房业务功能开发的读者。
1. java-ssm+jsp 健身房管理系统源码:值不值得做,先看它解决什么问题
「java ssm jsp 健身房管理系统源码」是 Java Web 课程设计与毕业论文里出现频率极高的一类项目:后端用 Spring + SpringMVC + MyBatis 搭增删改查,页面用 JSP 渲染,数据库固定是 MySQL,部署到 Tomcat 就能跑。它要解决的是健身房线下业务的信息化:开卡、续费、停卡、课程预约、场地预约、消费记录,外加一个把管理员和前台分开的后台权限。如果你想在一两周内交付一个能演示、能答辩的 SSM 项目,这个方向比从零做商城或论坛更聚焦,数据关系清晰,功能边界也明确。但先泼一盆冷水:网上这类源码多数不缺功能图,缺的是能跑起来的配置和你能改得动的数据结构。所以拿到任何一份源码,第一件事不是读代码,而是先跑通,再把表拆开看。
2. 把健身房业务拆成数据模型:会员、卡类型、课程预约这三张表是命根子
SSM 项目里最容易让新手栽跟头的不是 Controller,而是表设计。健身房管理系统的业务看起来多,实际上核心只有三个对象:会员、卡类型、课程/场地预约。大多数功能页面都是围绕这三者做增删改查和统计。先想清楚表与表之间的关系,再写代码,后面每个模块都能少改一遍。
2.1 会员表与卡类型表:为什么不建议给会员直接加一个「剩余次数」字段
很多初版源码会这样设计会员表:把卡类型、开卡日期、到期日期、剩余次数全部塞进会员表里,看起来查询方便,实际一上线就出问题。比如你卖的是「季卡 + 20 次私教」这种组合套餐,剩余次数和有效期是两套逻辑;又比如到期续卡后,老卡的历史记录还要保留。正确的做法是把卡类型单独建表,会员表只保存「当前正在使用的卡类型 ID」,卡次数和有效期跟着会员卡记录走。
card_type(卡类型表):
CREATE TABLE card_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(50) NOT NULL COMMENT '名称:月卡/季卡/次卡', price DECIMAL(10,2) NOT NULL, valid_days INT DEFAULT 0 COMMENT '有效期天数,0表示不限', total_times INT DEFAULT -1 COMMENT '总次数,-1表示不限次', expire_remind_days INT DEFAULT 3 COMMENT '到期前提醒天数', status TINYINT DEFAULT 1 COMMENT '1启用 0停用' );member(会员表):
CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT, member_no VARCHAR(20) NOT NULL UNIQUE COMMENT '会员卡号,前台刷卡用', name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 1, phone VARCHAR(20), card_type_id INT NOT NULL, start_date DATE NOT NULL, expire_date DATE, remain_times INT DEFAULT -1 COMMENT '剩余私教/次卡次数', balance DECIMAL(10,2) DEFAULT 0 COMMENT '储值余额', status TINYINT DEFAULT 1 COMMENT '1正常 2停卡 3到期', remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (card_type_id) REFERENCES card_type(id) );这里有两个参数需要特别说明。valid_days和total_times属于「卡规则」,而expire_date和remain_times属于「会员当前状态」,规则放卡类型表,状态放会员表;如果将来出活动赠送次数,就不需要动表结构。member_no要设唯一索引,因为前台操作基本靠卡号定位会员,没有唯一索引的话并发开卡时会生成重复卡号。
2.2 场地与课程预约:用一条预约记录同时表达「谁、什么时段、哪个场地」
健身房场景里,私教课和团操课都需要预约,但预约的资源不同:私教课预约的是教练的空闲时间,团操课预约的是操房 + 时间段。一些源码会把私教预约和场地预约分开建两张表,结果查询「某天某人的全部行程」时还要 UNION 两次。更常见的做法是统一成一张 booking 表,用 biz_type 区分对象,再用 course_id 指向课程表。如果场地要精细到器械,比如「跑步机 3 号」,就在 location 字段里存设备编号。
CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, course_type TINYINT COMMENT '1私教 2团操', coach_id INT NOT NULL COMMENT '教练ID', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, location VARCHAR(100) COMMENT '场地或设备编号', max_students INT DEFAULT 1 COMMENT '私教为1,团操为最大人数', price DECIMAL(10,2) DEFAULT 0, status TINYINT DEFAULT 1 COMMENT '1可预约 2满员 3取消' ); CREATE TABLE booking ( id INT PRIMARY KEY AUTO_INCREMENT, booking_no VARCHAR(30) NOT NULL, member_id INT NOT NULL, course_id INT NOT NULL, biz_type TINYINT COMMENT '1私教 2团操', status TINYINT DEFAULT 1 COMMENT '1已预约 2已上课 3取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_member_course (member_id, course_id) COMMENT '同一会员同一节课只能预约一次' );预约冲突检查不建议依赖数据库唯一索引,尤其是时间重叠的场景。数据库只能保证「同一个人不重复约同一节课」,但保证不了「同一个教练在同一时间段没有被约两节课」,这类冲突必须用一条带条件的 SQL 在事务里检查。对教练时间,查询course表里coach_id相同、start_time小于新课程的结束时间、end_time大于新课程开始时间,且状态不是取消的记录,有记录就拒绝。
2.3 MyBatis 动态 SQL:列表查询的三段式写法
SSM 项目里最常写的 SQL 是会员列表的多条件查询:按姓名/电话模糊搜索,按卡类型筛选,按到期状态筛选。MyBatis 的<where>配合<if>比拼字符串靠谱,不会出现「WHERE AND」这种经典报错。下面的写法可以直接抄进 mapper.xml:
<select id="findMemberPage" resultType="com.gym.entity.Member"> SELECT m.*, ct.type_name AS cardTypeName FROM member m LEFT JOIN card_type ct ON m.card_type_id = ct.id <where> <if test="keyword != null and keyword.trim() != ''"> AND (m.name LIKE CONCAT('%', #{keyword}, '%') OR m.phone LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="cardTypeId != null and cardTypeId != 0"> AND m.card_type_id = #{cardTypeId} </if> <if test="expireStatus != null and expireStatus == 1"> AND m.expire_date < NOW() </if> <if test="expireStatus != null and expireStatus == 2"> AND m.expire_date > NOW() </if> </where> ORDER BY m.create_time DESC </select>注意三个细节。第一,<和>是 XML 文件里小于号大于号的转义写法,直接写<会导致 XML 解析错误。第二,LIKE CONCAT('%', #{keyword}, '%')比直接写'%${keyword}%'安全,${}会做字符串拼接,存在 SQL 注入风险。第三,cardTypeName这个别名要跟实体类字段对应,如果开启了下划线转驼峰配置,数据库字段card_type_id才能自动映射到cardTypeId。
分页可以引入 PageHelper 插件,注意它必须在查询 SQL 之前一行指定分页参数:
PageHelper.startPage(pageNum, pageSize); List<Member> list = memberMapper.findMemberPage(query); PageInfo<Member> pageInfo = new PageInfo<>(list);PageHelper的原理是拦截下一次查询,自动拼接LIMIT。如果你在调用startPage和查询之间多写了一条无关的 SQL,分页参数就会作用到那条 SQL 上,列表查询失去分页效果。这是很多源码莫名翻车的高发点。
3. 用 SSM 把登录、权限和后台骨架搭起来:从登录拦截器到 JSP 列表页
数据模型定了以后,后端骨架就清晰了。SSM 的职责分配是:Spring 管 Bean,SpringMVC 管请求路由,MyBatis 管 SQL。健身房管理系统的后台通常有三类角色:管理员、前台、教练,菜单权限并不复杂,用手写拦截器 + Session 角色判断就够,不需要为这个项目单独引 Shiro。
3.1 登录拦截器:为什么用 Interceptor 而不是在每个 JSP 里判断 session
很多课程设计源码会用 Filter 做登录校验,或者直接在 JSP 顶部写if (session.getAttribute("loginUser") == null) response.sendRedirect("login.jsp")。这两种写法都有问题:Filter 能拦请求,但对 SpringMVC 的 Handler 和注解没有感知;JSP 里散落判断代码则是把安全逻辑写进视图层,想改一个统一跳转路径要翻十几个页面。用 SpringMVC 的HandlerInterceptor更可控。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、验证码、静态资源 String uri = request.getRequestURI(); if (uri.contains("/login") || uri.contains("/captcha") || uri.endsWith(".css") || uri.endsWith(".js")) { return true; } Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { // 使用重定向,避免在 iframe 里返回登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } // 教练角色只能访问 /member/ 和 /course/ 的部分页面,这里做二级校验 User user = (User) loginUser; if ("coach".equals(user.getRole()) && (uri.contains("/stat") || uri.contains("/user"))) { response.sendRedirect(request.getContextPath() + "/403"); return false; } return true; } }在 SpringMVC 配置里注册这个拦截器:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/captcha"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.gym.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>先配置mapping path="/**"把全部请求纳入拦截,再通过exclude-mapping排除登录页和静态资源。这里要注意静态资源的排除路径必须和<mvc:resources mapping="/static/**" location="/static/"/>的映射一致,否则拦截器会挡住 Bootstrap 和 jQuery,登录页样式全崩。response.sendRedirect要加上request.getContextPath()前缀,很多项目在 IntelliJ IDEA 默认部署路径下没问题,部署到 Tomcat 根路径以外的环境时缺了前缀就会 404。
3.2 会员列表页:从 Controller 到 JSP 的整条链路
SSM 的项目源码,最有参考价值的不是单个文件,而是「请求怎么走」的完整链路。以会员列表为例:浏览器访问/member/list,SpringMVC 根据@RequestMapping找到 Controller 方法,Controller 调 Service,Service 调 Mapper,最后返回 ModelAndView,JSP 拿到数据渲染。
Controller 写法:
@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, Integer cardTypeId, Integer expireStatus, Model model) { MemberQuery query = new MemberQuery(); query.setKeyword(keyword); query.setCardTypeId(cardTypeId); query.setExpireStatus(expireStatus); PageInfo<Member> page = memberService.findMemberPage(pageNum, pageSize, query); model.addAttribute("page", page); model.addAttribute("kw", keyword); return "member/list"; } }JSP 页面用 JSTL 循环渲染:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ 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-hover"> <thead> <tr> <th>卡号</th><th>姓名</th><th>卡类型</th> <th>到期日</th><th>状态</th><th>操作</th> </tr> </thead> <tbody> <c:forEach var="m" items="${page.list}"> <tr> <td>${m.memberNo}</td> <td>${m.name}</td> <td>${m.cardTypeName}</td> <td><fmt:formatDate value="${m.expireDate}" pattern="yyyy-MM-dd"/></td> <td> <c:choose> <c:when test="${m.status == 1}"><span class="label label-success">正常</span></c:when> <c:when test="${m.status == 2}"><span class="label label-warning">停卡</span></c:when> <c:otherwise><span class="label label-danger">到期</span></c:otherwise> </c:choose> </td> </tr> </c:forEach> </tbody> </table>如果没引入 JSTL 的依赖,<%@ taglib %>这一行会直接报错。Maven 里要同时引入jstl和javax.servlet.jsp.jstl两个坐标,光加一个standard不够。${m.memberNo}能取到值的前提是实体类字段名和 JavaBean 规范一致,MyBatis 的mapUnderscoreToCamelCase默认是 false,数据库列member_no不会自动映射到memberNo,这就是大量列表页显示空白的根源之一。
3.3 六个必调配置参数表:每个参数什么时候改
SSM 项目翻车,一半以上翻在配置文件。把这六个位置的参数背下来,拿到任何一份源码都能快速定位问题。
| 配置项 | 位置 | 常见值 | 什么时候要改 |
|---|---|---|---|
mapUnderscoreToCamelCase | MyBatis 全局配置或 mybatis-config.xml | true | 实体类使用驼峰字段名时必须开,否则查询结果全是 null |
base-package | context:component-scan | com.gym.controller等 | 新加的 Controller/Service 包不在扫描范围内时,启动找不到 Bean |
InternalResourceViewResolver | spring-mvc.xml | prefix/WEB-INF/views/,suffix.jsp | Controller return 的字符串跟 JSP 实际路径对不上时 404 |
maxActive/maxWait | Druid 或 C3P0 连接池 | 初始 10 / 最大 50 / 超时 10s | 多人演示同时点刷新时连接不够,或启动时报wait millis超时 |
serverTimezone | JDBC URL | Asia/Shanghai | 数据库时间字段比当前时间差 8 小时,或者TIMESTAMP取值报错 |
mvc:resources | spring-mvc.xml | mapping/static/** | 页面样式加载不出来、CSS 返回 404 时先看这个映射是否存在 |
这里挑两个值得细说的。第一,mapUnderscoreToCamelCase在很多源码里是写在mybatis-config.xml的<settings>,但有些 Maven 依赖版本较老的 SSM,这个配置放在 spring-mybatis.xml 的configurationProperties里也能生效;两份配置同时存在时以 MyBatis 原生配置为准,容易造成「我这改了怎么没反应」的困惑。第二,serverTimezone只解决 JDBC 连接层时区,不解决 Java 代码里new Date()传给 MySQL DATETIME 时的精度问题,建议所有时间字段都用 DATETIME,不要混用 TIMESTAMP。连接池和事务的配合也需要说明一下:Druid 在 Spring 配置里通常由DruidDataSource接管,事务注解用<tx:annotation-driven transaction-manager="transactionManager"/>,如果漏配事务管理器,@Transactional不会报错,但数据回滚静默失效。
4. JSP 与 SSM 联调的常见问题排查:从 404 到改库不生效
这一章是给那些「源码已经跑起来,但改一个页面就出问题」的人准备的。SSM + JSP 的问题往往不是单个文件写错,而是 Tomcat 部署方式、JSP 编译缓存、数据库连接、MyBatis 映射四个层面叠加在一起。写几条我踩过且高频复现的记录,每条都按「现象 → 原因 → 解决」说明。
4.1 页面能打开但列表却一直是空白,控制台没有任何报错
现象:后台登录成功,点击「会员管理」进入列表页,标题和搜索框正常显示,但表格区域空白,浏览器 F12 里 Network 状态 200,后端控制台也没有异常。
原因:这个现象 80% 是 MyBatis 返回结果没映射到实体类。数据库列是member_no、card_type_id这种下划线命名,而 Java 实体类字段是memberNo、cardTypeId,全局驼峰映射没有开启,resultType只能按字段名自动匹配,匹配不上就全部给 null。数据其实是查出来了,但 JSP 拿到的是「有 List 但每个对象的属性都是 null」的页面,所以渲染出来就是空白。另一种隐蔽的可能:Controller 里 return 的是member/list,但 JSP 文件实际放在/WEB-INF/views/member/list.jsp,视图解析器前缀写错时会 404,可这里的现象是 200,所以不是路径问题。
解决:先检查 mybatis-config.xml 或 spring-mybatis.xml 配置:
<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings>如果项目中还用了mybatis-spring-boot-starter风格的配置,则检查application.properties里的mybatis.configuration.map-underscore-to-camel-case=true。修改后重启 Tomcat,再把列表页的${m.memberNo}临时改成${m.member_no}测试一次;后者能显示而前者不能,就可以确认是映射开关没生效。
4.2 启动后运行几分钟就报「Connection is not available, request timed out」
现象:项目刚启动时正常,操作几次后突然卡顿,然后控制台抛异常,关键词是connection、timed out、wait millis。
原因:连接池参数设置不合理。常见源码里 Druid 的maxActive只有 20,maxWait默认 -1 或很短,而新增的页面里有人在一个for循环里反复调 Mapper 查数据,每次查到结果都不释放连接。当连接池被占满时,新请求排队等待,等待时间超过maxWait就直接抛异常。这个问题的本质不是连接池太小,而是代码里连接没有及时归还。Druid 默认removeAbandoned是 false,如果源码里没有开启,异常连接会一直占用池子。
解决:连接池参数短期可以调大,但更要改代码。
initialSize=10 minIdle=10 maxActive=100 maxWait=10000 timeBetweenEvictionRunsMillis=30000 validationQuery=SELECT 1 testWhileIdle=true testOnBorrow=false removeAbandoned=true removeAbandonedTimeout=180maxActive=100对健身房管理系统这种课设项目绰绰有余,removeAbandoned会在连接被占用超过 180 秒后强制回收。代码层面要避免循环内查库,比如批量导入会员时不要让每个用户都触发一次findByPhone,可以先查一次全量号码集合,内存里判断是否存在,这只在网上流传的源码里非常常见。注意removeAbandonedTimeout单位是秒,不要填 1800,否则连接泄露会拖很久才暴露。
4.3 修改 JSP 后刷新页面还是旧内容,样式改了也不生效
现象:在list.jsp里加了一列,重启 Tomcat 后页面没变化;把 CSS 文件里的颜色改掉,浏览器刷新后还是原来颜色。
原因:JSP 文件在 Tomcat 运行时会被编译成 Servlet 类,如果部署方式是「把 WAR 包解压到 webapps」,且 Tomcat 的reloadable设置为 false,那么 JSP 文件被覆盖后 Tomcat 不会重新编译。而 CSS、JS 这些静态资源的缓存问题更多是浏览器端的:.css文件 URL 没变化时,浏览器直接用本地缓存,不做协商请求。很多课程设计项目没有单独处理静态资源版本号,改样式后发现不生效是常态。
解决:开发阶段在 Tomcat 的conf/server.xml里,对应 Host 的Context上设置reloadable="true";部署 WAR 时尽量用「exploded」方式,直接把解压后的目录作为 docBase。静态资源版本号给到 JSP 页面的引用链接上:
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/main.css?v=20260201">每次大改升级一次v参数,既能绕过缓存,也不需要在源码里引入额外插件。样式完全不加载的另一种可能是mvc:resources没有放行/static/**,这个前面已经提过,排查优先级比缓存更高。
4.4 数据库时间字段差 8 小时,或者页面日期格式化报IllegalArgumentException
现象:会员开卡后,列表页显示的到期日比实际晚 8 小时;另一类情况是后台取到的时间字段是java.sql.Timestamp,在前端直接输出出现Cannot convert value of type 'java.sql.Timestamp'之类的类型异常。
原因:JDBC 连接串没有指定时区,MySQL 8 默认使用服务器时区,而 Tomcat 运行环境是系统默认时区,两边不一致就会偏 8 小时。java.sql.Timestamp报错则是因为 JSP 用<fmt:formatDate>格式化时,后端模型里传给页面的对象不是java.util.Date,或者该字段已经在前端被当作字符串处理。
解决:JDBC URL 上统一加上时区参数,这是最稳的做法。
jdbcUrl=jdbc:mysql://localhost:3306/gym?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai代码里统一用java.util.Date接收数据库的时间字段,MyBatis 的DateTypeHandler会把TIMESTAMP转成Date,避免直接暴露Timestamp。这里还有一个细节:如果你在实体类里混用了LocalDateTime和Date,而且 MyBatis 版本在 3.4 以下,时间转换会直接报错;SSM 项目里不要贪图LocalDateTime的现代 API,实体类时间字段全部用Date最省心。
5. 把这套源码从「能跑」推到「能答辩」:加一个营业统计接口和一个导出功能
前面的内容都在解决「怎么跑起来、怎么改得动」,最后一章聊怎么让它从「一个能演示的课设」变成「答辩时经得起追问的完整系统」。健身房管理系统最常见的短板是统计报表:页面上只有增删改查,缺少经营者真正关心的「本月收入、续卡率、热门课程」这些数据。与其做一个复杂的大屏,不如先把一个营业统计接口做扎实。
我一般会在原有项目里新增一个StatsController,返回 JSON,而不是直接再渲染一整页 JSP。这样做的原因是:答辩时老师最常问的问题是「如果我想看某个月的各项收入占比,你怎么改?」如果你把统计逻辑全部写在 Controller 里,并且参数写死,现场只能翻代码;如果先做一个接收year、month参数的接口,改动只在一个方法里。
@Controller @RequestMapping("/stat") public class StatsController { @Autowired private StatsService statsService; @ResponseBody @RequestMapping("/monthly") public Result monthly(@RequestParam Integer year, @RequestParam(defaultValue = "0") Integer month) { Map<String, Object> data = statsService.monthlyOverview(year, month); return Result.ok(data); } }对应的 MyBatis 查询只写一条SUMSQL,按卡类型分组统计当月开卡收入和私教课时收入,返回给前端。JSP 页面用 jQuery 的$.get拉取这个接口,渲染成一个简单的表格或图表。这样做既保留了原有 JSP 项目的风格,又把数据逻辑和页面渲染分开了,后续新增统计维度只是加一个 SQL 分组条件。如果源码里用的是非常老的 JSP 脚本片段<%= %>输出,建议尽量把这类代码改成 JSTL 或 AJAX,答辩时最容易因为页面里嵌了大量 Java 代码而被追问「MVC 分层体现在哪里」。
导出功能同样是个性价比高的加分项。给会员列表加一个「导出 Excel」按钮,用 Apache POI 的 SXSSFWorkbook 而不是 XSSFWorkbook,因为前者能控制内存占用,导出超过 3 行数据不会因为堆内存溢出让整个 Tomcat 卡死。最关键的是导出参数和列表查询共用同一个MemberQuery,确保页面筛什么,导出就是什么,否则答辩时现场演示「筛完再导出」马上露馅。
我做过一个健身房管理系统的教训:当时为了赶进度,把统计报表做成了 JSP 里一大段<% Map<String, Object> data = ... %>,答辩演示时老师让我现场把统计维度从「按月」改成「按周」,我只能低头改代码,场面非常尴尬。后来我把所有统计查询都收敛到StatsService,Controller 只负责接收参数名和返回类型,才彻底解决了这类问题。如果你正在改一套现成的 SSM 源码,与其把时间花在纠结 UI 细节,不如先检查这些「接口边界」——查询条件有没有被多个页面复用、时间参数能不能动态传入、导出和列表是不是同一套查询。这三个点都立住了,这套健身房管理系统源码才算真正变成你自己的东西。希望帮到你。
本文还有配套的精品资源,点击获取