如果要在 Java 入门和毕业设计之间找一个既能练手、又能在答辩时讲清楚的完整项目,健身房会员管理系统是一个很合适的选题。它的业务边界清晰,不像电商系统那样涉及商品、库存、支付、物流等多个复杂链路,同时也不是纯增删改查的玩具项目。开卡、续费、课时扣减、到期提醒等业务背后,需要一套完整的数据结构和事务规则来支撑。这篇文章围绕健身会员管理系统,从功能拆解、数据库设计、核心代码实现、页面联调,到常见报错排查和答辩准备,给出一条可以照着做出来的完整路径。项目技术栈以 Spring Boot + MyBatis + Thymeleaf + MySQL 为主,这也是当前 Java 毕业设计和练手项目中比较主流的组合。
1. 先拆业务:健身房会员管理系统到底要管哪些事
1.1 系统的业务场景和管理对象
在健身房场景里,最核心的管理对象是会员,而不是课程。前台需要登记会员信息,为会员选择卡项并开卡;会员后来续费、停卡、换卡,这些都属于会员生命周期的一部分。课程和私教预约则是建立在“会员有效”这个前提上的增值业务,如果会员的卡已经过期,就不应该再允许约课。
围绕这些对象,系统的业务主线可以概括为:录入会员 -> 购买卡项 -> 开始计卡 -> 使用课程或剩余次数 -> 到期提醒或续费。这条主线对应了数据库里至少五个核心实体:管理员账号、会员、卡项类型、会员持卡记录、缴费记录,以及课程和预约记录。理解这条主线,后面的表结构设计和代码分层就会很清楚。
1.2 功能模块拆解与优先级
开发这类项目时,不建议一开始就把功能铺得很大。合理做法是先完成核心闭环,再逐步补附属功能。建议按以下优先级拆分:
第一优先级是会员和卡管理。包括会员的新增、编辑、停用、列表筛选,卡项类型维护,开卡、续费、卡状态变更。这是系统的地基,答辩时被问得最多的也是这里的数据关系。
第二优先级是课程预约。包括课程创建、课程列表、会员预约、签到和取消。这个模块能体现“状态流转”设计,比普通单表增删改查更有含金量。
第三优先级是提醒和统计。包括会员卡到期提醒、当日课程提醒、会员数量和营收统计。这类功能可以用定时任务或查询 SQL 实现,代码量不大但展示效果明显。
这里要注意,功能拆分要落到具体的页面和接口上,不要只写“会员管理”四个字。每个模块至少要能回答:用户是谁、用什么操作、处理什么数据、改变什么状态。
1.3 技术选型:为什么推荐 Spring Boot + MyBatis + Thymeleaf
对于毕业设计和 Java 练手项目,技术选型的原则是:容易跑通、能回答原理、还能演示分层思想。Spring Boot 解决了配置繁琐的问题,内嵌 Tomcat 让项目启动更简单;MyBatis 能让你手动写 SQL,讲得清楚表关联和条件查询;Thymeleaf 是服务端渲染模板,适合做后台管理页面,避免前后端分离带来的跨域和部署复杂度;MySQL 是毕设最常用数据库,资料也多。
如果用 SSM 的 Spring MVC + Spring + MyBatis 组合,需要手动配置 web.xml、Spring 容器和 MyBatis 工厂,学习成本偏高。用 Spring Boot 并不是绕过这些,而是把常见配置变成约定,你可以把精力放在业务代码上。如果导师要求必须展示 Spring 原理,Spring Boot 项目里同样可以用配置类说明过滤器、拦截器、事务管理器,不影响答辩。
前端方面,后台管理系统通常采用 Bootstrap + jQuery + Thymeleaf 的组合,不需要构建 npm 项目,页面文件放在 templates 下即可。这样项目结构简单,适合单人维护,也方便在笔记本电脑上运行演示。
2. 环境准备与项目骨架
2.1 开发环境与版本对齐
版本对齐是这类项目最容易踩坑的地方。JDK、Spring Boot、MySQL、连接驱动之间必须匹配,否则会出现启动失败、驱动类找不到、加密方式不兼容等问题。
建议一组相对稳妥的组合:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | Spring Boot 2.x 项目常用 |
| Spring Boot | 2.7.x | 稳定且资料多 |
| MyBatis | mybatis-spring-boot-starter 2.x | 与 Spring Boot 2.x 匹配 |
| MySQL | 5.7 或 8.0 | 生产开发常用 |
| MySQL 驱动 | mysql-connector-j 8.x | MySQL 8 官方坐标已调整 |
| Maven | 3.6 以上 | 管理依赖和打包 |
| IDE | IntelliJ IDEA 或 Eclipse | 按个人习惯选择 |
如果你使用 Spring Boot 3.x,则 JDK 需要 17 及以上,部分依赖坐标和配置也会变化。实际开发前先确认自己本机的 JDK 版本,再决定使用哪个 Spring Boot 版本,不要直接复制网上任意一段 pom 配置。
MySQL 8 和 MySQL 5.7 的驱动类名不同。JDK 8 配合 MySQL 8 时,常见驱动类是com.mysql.cj.jdbc.Driver,连接串还要带serverTimezone;MySQL 5.7 使用com.mysql.jdbc.Driver的写法也比较常见。为了避免混淆,建议统一使用 MySQL 8 的驱动坐标和连接串写法。
2.2 通过 Maven 创建项目和依赖配置
在 IDEA 中新建项目时,可以选择 Spring Initializr 创建 Spring Boot 工程,也可以手工创建 Maven 工程。无论哪种方式,最终 pom.xml 都需要包含 web、thymeleaf、mybatis、mysql、lombok 这些依赖。
下面是一份核心依赖配置示例,用于说明结构:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>gym-member</artifactId> <version>1.0.0</version> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies> </project>依赖配置中要特别注意两点。第一,MyBatis 的 starter 坐标是org.mybatis.spring.boot,不要和 MyBatis 官方核心依赖混淆;第二,MySQL 8 的驱动坐标是com.mysql:mysql-connector-j,旧项目里常见的mysql:mysql-connector-java是旧坐标,如果 Spring Boot 版本较新,推荐使用新坐标。
2.3 项目目录结构说明
项目结构建议按“表现层 -> 业务层 -> 持久层”的标准分层,形成清晰的模块边界:
src/main/java/com/example/gym ├── config // 配置类,如拦截器配置 ├── controller // 控制器 ├── entity // 实体类 ├── mapper // MyBatis Mapper 接口 ├── service // 业务接口和实现 ├── common // 通用返回、异常、工具类 └── GymApplication.java // Spring Boot 启动类 src/main/resources ├── mapper // MyBatis XML 文件 ├── static // css、js、图片 └── templates // Thymeleaf 模板页面实体类对应数据库表,Mapper 接口负责 SQL 映射,Service 处理业务规则,Controller 接收请求并返回页面或数据。不要把所有 SQL 写在接口注解里,尽量使用 XML 文件,这样复杂的动态查询更好维护,也方便在答辩时讲解 SQL 写法。
3. 数据库设计:会员、卡项、课程和预约怎么建模
3.1 数据模型设计思路
数据库设计是这个项目的核心,也是最值得在答辩时展开讲的部分。健身会员管理系统的数据关系可以概括为:一个会员可以持有多张卡,一张卡属于一个卡项类型,会员使用卡去预约课程,每次续费或购买都会产生缴费记录。
因此,表结构建议设计为:sys_user存管理员账号;member存会员基础信息;card_type存卡项类型,比如月卡、季卡、年卡、次卡;member_card存会员当前持有的卡,记录开始日期、到期日期或剩余次数;payment_record存每笔缴费流水;course和appointment存课程和预约记录。
这类系统不建议把卡的有效期直接写在 member 表里。因为会员可能换卡、补卡,一张会员卡可能经历续费延长,如果只用一个字段表示到期时间,历史记录很容易丢失。正确做法是让 member_card 成为关联核心,member 只保留会员状态,卡状态和到期信息全部放在 member_card 中。
3.2 核心建表 SQL 示例
会员信息表设计如下:
CREATE TABLE member ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '会员ID', member_no VARCHAR(32) NOT NULL COMMENT '会员编号', name VARCHAR(50) NOT NULL COMMENT '姓名', phone VARCHAR(20) NOT NULL COMMENT '手机号', id_card VARCHAR(18) DEFAULT NULL COMMENT '身份证号', gender TINYINT NOT NULL DEFAULT 1 COMMENT '性别 1男 2女', birthday DATE DEFAULT NULL COMMENT '生日', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1正常 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_member_no (member_no), KEY idx_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员信息表';会员编号使用业务编码而不是直接暴露自增 ID,这是后台管理的常见做法。手机号建立索引,因为列表页经常按手机号搜索。
卡项类型表:
CREATE TABLE card_type ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '卡项ID', name VARCHAR(50) NOT NULL COMMENT '卡项名称', card_kind TINYINT NOT NULL COMMENT '卡类型 1计时卡 2次卡', duration_days INT DEFAULT NULL COMMENT '计时卡有效天数', times INT DEFAULT NULL COMMENT '次卡总次数', price DECIMAL(10,2) NOT NULL COMMENT '售价', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1上架 0停用', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡项类型表';卡项类型同时兼容计时卡和次卡。计时卡需要 duration_days,次卡需要 times,不同卡类型使用不同字段,这种设计避免了为两种卡建两张表。
会员持卡表:
CREATE TABLE member_card ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '持卡ID', member_id BIGINT NOT NULL COMMENT '会员ID', card_type_id BIGINT NOT NULL COMMENT '卡项ID', start_date DATE NOT NULL COMMENT '开卡日期', expire_date DATE DEFAULT NULL COMMENT '到期日期', remain_times INT DEFAULT NULL COMMENT '剩余次数', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1有效 2已过期 0停用', PRIMARY KEY (id), KEY idx_member (member_id), KEY idx_expire (expire_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员持卡表';把 status 设计为独立字段非常关键。不能用 SQL 计算“today 是否超过 expire_date”来取代状态字段,原因是定时任务和列表页都需要快速查询状态,而且停卡操作只能通过状态字段表达。
缴费记录表:
CREATE TABLE payment_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '流水ID', member_id BIGINT NOT NULL COMMENT '会员ID', member_card_id BIGINT NOT NULL COMMENT '持卡ID', amount DECIMAL(10,2) NOT NULL COMMENT '金额', pay_type TINYINT NOT NULL DEFAULT 1 COMMENT '支付方式 1现金 2微信 3支付宝', remark VARCHAR(255) DEFAULT NULL COMMENT '备注', created_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '缴费时间', PRIMARY KEY (id), KEY idx_member_card (member_card_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缴费记录表';课程和预约表:
CREATE TABLE course ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '课程ID', course_name VARCHAR(100) NOT NULL COMMENT '课程名称', coach_name VARCHAR(50) NOT NULL COMMENT '教练姓名', course_time DATETIME NOT NULL COMMENT '上课时间', capacity INT NOT NULL DEFAULT 10 COMMENT '人数上限', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1可预约 0已取消', PRIMARY KEY (id), KEY idx_course_time (course_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; CREATE TABLE appointment ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '预约ID', member_id BIGINT NOT NULL COMMENT '会员ID', course_id BIGINT NOT NULL COMMENT '课程ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0已预约 1已签到 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '预约时间', PRIMARY KEY (id), KEY idx_member_course (member_id, course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程预约表';预约表使用联合索引idx_member_course,可以高效查询“某个会员约了哪些课”和“某节课有哪些人约”。
3.3 字段与约束设计中的几个关键判断
数据库设计里常见的几个决策点,建议不要凭感觉写。
金额字段必须使用 DECIMAL,不要使用 FLOAT 或 DOUBLE。浮点数在累加和比较时会丢失精度,缴费流水的金额一旦出错很难解释。DECIMAL(10,2)表示最多保存 8 位整数和 2 位小数,对健身房单笔数千元的场景完全够用。
时间字段要区分创建时间、业务时间和到期时间。创建时间用CURRENT_TIMESTAMP自动填充,业务时间由 Java 层或 SQL 明确定义。不要滥用ON UPDATE CURRENT_TIMESTAMP,把它加在会员的业务时间字段上会导致数据莫名其妙被修改。
会员卡状态字段要预留“已过期”状态。如果只存在“有效”和“停用”,定时任务把过期卡改成停用,会和人工停卡混在一起,无法区分。建议使用 1 有效、2 已过期、0 停用。
外键约束在实际项目中并不建议大量使用。表之间通过业务字段关联即可,不要用外键把表结构锁死。MyBatis 环境下,外键通常由 Service 层保证,而不是由数据库主动级联操作。
4. 核心代码实现:会员开卡、续费和到期提醒
4.1 后端分层与实体类设计
后端代码按照 Controller、Service、Mapper 三层组织。Controller 只处理请求参数、调用 service、返回页面或结果;Service 负责业务规则和事务;Mapper 负责 SQL。
实体类建议与表字段一一对应。以持有卡信息为例:
@Data public class MemberCard { private Long id; private Long memberId; private Long cardTypeId; private LocalDate startDate; private LocalDate expireDate; private Integer remainTimes; private Integer status; }使用 Lombok 的@Data可以省略 getter 和 setter 方法,项目更简洁。实体类字段类型与数据库字段对应时要注意:日期字段用LocalDate,时间字段用LocalDateTime,金额字段用BigDecimal。如果实体类字段和数据库列名不一致,用 MyBatis 的mapUnderscoreToCamelCase配置自动映射驼峰和下划线。
4.2 会员开卡接口实现
会员开卡的业务流程是:根据前台提交的会员信息和卡项编号,先在 member 表插入会员,再在 member_card 表插入持卡记录,同时写入一条 payment_record 缴费流水。三个动作是一个事务,任何一个失败都要整体回滚,否则会出现“会员有了但卡没开成”或“卡开了但账对不上”的问题。
Mapper 接口定义如下:
@Mapper public interface MemberCardMapper { int insert(MemberCard card); MemberCard findById(Long id); int updateExpireDate(@Param("id") Long id, @Param("expireDate") LocalDate expireDate); int updateStatus(@Param("id") Long id, @Param("status") Integer status); List<MemberCard> findExpiringCards(@Param("deadline") LocalDate deadline); }Service 实现关键逻辑:
@Service public class MemberCardService { @Resource private MemberMapper memberMapper; @Resource private MemberCardMapper memberCardMapper; @Resource private PaymentRecordMapper paymentRecordMapper; @Transactional(rollbackFor = Exception.class) public void openCard(Member member, Long cardTypeId) { // 1. 保存会员 memberMapper.insert(member); // 2. 查询卡项 CardType cardType = cardTypeMapper.findById(cardTypeId); if (cardType == null) { throw new BusinessException("卡项不存在"); } // 3. 创建持卡记录 MemberCard card = new MemberCard(); card.setMemberId(member.getId()); card.setCardTypeId(cardTypeId); card.setStartDate(LocalDate.now()); if (cardType.getCardKind().equals(1)) { card.setExpireDate(LocalDate.now().plusDays(cardType.getDurationDays())); } else { card.setRemainTimes(cardType.getTimes()); } card.setStatus(1); memberCardMapper.insert(card); // 4. 写入缴费流水 PaymentRecord record = new PaymentRecord(); record.setMemberId(member.getId()); record.setMemberCardId(card.getId()); record.setAmount(cardType.getPrice()); record.setPayType(1); paymentRecordMapper.insert(record); } }开卡接口中要重点理解两个设计。第一,事务范围是整个业务流程,不是单独一条 SQL,所以方法上必须使用@Transactional。第二,计时卡和次卡在持卡记录上的表达方式不同,一个写 expireDate,一个写 remainTimes,需要在代码里显式区分。
4.3 续费与缴费记录:事务是关键
续费的业务逻辑比开卡更复杂一些。如果会员当前卡还没过期,续费应该在原到期时间上延长;如果已经过期,则从当前日期开始计算。这符合健身房运营的实际规则,也是答辩时能体现思考深度的点。
续费示例:
@Transactional(rollbackFor = Exception.class) public void renew(Long memberCardId, Long cardTypeId) { MemberCard card = memberCardMapper.findById(memberCardId); if (card == null) { throw new BusinessException("持卡记录不存在"); } CardType cardType = cardTypeMapper.findById(cardTypeId); if (cardType == null) { throw new BusinessException("卡项不存在"); } LocalDate baseDate; if (card.getExpireDate() != null && card.getExpireDate().isAfter(LocalDate.now())) { baseDate = card.getExpireDate(); } else { baseDate = LocalDate.now(); } LocalDate newExpireDate = baseDate.plusDays(cardType.getDurationDays()); memberCardMapper.updateExpireDate(memberCardId, newExpireDate); PaymentRecord record = new PaymentRecord(); record.setMemberId(card.getMemberId()); record.setMemberCardId(memberCardId); record.setAmount(cardType.getPrice()); record.setPayType(1); paymentRecordMapper.insert(record); }续费时必须同时更新持卡记录和写入缴费流水,因此事务方法里不要写多个 mapper 调用后任凭返回结果。要明确判断每个 mapper 的返回值,如果影响行数为 0,说明数据已经被别人修改,应当抛异常或提示。
这里有一个容易被忽略的问题:并发重复提交。两个管理员同时给同一张卡续费,可能因为都读到旧的到期时间而各加一次,导致时间重复延长。生产级方案是在 member_card 表加 version 字段做乐观锁,或在 update 语句中带条件判断,比如只更新 expire_date 等于传入旧值的记录。毕设阶段可以说明这个风险,再在代码中用状态判断限制重复提交即可。
4.4 到期提醒和状态自动更新如何处理
到期提醒通常有两种实现方式。第一种是定时任务,比如每天凌晨扫描 member_card 表,把过期卡状态改为 2,并查询 3 天内到期的卡生成提醒;第二种是查询时动态判断,列表页显示“即将到期”或“已过期”标记,不实时修改数据库。
推荐把两种方式结合起来:定时任务负责修正状态,页面查询负责展示提醒。示例 SQL 可以查询 3 天内到期的卡:
<select id="findExpiringCards" resultType="com.example.gym.entity.MemberCard"> SELECT mc.*, m.name AS memberName, m.phone AS memberPhone FROM member_card mc LEFT JOIN member m ON mc.member_id = m.id WHERE mc.status = 1 AND mc.expire_date IS NOT NULL AND mc.expire_date BETWEEN #{today} AND #{deadline} ORDER BY mc.expire_date </select>使用 Spring 的定时任务时,在启动类加@EnableScheduling,并在定时任务方法上使用@Scheduled(cron = "0 0 1 * * ?")表示每天凌晨 1 点执行。毕设中要说明定时任务只负责数据修正,真正的提醒内容建议在管理员首页查询展示,不要把短信、邮件等外部推送实现写得过重。
5. 页面联调:登录、列表、表单和预约闭环
5.1 登录与会话拦截
后台管理系统必须有登录页,不能只暴露数据列表。登录逻辑比较简单:根据用户名查询管理员账号,校验密码,然后把用户信息放入 Session。密码不要明文存储,建议使用 BCrypt 加密。即使毕设项目,也需要在文档里写明这是基本安全要求。
密码校验示例:
@Service public class SysUserService { @Resource private SysUserMapper sysUserMapper; public SysUser login(String username, String password) { SysUser user = sysUserMapper.findByUsername(username); if (user != null && BCrypt.checkpw(password, user.getPassword())) { return user; } return null; } }会话控制使用拦截器实现。编写一个 HandlerInterceptor,在 preHandle 中判断 Session 是否存在登录用户,如果不存在则跳转到登录页。然后通过 WebMvcConfigurer 注册拦截器,并放行登录页、静态资源、错误页。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { if (request.getSession().getAttribute("loginUser") == null) { response.sendRedirect("/login"); return false; } return true; } }拦截器配置只需要在 Spring Boot 项目中加入一个配置类。这样登录和权限问题在讲项目实施时很自然,也能避免答辩时“没有权限控制”这个追问。
5.2 会员列表页和分页筛选
会员列表页是管理后台的门面,需要支持分页、按姓名手机号搜索、按状态筛选。列表页使用 PageHelper 分页,Controller 接收当前页码和搜索条件,把分页结果传给 Thymeleaf 模板。
Controller 示例:
@Controller @RequestMapping("/member") public class MemberController { @Resource private MemberService memberService; @GetMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(required = false) String keyword, @RequestParam(required = false) Integer status, Model model) { PageInfo<Member> page = memberService.findPage(pageNum, 10, keyword, status); model.addAttribute("page", page); model.addAttribute("keyword", keyword); model.addAttribute("status", status); return "member/list"; } }Service 中调用 PageHelper.startPage 开启分页,再执行查询即可:
public PageInfo<Member> findPage(Integer pageNum, Integer pageSize, String keyword, Integer status) { PageHelper.startPage(pageNum, pageSize); List<Member> list = memberMapper.selectByCondition(keyword, status); return new PageInfo<>(list); }Mapper 的 XML 使用动态 SQL:
<select id="selectByCondition" resultType="com.example.gym.entity.Member"> SELECT * FROM member <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR phone LIKE CONCAT('%', #{keyword}, '%') OR member_no LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY id DESC </select>这里使用<where>动态标签能自动处理 AND 拼接问题。搜索条件使用 CONCAT 拼出模糊查询,避免直接写LIKE '%${keyword}%'造成 SQL 注入风险。
5.3 开卡表单和前端校验
开卡表单页面至少包含会员姓名、手机号、性别、卡项选择等字段。卡项下拉框的数据从 card_type 表查询,只展示 status 为 1 的有效卡项。
前端校验和后端校验不能互相替代。页面提交时使用 jQuery 校验非空项,但后端也必须再次校验。后端校验不能只依赖前端,因为通过接口直接调用可以绕过页面。
后端 Controller:
@PostMapping("/openCard") public String openCard(Member member, @RequestParam Long cardTypeId, RedirectAttributes redirectAttributes) { try { memberService.openCard(member, cardTypeId); redirectAttributes.addFlashAttribute("message", "开卡成功"); } catch (BusinessException e) { redirectAttributes.addFlashAttribute("error", e.getMessage()); } return "redirect:/member/list"; }开卡成功后重定向到列表页,避免表单重复提交。提示信息通过 RedirectAttributes 传递,刷新页面后不会保留表单数据。
5.4 课程预约的简化实现
课程预约模块的核心是控制预约资格:会员必须有一张有效卡,且该课程还没有被该会员重复预约。实现预约时,可以在 appointment 表查询是否存在相同 member_id 和 course_id 的记录,也可以在数据库加唯一索引防止重复预约。
业务判断示例:
@Transactional(rollbackFor = Exception.class) public void book(Long memberId, Long courseId) { Course course = courseMapper.findById(courseId); if (course == null || course.getStatus() != 1) { throw new BusinessException("课程不存在或已取消"); } MemberCard activeCard = memberCardMapper.findActiveByMemberId(memberId); if (activeCard == null) { throw new BusinessException("会员卡不存在或已过期,无法预约"); } int count = appointmentMapper.countByMemberAndCourse(memberId, courseId); if (count > 0) { throw new BusinessException("该课程已预约,请勿重复预约"); } Appointment appointment = new Appointment(); appointment.setMemberId(memberId); appointment.setCourseId(courseId); appointment.setStatus(0); appointmentMapper.insert(appointment); }在 appointment 表上,建议增加UNIQUE KEY uk_member_course (member_id, course_id)作为最后一道防线,避免并发下的重复预约。业务层提示语要清晰,不能只返回“操作失败”。
6. 运行验证与常见问题排查
6.1 初始化数据库并准备演示数据
代码写完并不代表项目能直接演示。建议先创建数据库,再执行建表 SQL,然后插入一组演示数据:一个管理员账号、三到五种卡项、若干会员、一两张会员卡、两节课程。没有演示数据,页面打开是空列表,很难展示效果。
初始化管理员密码如果使用 BCrypt,可以在启动类里写一个 ApplicationRunner,检查 sys_user 是否为空,为空则插入默认账号。也可以直接在执行 SQL 中放入已经加密好的字符串。演示数据要保证业务状态正确,比如至少有一个会员卡今天到期或三天内到期,这样首页提醒区域才有内容展示。
6.2 启动、测试和验证清单
项目启动后,按以下顺序验证:
- 打开登录页,正确账号密码可以登录,错误密码不能登录。
- 新增一个会员并开卡,缴纳流水是否正确生成。
- 查看会员列表,搜索姓名或手机号,结果正确。
- 进入持卡列表,核对到期时间和状态。
- 给一张卡续费,确认到期时间在原日期上延长。
- 创建课程并预约,检查重复预约是否被拦截。
- 查看首页或统计页,会员总数、今日营收等数字是否与数据库一致。
这里有一个常见误区:只验证“新增成功后列表多了一条”,不验证异常分支。续费失败时流水是否回滚、重复预约是否提示错误,这些都需要在验证清单里覆盖。
6.3 常见报错排查表
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 启动报数据库连接失败 | MySQL 未启动、库名错误、密码错误 | 检查 application.properties,使用客户端连接测试 | 先确认 MySQL 服务在线,再核对用户名密码 |
| 驱动类找不到 | 依赖坐标错误或版本不匹配 | 查看 Maven 依赖树、检查 pom.xml | 统一使用 mysql-connector-j 8.x 并确认 starter 版本 |
| 页面路径 404 | controller 返回视图名错误或 templates 下文件名不对 | 查看访问 URL、模板目录结构 | 保持 controller 返回名与 templates 下文件名一致 |
| 静态资源 404 | 登录拦截器拦截了 css/js | 查看浏览器 Network 请求状态 | 在拦截器注册中放行 /static/** 等资源路径 |
| 中文乱码 | 数据库连接串没有指定 UTF-8 | 查看响应头、页面编码 | 连接串加 characterEncoding=utf8,页面 meta 指定 UTF-8 |
| 接口返回 500 | 日志中有 SQL 或 NPE 异常 | 查看控制台完整堆栈 | 根据堆栈定位到具体行,先确认空值判断 |
| 端口占用 | 8080 被其他进程占用 | 使用 netstat -ano 查看端口 | 在 application.properties 中改用 server.port=8081 |
| Maven 依赖下载慢 | 网络或镜像问题 | 查看 IDEA 控制台依赖解析日志 | 配置阿里云 Maven 镜像仓库 |
6.4 一条可复用的排错路径
遇到系统报错时,不要盲目改代码。按以下顺序排查:
- 先看请求是否到达 Controller,日志里有没有 Controller 方法的打印。
- 再看参数是否正确,页面提交的字段名是否和后端属性对应。
- 接着检查 SQL,在配置文件中开启 MyBatis 日志,把最终执行的 SQL 复制到数据库客户端执行。
- 然后检查事务,看 Service 方法是否被代理调用,是否在同一个类中通过 this 调用导致事务失效。
- 最后检查环境,比如数据库编码、连接串时区、JDK 版本。
如果排查到一半发现异常是偶发性的,优先看并发操作和数据库锁,不要只在单次请求里打日志。这类问题通常不是代码语法错误,而是状态竞争导致的数据不一致。
7. 从毕设到生产:答辩重点、扩展方向与检查清单
7.1 答辩时重点讲清楚哪三个设计
答辩时最容易打动老师的,不是“功能多”,而是“能说清楚为什么这么做”。这个项目建议准备三个设计点。
第一个设计点是会员卡状态机。开卡创建有效卡,续费延长有效期,定时任务把过期卡改成已过期,人工停用改成停用状态。能画出状态流转并解释每个状态对应的业务动作,是加分项。
第二个设计点是事务边界。开卡和续费都不是单表操作,需要通过 Spring 事务保证 member_card 和 payment_record 的一致性。能说明为什么@Transactional必须加在 Service 方法上,而不是 Controller 或 Mapper 方法上,说明你理解代理机制。
第三个设计点是计时卡和次卡的统一建模。用 card_kind 区分类型,用 expire_date 和 remain_times 分别表达两种卡的核心属性,避免建多张表造成冗余。这种设计体现了数据建模能力。
7.2 学习环境与生产环境的差距
作为毕设项目,本地跑通是目标;如果这个系统要进入生产环境,还需要补很多内容。
生产环境需要把配置外置,数据库地址、账号、密码不能写在源码的 application.properties 里,一般通过环境变量或配置中心管理;需要接入日志系统,为每个请求生成 traceId,方便排查全链路;需要做数据库备份和定期巡检,不能只依赖手动导 SQL;权限方面,除了登录校验,还需要角色管理和操作日志,至少能知道谁在什么时候改了什么数据。
这些内容不需要全部实现,但要在论文的“改进方向”或答辩回答中提出来。说明自己知道开发环境和真实生产环境的差距,反而比只展示功能更可信。
7.3 后续扩展方向
这个项目可以在原有基础上做几个有价值的扩展:
引入 Spring Security,把拦截器换成框架化的认证授权,统一处理密码加密、Session 过期、权限校验。引入 Redis 缓存,把首页统计和系统配置缓存起来,降低数据库查询压力。引入图表统计,用 ECharts 展示每月新增会员、营收趋势、课程预约热度。增加图片上传,为会员添加头像,为课程添加封面图。引入操作日志表,记录管理员的关键操作。
扩展时注意不要破坏原有核心闭环。建议在主分支上保留可运行版本,每完成一个扩展独立提交,出现问题可以快速回退。
7.4 项目交付前检查清单
| 检查项 | 说明 |
|---|---|
| 环境一致性 | 确认 JDK、MySQL、Maven 版本一致,能在另一台机器跑通 |
| 数据库脚本 | 包含建库、建表、初始化数据,并提供导入说明 |
| 演示数据 | 至少有 5 个会员、2 张有效卡、1 张临期卡、2 个课程 |
| 核心异常 | 开卡失败、续费失败、重复预约、错误登录都有提示 |
| 代码结构 | Controller、Service、Mapper 分层清晰,没有大量 Controller 中写 SQL |
| 日志 | 关键接口有日志输出,不是只打印 System.out |
| 配置说明 | 写清数据库连接、端口、页面访问路径 |
| 论文截图 | 提前准备登录页、会员列表、开卡表单、预约页、统计页截图 |
完成这些检查后,项目不只是在本地“能启动”,而是可以给别人演示、可以在另一台电脑部署、也可以在答辩现场按流程走通核心业务。对于 Java 练手项目来说,能够完整跑通一条业务闭环,并且能解释每个核心设计点的取舍,比堆砌功能数量更有价值。实际动手开发时,建议先按“会员开卡 -> 会员列表 -> 续费 -> 预约”这个顺序实现,等核心链路稳定后再去补充统计、提醒和扩展功能,这样项目推进节奏会清晰很多。