基于Java+JSP+MySQL的健身俱乐部会员管理系统实战解析

发布时间:2026/9/11 16:32:36
基于Java+JSP+MySQL的健身俱乐部会员管理系统实战解析 简介面向健身俱乐部会员管理场景这份基于JavaJSPMySQL的BS架构项目源码与文档包适合计算机专业课程设计、毕业设计以及希望系统学习Java Web开发的初学者参考。系统围绕会员信息管理、课程预约、教练排班、收费统计等典型业务模块展开完整呈现从数据库设计、后台逻辑处理到前端页面交互的Java Web项目实现路径。压缩包整体约85.97MB内含项目全套源码和完整文档说明源码经过调试可正常运行省去了环境搭建和编码排错的摸索时间。目前已有1139人浏览学习具备较好的参考热度。借助源码注释和配套文档可深入理解JSPServlet操作MySQL、会话管理、权限控制等核心技术也能直接复用其中类、表结构与功能模块为同类管理系统设计提供扎实基础。1. 基于 Java JSP MySQL 的健身俱乐部会员管理系统能解决什么基于 Java JSP MySQL 的健身俱乐部会员管理系统核心价值不是把 Excel 换成网页而是把会员档案、卡种价格、订单流水放到同一套 B/S 结构里连续维护。中小健身场馆最常做的就是“报手机号查会员、开卡续费、月底看收入”拆开看都是增删改查但遇到重复注册、提前续费、过期提醒、中文乱码这些边界问题时需要数据库设计和 Java 事务一起兜底。JSP 直接承接页面渲染Servlet 做请求分发MySQL 负责持久化三者组合学习曲线低源码和文档交付时也比较容易被评审看懂。这套技术栈尤其适合课程设计也适合刚学完 Java Web、想完整把项目跑通一遍的人照着上手。下面从表结构设计开始讲到事务、分页、乱码和部署验证全程以可复现为第一目的。2. 用 MySQL 建模会员、卡种与订单表2.1 会员表手机号检索字段为什么用 VARCHAR会员表是这套系统的数据底座前台绝大多数查询都从这里发起。设计字段时先列业务动作输手机号调会员、看是否到期、办理续费、操作冻结。围绕这些动作字段控制在 10 个左右不要加“紧急联系人”“备用手机号”这类低频冗余。关键的类型取舍如下。CREATE TABLE t_member ( member_id INT UNSIGNED AUTO_INCREMENT COMMENT 会员ID, name VARCHAR(50) NOT NULL COMMENT 会员姓名, gender TINYINT NOT NULL DEFAULT 0 COMMENT 0未知 1男 2女, phone VARCHAR(20) NOT NULL COMMENT 手机号登录与检索用, birthday DATE COMMENT 出生日期, card_type_id INT UNSIGNED DEFAULT NULL COMMENT 逻辑外键指向 t_card_type, card_start DATE DEFAULT NULL COMMENT 当前卡生效日期, card_end DATE DEFAULT NULL COMMENT 当前卡到期日期, points INT NOT NULL DEFAULT 0 COMMENT 累计积分, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0冻结 2注销, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 建档时间, PRIMARY KEY (member_id), KEY idx_phone (phone), KEY idx_card_end (card_end) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员基本信息表;手机号用 VARCHAR(20) 而不是 BIGINT是这套表结构里最容易被忽略的设计决策。数字类型会去掉前导零遇到带“86”前缀、短横线或临时号码时直接报错更麻烦的是后续接短信平台时号码都是字符串拼接查询条件写成字符串后BIGINT 字段会产生隐式类型转换导致 idx_phone 索引失效。用 VARCHAR 看似少了一点“严谨”却能让前台和报表层少处理很多转换逻辑。gender 和 status 用 TINYINT 存语义值业务代码里定义常量比较即可。比如页面显示性别或会员状态时c:if test${member.status 1}直接比较整型比比较汉字字符串更稳也不会因为后台改了一个字导致页面判断失效。生日存 DATE 而不存年龄段年龄统计由当前年份减出生年份计算不要在表里冗余“年龄段”否则每过一个季度报表逻辑就要跟着改一遍。索引方面idx_phone 服务前台等值查询idx_card_end 服务“未来 7 天到期会员”这类报表。字段不多时索引控制在 4 个以内即可像 TINYINT 的 status 就没必要单独建索引选择性太低插数据时反而要额外维护 B 树。2.1.1 为什么不建物理外键t_member.card_type_id 不声明 FOREIGN KEY是因为换卡、卡种下架这些操作要求应用层在同一个事务里控制一致性。物理外键会把失败面扩大报错信息也没法直接翻译成前台提示。逻辑外键由 Service 层保证这在 JSP 中小型项目中是更通行的做法排查数据问题时也能直接从 Java 代码下手。2.2 卡种表与订单表订单冗余到期日期金额用 DECIMAL卡种表定义卖什么订单表记录每次办卡和续费行为。会员当前持有什么卡看 t_member 的 card_type_id 和 card_end每一笔钱什么时候收的看 t_order。CREATE TABLE t_card_type ( card_type_id INT UNSIGNED AUTO_INCREMENT COMMENT 卡种ID, type_name VARCHAR(30) NOT NULL COMMENT 卡种名称如年卡/季卡, valid_days INT NOT NULL COMMENT 开卡后有效天数, price DECIMAL(10,2) NOT NULL COMMENT 售价, gift_points INT DEFAULT 0 COMMENT 赠送积分, is_active TINYINT DEFAULT 1 COMMENT 1可售 0下架, PRIMARY KEY (card_type_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员卡种表; CREATE TABLE t_order ( order_id INT UNSIGNED AUTO_INCREMENT COMMENT 订单号, member_id INT UNSIGNED NOT NULL COMMENT 会员ID, card_type_id INT UNSIGNED NOT NULL COMMENT 卡种ID, amount DECIMAL(10,2) NOT NULL COMMENT 实付金额, pay_method TINYINT DEFAULT 0 COMMENT 0现金 1微信 2支付宝, order_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 下单时间, start_date DATE NOT NULL COMMENT 卡生效日期, end_date DATE NOT NULL COMMENT 卡到期日期, operator_id INT UNSIGNED DEFAULT NULL COMMENT 操作员工号, PRIMARY KEY (order_id), KEY idx_member_time (member_id, order_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT办卡/续费订单流水表;金额必须用 DECIMAL(10,2)不用 FLOAT。浮点型存 19.99 在比较时会得到 19.989999 这类结果微信、支付宝对账单出来之后每一分钱都要手工核成本远高于表结构设计时多打几个字符。t_order.end_date 是冗余字段冗余在这里不是缺点而是为了月报和到期提醒能直接查字段不用每次靠函数计算。联合索引 idx_member_time 服务“某个会员的历史订单”和“最近一笔订单”两类访问场景。三张表的分工可以用一张表概括。数据表主键语义高频查询条件报表统计字段t_member会员IDphone, status, card_endpoints, created_att_card_type卡种IDis_activepricet_order订单IDmember_id, order_timeamount, end_date2.3 统计与报表日期条件别写成对字段套函数月底财务要的是“当月收入、当月续费人数、当前在册会员数”。收入汇总最直接的写法是按月分组SELECT DATE_FORMAT(order_time, %Y-%m) AS ym, COUNT(*) AS orders, SUM(amount) AS revenue FROM t_order WHERE order_time 2025-03-01 AND order_time 2025-04-01 GROUP BY ym ORDER BY ym DESC;WHERE 条件这里用的是半开区间 [start, end)也就是含头不含尾。不要写BETWEEN 2025-03-01 AND 2025-03-31这样会把 3 月 31 日 23:00 之后的订单漏掉。另一个新手高频错误是写成DATE_FORMAT(order_time, %Y-%m) 2025-03作为过滤条件一旦对字段套了函数idx_member_time 就无法命中月底跑汇总直接全表扫描。更稳的做法是在 Java 侧把月份边界算成两个准确的时间点再作为参数传入 SQL。“下个月到期会员”的查询同样走边界条件SELECT member_id, name, phone, card_end FROM t_member WHERE card_end 2025-04-01 AND card_end 2025-05-01 AND status 1;这套查询充分利用了 t_member 上的 idx_card_end 索引。无需在应用里再写定时任务反复扫全表到期提醒页面每次刷新时直接执行这条 SQL 即可。3. Java 到 JSP 的数据链路Servlet、连接池、JSTL 与编码过滤器3.1 请求入口WebServlet 如何承接与分派JSP 页面本身不处理业务逻辑请求先落到 Servlet。这套 BS 项目的常见路由方式是把同一模块的多个操作挂在统一路径前缀下用路径信息区分动作。具体实现WebServlet(/member/*) public class MemberServlet extends HttpServlet { private final MemberService memberService new MemberService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handle(req, resp); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { handle(req, resp); } private void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getPathInfo(); if (/list.equals(action)) { String keyword req.getParameter(keyword); req.setAttribute(memberList, memberService.listMembers(keyword)); req.getRequestDispatcher(/WEB-INF/jsp/member/list.jsp) .forward(req, resp); } else if (/detail.equals(action)) { MemberVO member memberService.getById(req.getParameter(memberId)); req.setAttribute(member, member); req.getRequestDispatcher(/WEB-INF/jsp/member/detail.jsp) .forward(req, resp); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } }这段代码里有几个要点。JSP 文件放在 WEB-INF/jsp 目录下浏览器无法在地址栏直接访问只能由 Servlet 转发进入这就是最基础的访问控制。doGet 和 doPost 都走 handle是因为地址栏回车产生 GET表单提交则可能是 POST统一入口能减少一套分支。所有业务规则都封装在 MemberServiceServlet 层只做参数接收、设置 request 属性和转发保持薄薄一层源码阅读和后续维护都省力。3.2 连接池配置与 JDBC 连接生命周期数据库连接不能每次请求都现建项目里用 Druid 连接池做复用。resources 目录下的 druid.properties 可以这样配置driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/fitness_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai usernameroot passwordfitness2024 initialSize5 maxActive20 maxWait60000 validationQuerySELECT 1对这套系统来说有几个参数在部署前值得单独说明。参数推荐值说明与失效场景initialSize5连接池启动即建立太小会在流量尖峰时突发建连minIdle5低于空闲下限会触发回收应与 initialSize 协调maxActive20超过后连接交给 maxWait 排队过大容易占满 MySQL 连接maxWait60000等待超时抛异常设 0 会让请求无限阻塞拖死线程池validationQuerySELECT 1防止断连残留被继续使用连接池初始化代码可以封装成工具类public class DruidUtils { private static DruidDataSource ds; static { try (InputStream in DruidUtils.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(in); ds (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }注意getResourceAsStream在 war 包中依然能读取 classes 目录下的文件这是连接池能正常初始化的前提。事务边界放在 Service 层拿到连接后马上setAutoCommit(false)所有 SQL 执行完再 commitcatch 中 rollbackfinally 中把连接还回池里。这里conn.close()并不真正断开数据库而是归还连接所以事务状态必须手动重置否则下一次借出连接时可能复用到脏状态。注意MySQL 5.7 时代驱动类名是 com.mysql.jdbc.Driver8.x 已改为 com.mysql.cj.jdbc.Driver。迁移项目时先确认驱动版本再决定 properties 里的 driverClassName。3.3 JSP 页面渲染JSTL 标签输出列表不写 Java 脚本在 JSP 页面里写% %脚本片段是早期项目最常见的坏味道后期改样式都要小心运行异常。用 JSTL 标签配合 EL 表达式列表页的核心循环可以这样展示% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headmeta http-equivContent-Type contenttext/html; charsetUTF-8//head body table border1 cellpadding6 trth姓名/thth手机号/thth到期日/thth状态/th/tr c:forEach items${memberList} varm tr td${m.name}/td td${m.phone}/td td${m.cardEnd}/td td c:choose c:when test${m.status 1}正常/c:when c:when test${m.status 0}冻结/c:when c:otherwise注销/c:otherwise /c:choose /td /tr /c:forEach /table /body /html${m.name}实际调用的是 MemberVO 的 getName() 方法而不是直接读取私有字段。后台塞入实体后页面却输出空白绝大多数情况是 getter 缺失或属性名拼写不一致这类错误在编译期发现不了只能靠运行日志定位。标签库也有前提WEB-INF/lib 下必须有 jstl 相关 jar否则页面会直接报找不到 taglib uri。c:choose 的结构对应 Java 的 if/else if/else用于状态、性别这类枚举值显示可读性远好于一段一段的 Java 脚本。3.4 编码过滤器POST 乱码一次性解决中文乱码几乎是 JSP 项目的默认见面礼。POST 请求体的编码必须在解析参数之前设定最稳的做法是加一个全局过滤器在 web.xml 里声明filter filter-nameencodingFilter/filter-name filter-classcom.fitness.filter.EncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping实现类只做一件事在请求体被解析前设置 request 和 response 的字符编码然后直接放行。public class EncodingFilter implements Filter { private String encoding UTF-8; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(encoding); response.setCharacterEncoding(encoding); chain.doFilter(request, response); } }注意 filter 的执行顺序与 web.xml 声明顺序一致编码过滤器必须排在所有过滤器最前。如果权限或 session 过滤器先执行并读了参数乱码就被记住了后面再设置编码也改不回来。这个问题的排查成本集中在源头配置顺序一次性理顺就能避免。4. 办卡、续费、检索与月度报表会员核心业务怎么落地4.1 办卡一个事务同时写会员、订单、回写到期日开卡动作是整套系统的压力测试点多数失败不在 SQL 本身而在于“新增会员、写订单、改卡状态”没有包在同一个事务里。参考 Service 层的实现public boolean applyCard(String name, String phone, Integer cardTypeId) { // 前置校验手机号重复则走续费流程 if (memberDao.existsByPhone(phone)) { return false; } CardTypeVO card cardTypeDao.findById(cardTypeId); if (card null || card.getIsActive() ! 1) { return false; // 卡种不存在或已下架 } LocalDate start LocalDate.now(); LocalDate end start.plusDays(card.getValidDays()); Connection conn null; try { conn DruidUtils.getConnection(); conn.setAutoCommit(false); Long memberId memberDao.insertMember(conn, name, phone); orderDao.insertOrder(conn, memberId, cardTypeId, card.getPrice(), start, end); memberDao.updateCard(conn, memberId, cardTypeId, start, end); conn.commit(); return true; } catch (SQLException e) { log.error(办卡失败 phone{}, phone, e); if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { /* ignore */ } } return false; } finally { if (conn ! null) { try { conn.close(); } catch (SQLException e) { /* ignore */ } } } }这段代码的事务边界用表格可以看得很清楚。步骤失败场景事务结果检查手机号重复前台双人同时提交相同号码直接返回不产生脏数据插入 t_member必填字段为空rollback无残余会员插入 t_order金额或日期非法会员记录一起回滚更新 t_member.card_end事务中途抛异常除保持原子性外无副作用获取自增主键用 PreparedStatement.RETURN_GENERATED_KEYS不要先select max(member_id)1。两个前台同时办卡时后取的号会撞车主键冲突异常一出现整条事务都回滚直接把前台业务流程打断。JDBC 规范里这一套是通用的翻源码或者是照自己手写时都用同样方式。4.2 续费基准日一个分支解决提前续费续费和新开卡相反难点是决定新周期从哪天开始。这套业务有三种状态会员从来没有卡baseDate 当前日期会员卡未到期baseDate 原到期日会员卡已过期baseDate 当前日期。第二种分支是最多初版代码缺失的逻辑。直接写“当前日期加 30 天”也不错但会员提前一个月续年卡实际使用期限变成 13 个月下个月再续就变成 14 个月月度报表收入被逐月冲淡到年底对账时问题集中爆发。修正方式很直接LocalDate today LocalDate.now(); LocalDate baseDate (member.getCardEnd() ! null member.getCardEnd().isAfter(today)) ? member.getCardEnd() : today; LocalDate newEnd baseDate.plusDays(card.getValidDays());接下来在同一个事务里插入一条 t_order并更新 t_member 的 card_end。数据库层面不建议用UPDATE t_member SET card_end DATE_ADD(card_end, INTERVAL ? DAY)一步完成因为续季卡、年卡时有效天数来自卡种参数在 Java 里算好再写入后续表逻辑更直观也方便记录订单明细。4.3 条件检索与分页动态 SQL 的参数封装会员列表页要求支持关键字、状态、分页三个维度。不用 ORM 框架时SQL 动态拼接最实用的写法StringBuilder sql new StringBuilder(SELECT * FROM t_member WHERE 1 1 ); ListObject params new ArrayList(); if (keyword ! null !keyword.isBlank()) { sql.append(AND (name LIKE ? OR phone LIKE ?) ); params.add(% keyword %); params.add(% keyword %); } if (status ! null) { sql.append(AND status ? ); params.add(status); } sql.append(ORDER BY member_id DESC LIMIT ?, ?); params.add(offset); params.add(pageSize);WHERE 1 1的作用是让后面每条条件都能以 AND 开头免去判断“这是不是第一个条件”的冗余分支这是动态 SQL 的惯用法并不存在 SQL 注入问题参数值统一由 PreparedStatement 绑定。手机号建议用等值匹配而不是 LIKE姓名用模糊匹配更符合搜索习惯。分页参数里 offset (page - 1) * pageSize由 Servlet 层根据当前页码计算后传入不要在 SQL 里直接做乘法。4.4 按月报表月份区间在 Java 侧算好SQL 只接常量月底要输出“当月新增会员数、当月实收、当月续费人数”三组数据。这些统计的公共部分是 t_order 的下单时间区间建议在 Java 侧用 YearMonth 计算边界YearMonth ym YearMonth.now(); LocalDateTime start ym.atDay(1).atStartOfDay(); LocalDateTime end ym.plusMonths(1).atDay(1).atStartOfDay();然后查询 SQL 只接收这两个参数SELECT COUNT(DISTINCT member_id) AS renew_member_cnt, -- 当月续费去重会员数 COUNT(*) AS order_cnt, -- 订单总笔数 SUM(amount) AS revenue -- 当月实收金额 FROM t_order WHERE order_time ? -- 月初Java 计算 AND order_time ?; -- 下月初Java 计算COUNT(DISTINCT member_id) 是为了避免同一个月内续了两次费的会员被重复计入“续费人数”。报表查询最怕在 WHERE 里对 order_time 套函数比如写成EXTRACT(MONTH FROM order_time) 3这样联合索引会完全失效。边界值在应用层算好SQL 只做范围比较索引命中报表页打开速度才有保障。5. 在 Tomcat 上部署与验证用两组数据确认续费闭环5.1 部署时最常遇到的三处检查拿到源码往 Tomcat 部署时有三件事比启动按钮更值得先确认。第一mysql-connector-java 的 jar 是否在 WEB-INF/lib 下Maven 项目检查依赖 scope 不是 provided否则启动后直接报驱动类找不到。第二连接串里的 serverTimezone 必须与本地 MySQL 一致不一致时 Druid 初始化阶段就会抛时区异常。第三JSP 页面是第一次被访问时才编译成 class产物落在 Tomcat 的 work/Catalina/localhost/工程名 目录下。修改 JSP 后浏览器仍显示旧内容时优先清理 work 目录下的编译产物比反复清浏览器缓存更直接。IDE 部署时还要确认 “Deploy at server startup” 是勾选状态否则应用根本没有启动 Web 容器访问 localhost 自然 404。5.2 验证一新办卡后会员表与订单表是否同时更新启动完成后跑第一单数据。通过页面新增会员“沈晴”手机号 13800001111选择 30 天体验卡并完成付款。然后执行两条核对 SQLSELECT member_id, name, phone, card_end FROM t_member WHERE phone 13800001111; SELECT order_id, member_id, amount, start_date, end_date FROM t_order ORDER BY order_id DESC LIMIT 1;预期结果是两条记录同时出现会员表里的 card_end 为今天加 30 天订单表里的 end_date 与它一致。如果只出现其中一条说明事务边界没有覆盖全部步骤回到 4.1 检查 commit 位置和 rollback 分支。5.3 验证二提前续费时到期日是否顺延对同一个会员再执行一次续费 30 天。续完后重新执行上面两条查询重点对比两次查询到的 card_end。正确结果是新到期日等于原 card_end 加 30 天如果直接等于当前日期加 30 天说明续费基准日取成了当前时间提前续费应得的天数被吃掉了这个分支就是 4.2 中强调的判断逻辑改完重新部署再跑一次。前端页面上展示的到期日来自 t_member 表数据库里字段正确页面就不可能出错若页面和数据库不一致先查 SQL 里的字段名映射。用这两个验证用例覆盖开卡和续费两条主链路大部分事务与日期计算问题都能暴露出来这组验证步骤也可以随项目文档一起交付验收时按顺序执行即可。本文还有配套的精品资源点击获取