简介:面向健身俱乐部会员管理场景,这份基于Java+JSP+MySQL的BS架构项目源码与文档包,适合计算机专业课程设计、毕业设计,以及希望系统学习Java Web开发的初学者参考。系统围绕会员信息管理、课程预约、教练排班、收费统计等典型业务模块展开,完整呈现从数据库设计、后台逻辑处理到前端页面交互的Java Web项目实现路径。压缩包整体约85.97MB,内含项目全套源码和完整文档说明,源码经过调试可正常运行,省去了环境搭建和编码排错的摸索时间。目前已有1139人浏览学习,具备较好的参考热度。借助源码注释和配套文档,可深入理解JSP+Servlet操作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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='办卡/续费订单流水表';金额必须用 DECIMAL(10,2),不用 FLOAT。浮点型存 19.99 在比较时会得到 19.989999 这类结果,微信、支付宝对账单出来之后每一分钱都要手工核,成本远高于表结构设计时多打几个字符。t_order.end_date 是冗余字段,冗余在这里不是缺点,而是为了月报和到期提醒能直接查字段,不用每次靠函数计算。联合索引 idx_member_time 服务“某个会员的历史订单”和“最近一笔订单”两类访问场景。
三张表的分工可以用一张表概括。
| 数据表 | 主键语义 | 高频查询条件 | 报表统计字段 |
|---|---|---|---|
| t_member | 会员ID | phone, status, card_end | points, created_at |
| t_card_type | 卡种ID | is_active | price |
| t_order | 订单ID | member_id, order_time | amount, end_date |
2.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,统一入口能减少一套分支。所有业务规则都封装在 MemberService,Servlet 层只做参数接收、设置 request 属性和转发,保持薄薄一层,源码阅读和后续维护都省力。
3.2 连接池配置与 JDBC 连接生命周期
数据库连接不能每次请求都现建,项目里用 Druid 连接池做复用。resources 目录下的 druid.properties 可以这样配置:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/fitness_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=fitness@2024 initialSize=5 maxActive=20 maxWait=60000 validationQuery=SELECT 1对这套系统来说,有几个参数在部署前值得单独说明。
| 参数 | 推荐值 | 说明与失效场景 |
|---|---|---|
| initialSize | 5 | 连接池启动即建立,太小会在流量尖峰时突发建连 |
| minIdle | 5 | 低于空闲下限会触发回收,应与 initialSize 协调 |
| maxActive | 20 | 超过后连接交给 maxWait 排队,过大容易占满 MySQL 连接 |
| maxWait | 60000 | 等待超时抛异常,设 0 会让请求无限阻塞,拖死线程池 |
| validationQuery | SELECT 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 执行完再 commit,catch 中 rollback,finally 中把连接还回池里。这里conn.close()并不真正断开数据库,而是归还连接,所以事务状态必须手动重置,否则下一次借出连接时可能复用到脏状态。
注意:MySQL 5.7 时代驱动类名是 com.mysql.jdbc.Driver,8.x 已改为 com.mysql.cj.jdbc.Driver。迁移项目时先确认驱动版本,再决定 properties 里的 driverClassName。
3.3 JSP 页面渲染:JSTL 标签输出列表,不写 Java 脚本
在 JSP 页面里写<% %>脚本片段,是早期项目最常见的坏味道,后期改样式都要小心运行异常。用 JSTL 标签配合 EL 表达式,列表页的核心循环可以这样展示:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/></head> <body> <table border="1" cellpadding="6"> <tr><th>姓名</th><th>手机号</th><th>到期日</th><th>状态</th></tr> <c:forEach items="${memberList}" var="m"> <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-name>encodingFilter</filter-name> <filter-class>com.fitness.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</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 "); List<Object> 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 天体验卡并完成付款。然后执行两条核对 SQL:
SELECT 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 里的字段名映射。用这两个验证用例覆盖开卡和续费两条主链路,大部分事务与日期计算问题都能暴露出来,这组验证步骤也可以随项目文档一起交付,验收时按顺序执行即可。
本文还有配套的精品资源,点击获取