简介:本资源是一套基于SSM(Spring+SpringMVC+MyBatis)框架开发的B/S架构健身房管理系统完整源码,面向计算机、电子信息工程等专业的本科生及毕业设计群体,适用于高分毕设、课程设计与期末大作业场景。系统采用Java 1.8、MySQL 5.7、Tomcat 8/9及Vue前端技术栈,融合Ajax异步交互与MVC分层设计,覆盖会员管理、课程预约、教练排班、消费记录等核心业务模块。压缩包共668个文件,含160个Java后端逻辑文件、114个Vue组件页面、44个JS交互脚本、26个XML配置文件及配套SQL建表语句、启动批处理脚本(bat)、静态资源与样式文件等,整体大小21.14MB,结构清晰、模块解耦,便于二次开发与功能扩展。所有代码均经实测可运行,配套SQL脚本支持一键导入,适合快速部署学习与项目复现。 从去年开始,我一直在用 Spring + SpringMVC + MyBatis 这套经典组合做业务系统,最近刚完成一套健身房管理系统,从会员办卡到私教排课、器材报修、营收统计,全部落地。SSM 这三个框架放到今天依然是 Java 后端最常见的技能组合,很多刚入门的朋友能把框架单独用明白,但一到"如何把三个框架拧成一个完整工程"就卡住了。这篇文章我就拿这套健身房管理系统代码当例子,把数据库设计、SSM 整合配置、核心业务实现、Vue3 前端对接这些关键环节全部拆开讲清楚。适合正在学 SSM 的学生、准备做项目练手的开发者,也适合需要快速搭一套管理后台的工程实践参考。
1. 先想清楚健身房管理系统要管什么
1.1 模块划分:从一个健身房的日常运营出发
很多教程一上来就建表写代码,结果做出来的项目只能叫"增删改查练习",离真正的管理系统差得很远。我在动手前先去线下健身房坐了半下午,把前台、私教、会籍这些角色的日常工作捋了一遍,才确定系统边界。
一家普通健身房每天要处理的事情大概是这些:
- 会员进门要核验身份和卡片状态,快过期的要提醒续卡
- 办卡分月卡、季卡、年卡,每种卡价格和有效期不同
- 会员能约团课、约私教课,约了之后教练端要能看到
- 器材会坏,坏了要登记、要报修、要记录费用
- 每天、每月要知道收了多少钱,卖了多少张卡
- 店长能登录后台做管理,普通员工权限不能太大
对应到系统里,我拆成了会员管理、教练管理、课程排课、器材管理、订单统计、系统管理六个模块。这个划分不是拍脑袋来的,每个模块都能对应到真实业务流程中的一个环节。比如会员管理不是只存一份会员名单,还要和会员卡类型、到期时间、续卡订单联动起来。
这里有个经验:做管理系统,一开始就要把"主数据"和"业务数据"分清楚。会员、教练、器材是主数据,偏静态;订单、预约、维修记录是业务数据,会持续增长。分组清楚之后,表设计、缓存策略、统计查询都好做很多。
1.2 数据库表设计:会员、卡片、课程、器材、订单
整个项目我设计了 11 张表,这里挑核心的几张说明。
会员卡类型表 card_type:
CREATE TABLE card_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(50) NOT NULL COMMENT '卡类型:月卡/季卡/年卡', duration_days INT NOT NULL COMMENT '有效天数', price DECIMAL(10,2) NOT NULL COMMENT '售价', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );会员表 member:
CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(32) NOT NULL UNIQUE COMMENT '会员卡号', name VARCHAR(50) NOT NULL, phone VARCHAR(20), gender TINYINT DEFAULT 1 COMMENT '1男 2女', card_type_id INT COMMENT '当前卡类型', open_time DATETIME COMMENT '开卡时间', expire_time DATETIME COMMENT '到期时间', status TINYINT DEFAULT 1 COMMENT '1正常 2停用 3过期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (card_type_id) REFERENCES card_type(id) );会员的卡片状态我没有单独建一张会员卡流水表,而是在 member 表里直接冗余了当前卡的类型和到期时间。为什么这么做?因为 90% 的查询都是"这个会员现在的卡是什么、到期没有",如果每次都要 join 会员卡流水表去算最新状态,查询复杂度会高很多。当然,如果要做复杂的卡变更历史,那流水表是必须的,这是一个取舍问题。我当时的做法是订单表 orders 里记录每次购买和续费,member 表里只保留当前生效卡信息,两边通过会员 id 关联,既不丢历史,查询又快。
课程和预约这块是三张表:
CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(50) NOT NULL, course_type TINYINT COMMENT '1团课 2私教课', coach_id INT, duration_minutes INT DEFAULT 60, max_people INT DEFAULT 1 COMMENT '团课人数上限,私教课默认1', status TINYINT DEFAULT 1, FOREIGN KEY (coach_id) REFERENCES coach(id) ); CREATE TABLE course_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, course_id INT NOT NULL, class_date DATE NOT NULL, class_time VARCHAR(20) COMMENT '例如 10:00-11:00', max_people INT NOT NULL, booked_number INT DEFAULT 0 COMMENT '已预约人数', status TINYINT DEFAULT 1 COMMENT '1可约 2已满 3已结束', FOREIGN KEY (course_id) REFERENCES course(id) ); CREATE TABLE booking ( id INT PRIMARY KEY AUTO_INCREMENT, member_id INT NOT NULL, schedule_id INT NOT NULL, booking_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT '1已预约 2已取消 3已完成', UNIQUE KEY uk_member_schedule (member_id, schedule_id), FOREIGN KEY (member_id) REFERENCES member(id), FOREIGN KEY (schedule_id) REFERENCES course_schedule(id) );订单表 orders:
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, member_id INT NOT NULL, order_type TINYINT COMMENT '1购卡 2续费 3课程购买', item_id INT COMMENT '关联卡类型id或课程id', amount DECIMAL(10,2) NOT NULL, pay_status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', pay_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (member_id) REFERENCES member(id) );1.3 状态字段与时间字段的设计规范
这套表设计里有几个细节,是写代码时特别容易踩坑的地方。
第一,所有状态字段我都用了 TINYINT 而不是 VARCHAR。很多初学者喜欢用 status VARCHAR(10) 存"正常""停用"这种中文,看着直观,但一旦需求改成"冻结""注销",你的 SQL、Java 枚举、前端下拉框全都要跟着改。用数字状态,配合注释或者枚举类,后续扩展就是加一个数字的事。
第二,时间字段区分了 DATE 和 DATETIME。课程日期只关心某一天,用 DATE;下单时间、操作时间这种需要精确到秒的,用 DATETIME。时间字段的默认值尽量用 CURRENT_TIMESTAMP 兜底,这样插入数据时少写一堆繁琐的 Java 时间代码。
第三,会员到期时间的判断,我用了 expire_time 字段,而不是在查询时动态用 open_time 加卡类型天数去算。虽然动态算也能算出来,但每次查询都要做日期加法,索引没法参与,性能不好。直接在开卡、续费时把 expire_time 写死,查询到期会员就是一条简单的 WHERE expire_time BETWEEN ... 语句。
2. SSM 框架整合:从零攒一份能直接跑的配置
2.1 pom.xml 依赖清单:避免版本冲突的固定搭配
SSM 整合最让人头疼的往往是依赖版本。我在项目里用了一套经过验证的版本组合,分享出来可以直接抄:
| 依赖 | 版本 | 作用 |
|---|---|---|
| spring-webmvc | 5.3.23 | SpringMVC,包含 Spring 核心依赖 |
| mybatis | 3.5.10 | ORM 框架 |
| mybatis-spring | 2.0.7 | MyBatis 与 Spring 整合的桥梁 |
| mysql-connector-java | 8.0.28 | MySQL 驱动 |
| druid | 1.2.15 | 数据库连接池 |
| pagehelper | 5.3.2 | 分页插件 |
| jackson-databind | 2.13.4 | JSON 序列化 |
| servlet-api | 4.0.1 | Servlet 支持,provided 作用域 |
| jstl | 1.2 | JSTL 标签库(非前后端分离时需要) |
这里要特别说一下 mybatis-spring 的版本。低于 2.0 的版本和 Spring 5 一起使用会有兼容问题,最典型的报错是 NoSuchBeanDefinitionException 或者 ClassNotFound。如果你用的是 Spring 5 + MyBatis 3.5,mybatis-spring 就直接上 2.0.7 或者更新版本,别用老攻略里的 1.3.2。
另外,spring-webmvc 这个依赖会把 spring-core、spring-context、spring-beans 这些核心包都带进来,所以不需要再单独声明 Spring 全家桶。但要注意不要在不同依赖里混入不同版本的 spring-jdbc,比如 mybatis-spring 传递依赖可能带一个旧版本,建议在 pom 里显式声明 spring-jdbc 跟 spring-webmvc 同版本,避免运行时出现 NoSuchMethodError。
2.2 Spring 容器与 MyBatis 的融合配置
SSM 整合的核心是理解:MyBatis 的 SqlSessionFactory、Mapper 接口代理对象、事务管理器,这些都应该交给 Spring 容器来管理,而不是像单独使用 MyBatis 时那样自己手动 new SqlSessionFactoryBuilder。applicationContext.xml 里的关键配置如下:
<!-- 扫描 Service、Dao 组件,注意排除 @Controller --> <context:component-scan base-package="com.gym"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <!-- Druid 数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/gym_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="root"/> <property name="initialSize" value="5"/> <property name="minIdle" value="5"/> <property name="maxActive" value="20"/> </bean> <!-- SqlSessionFactory --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.gym.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <!-- 别名配置,MyBatis 全局配置可以引用外部文件 --> <property name="configLocation" value="classpath:mybatis-config.xml"/> </bean> <!-- 扫描 Mapper 接口,生成代理对象 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.gym.mapper"/> </bean> <!-- 事务管理器 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <!-- 开启注解事务 --> <tx:annotation-driven transaction-manager="transactionManager"/>mybatis-config.xml 里我主要配置了驼峰映射和分页插件:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"/> </plugins> </configuration>mapUnderscoreToCamelCase 这个配置强烈建议开启。数据库字段是 card_no,Java 属性是 cardNo,开启驼峰映射后,MyBatis 自动帮你完成映射,不用写一堆 resultMap 硬映射。注意,开启后如果 XML 里仍然写 resultMap,以 resultMap 为准,部分映射场景可能会踩坑,后面会提到。
2.3 SpringMVC 配置与 web.xml 的联动
SpringMVC 的职责是接收请求、找到对应的 Controller 处理、把响应写回。既然我们最终要对接 Vue3 前端,绝大多数接口返回 JSON,所以 springmvc.xml 里不再配置 JSP 的视图解析器,而是用 @ResponseBody 配合 Jackson 直接输出 JSON。
<!-- 扫描 Controller --> <context:component-scan base-package="com.gym.controller"/> <!-- 启用 SpringMVC 注解驱动 --> <mvc:annotation-driven/> <!-- 默认静态资源处理,避免 DispatcherServlet 拦截 js/css/images --> <mvc:default-servlet-handler/> <!-- 静态资源映射(如果静态资源放在 webapp/static 下) --> <mvc:resources mapping="/static/**" location="/static/"/>web.xml 里最关键的是两层配置:过滤器要在最前面,DispatcherServlet 兜底所有请求。
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 启动 Spring 容器 --> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <!-- SpringMVC 前端控制器 --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:springmvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>2.4 事务与连接池:为什么不建议用默认配置
很多人会把 Spring 自带的 DriverManagerDataSource 拿来用,因为它配置简单,不引额外依赖。但这东西不适合任何线上环境,它每次 getConnection 都新建数据库连接,没有任何复用,并发一高系统就卡死。Druid 连接池的 initialSize、maxActive 这些参数,在一家小健身房系统里设置成 5 和 20 完全够用,初期几百个并发请求都不需要调整。
事务配置这块有个很多人忽略的细节: tx:annotation-driven 一定放在 spring-mvc 会扫描到的包之外,或者干脆只放在 applicationContext.xml 里。为什么?因为 @Transactional 是通过 AOP 代理实现的,如果你的 Service 被 SpringMVC 和 Spring 两个容器各自扫描了一遍,就会产生两个代理对象,事务可能只在其中一个容器里生效。后面坑的部分我会详细展开。
3. 业务代码落地:三层架构怎么写才不像玩具
3.1 包结构与通用返回体
我习惯的包结构是这样的:
com.gym ├── controller // 接口层,只处理参数和路由 ├── service // 业务层,接口+Impl │ └── impl ├── mapper // MyBatis 数据访问层,接口 ├── entity // 实体类,和表结构对应 ├── common // 通用返回体、常量、异常 └── interceptor // 拦截器Controller 层只负责接收参数和返回结果,Service 层写业务判断,Mapper 层只做数据持久化。这个分层的好处是出了问题能快速定位:接口参数不对找 Controller,业务逻辑不对找 Service,SQL 查不出数据找 Mapper。千万别把业务逻辑写在 Controller 里,虽然一个查询接口几十行代码也能跑,但后面接权限、加日志、做事务的时候会非常痛苦。
通用返回体我定义为 Result 类:
public class Result<T> implements Serializable { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } // getter/setter 省略 }这个返回体会贯穿所有接口,前端统一判断 code == 200 才取 data。后面对接 Vue3 时,只要把契约定清楚,联调能省一半时间。
3.2 会员管理:分页查询与卡片到期的完整闭环
会员管理是最典型的增删改查,但我把"查询会员列表"和"卡片到期处理"联动了起来。先看 Controller 层:
@RestController @RequestMapping("/api/member") public class MemberController { @Resource private MemberService memberService; @GetMapping("/list") public Result<PageResult<MemberVO>> list( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) Integer status) { return Result.ok(memberService.pageQuery(pageNum, pageSize, keyword, status)); } @PostMapping("/renew") public Result<String> renew(@RequestBody RenewDTO dto) { memberService.renewCard(dto.getMemberId(), dto.getCardTypeId()); return Result.ok("续费成功"); } }ServiceImpl 里用 PageHelper 做分页,这是 PageHelper 的最高频用法:
@Override public PageResult<MemberVO> pageQuery(int pageNum, int pageSize, String keyword, Integer status) { PageHelper.startPage(pageNum, pageSize); List<MemberVO> list = memberMapper.selectMemberPage(keyword, status); PageInfo<MemberVO> pageInfo = new PageInfo<>(list); PageResult<MemberVO> result = new PageResult<>(); result.setTotal(pageInfo.getTotal()); result.setList(pageInfo.getList()); return result; }Mapper 接口:
public interface MemberMapper { List<MemberVO> selectMemberPage(@Param("keyword") String keyword, @Param("status") Integer status); }Mapper XML 里的动态 SQL 是重点,关键词模糊搜索和状态筛选靠的是 和 标签:
<select id="selectMemberPage" resultType="com.gym.entity.MemberVO"> SELECT m.id, m.card_no, m.name, m.phone, m.expire_time, m.status, 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 != ''"> AND (m.name LIKE CONCAT('%', #{keyword}, '%') OR m.phone LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND m.status = #{status} </if> </where> ORDER BY m.create_time DESC </select>这里有个细节:动态 SQL 里用 CONCAT('%', #{keyword}, '%') 而不是直接写 '%${keyword}%'。用 ${} 直接拼接会有 SQL 注入风险,这是很多初学 SSM 的人容易踩的坑。
续费操作的 Service 代码要加事务,因为要同时更新 member 表的卡类型和到期时间、生成 orders 订单记录、还要刷新 card_type 的价格快照:
@Override @Transactional(rollbackFor = Exception.class) public void renewCard(Integer memberId, Integer cardTypeId) { Member member = memberMapper.selectById(memberId); if (member == null) { throw new BusinessException("会员不存在"); } CardType cardType = cardTypeMapper.selectById(cardTypeId); if (cardType == null) { throw new BusinessException("卡类型不存在"); } // 计算新的到期时间:当前时间如果还没过期,则从原到期时间开始累加 LocalDateTime baseTime = member.getExpireTime().isAfter(LocalDateTime.now()) ? member.getExpireTime() : LocalDateTime.now(); LocalDateTime newExpireTime = baseTime.plusDays(cardType.getDurationDays()); memberMapper.updateMemberCard(memberId, cardTypeId, newExpireTime); orderMapper.insertOrder( OrderUtil.generateOrderNo(), memberId, 2, // 续费 cardTypeId, cardType.getPrice() ); }事务一定要加 rollbackFor = Exception.class。Spring 默认只对 RuntimeException 回滚,对 checked Exception 不回滚。如果你抛的是业务异常且继承自 Exception,不写明 rollbackFor,你会看到会员卡片更新了、订单却因为异常没插进去,数据库状态不一致。这是我实际踩过的坑。
3.3 课程预约:一个需要事务和并发控制的典型场景
课程预约比会员管理复杂,因为涉及到并发。一个团课最多 20 人,两个会员同时提交预约,如果先查再插,可能都查到"还差一个名额",然后都插入成功,导致超员。这个问题在业务场景里必须解决。
我的做法是把名额扣减变成一条原子的 UPDATE 语句:
@Transactional(rollbackFor = Exception.class) public void bookCourse(Integer memberId, Integer scheduleId) { // 原子更新:只有当已预约人数小于上限时,人数才加1 int updated = scheduleMapper.increaseBookedNumber(scheduleId); if (updated == 0) { throw new BusinessException("课程名额已满"); } // 插入预约记录 bookingMapper.insertBooking(memberId, scheduleId); }对应的 Mapper XML:
<update id="increaseBookedNumber"> UPDATE course_schedule SET booked_number = booked_number + 1 WHERE id = #{scheduleId} AND booked_number < max_people </update>这条 UPDATE 语句本身带条件,数据库会锁住这一行,同一个时刻只有一个事务能成功把 booked_number 加一。如果影响行数为 0,说明名额已经满了。这个方案比"先 SELECT 再 UPDATE"简单可靠得多,也是防止超卖的经典思路。
同时,booking 表上加了唯一索引 uk_member_schedule,防止同一个会员重复预约同一个课程。万一代码逻辑漏了一层,数据库也能兜底。
3.4 营收统计:把 SQL 写明白比堆 Java 代码更省事
统计功能是管理系统的标配,很多人喜欢把所有数据查出来到 Java 里循环计算,这是最笨的办法。聚合统计就应该交给 SQL 去干。月度营收统计我直接写了这样一条:
SELECT DATE_FORMAT(pay_time, '%Y-%m') AS month, COUNT(*) AS orderCount, SUM(amount) AS totalAmount FROM orders WHERE pay_status = 1 AND pay_time >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) GROUP BY DATE_FORMAT(pay_time, '%Y-%m') ORDER BY month DESCMyBatis 里返回一个 List ,实体类里有 month、orderCount、totalAmount 三个属性,不用写任何 Java 循环就能拿到图表数据。
会员到期提醒也是 SQL 的优势场景:
SELECT id, name, phone, expire_time FROM member WHERE status = 1 AND expire_time BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY)这类需求放在数据库层做,只要索引覆盖到位,几万条数据都是毫秒级返回。代码还能少写很多。
4. 登录与权限:SSM 项目的安全底线
4.1 拦截器实现登录校验
管理系统一定要有登录拦截。SSM 项目里最朴素的方案是拦截器加 Session,不需要引入 Spring Security 这种重量级框架。自定义一个拦截器:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object adminUser = session.getAttribute("loginUser"); if (adminUser != null) { return true; } // 未登录,直接返回 JSON,而不是重定向页面 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } }注册拦截器用 WebMvcConfigurer:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/static/**"); } }这里有个前后端分离的细节:传统 SSM 项目在拦截器里常用 response.sendRedirect("/login.jsp") 跳转登录页,但对接 Vue3 时前端 SPA 没有对应的 JSP 页面,所以我在拦截器里直接输出 JSON,前端拿到 code = 401 就自动跳转到登录路由。
4.2 密码加密:从 MD5 到加盐处理的演进
管理后台用户表 admin_user 里的密码绝对不能明文存储。MD5 直接算也有一个著名问题:同样的密码会得到同样的摘要,撞库风险极高。我在项目里用的方案是"MD5 + 随机盐",虽然不够前沿,但对这个体量的系统来说安全性和实现成本比较均衡。
注册时生成盐值(比如 UUID 的前8位),将盐拼到密码后面一起做 MD5:
public static String encrypt(String rawPassword, String salt) { return DigestUtils.md5DigestAsHex((rawPassword + salt).getBytes(StandardCharsets.UTF_8)); }数据库里存两列:password 存加密后的值,salt 存随机盐。登录时用用户提交的密码和数据库里的盐拼起来,重新 MD5 一次,和存储的密码比对。
如果项目允许引入新依赖,强烈建议直接用 Spring Security 的 BCryptPasswordEncoder,或者 jBCrypt 库。BCrypt 自带随机盐,每次加密结果都不同,验证时靠算法内部提取盐信息,代码比手写 MD5 加盐更省心。这里不展开,因为 SSM 项目里也有很多人保留着 MD5 加盐的写法,不能说完全不能用。
4.3 接口层面的权限细节
登录拦截只是第一道关,接口级别还需要区分管理员和普通员工。我在 admin_user 表加了 role 字段,取值 1 是管理员,2 是员工。拦截器里有两种做法:
一是写一个自定义注解 @RequireRole("admin"),用 HandlerInterceptor 配合判断。二是简单地在每个 Controller 方法里判断角色,代码冗余。我推荐注解方式,虽然第一次写要多花点时间,但后面每个接口只要一行注解就搞定。
另外一个权限细节是操作审计。删除会员、修改价格、报废器材这类高风险操作,一定要在日志表里记录操作人、操作时间、操作前后的数据快照。这个需求我在第一版里完全没做,后来门店反映有教练误改了会员信息查不到责任人,才补上。管理系统做到后面你会发现,审计日志有时候比功能本身更重要。
5. Vue3 前端对接:前后端分离模式下的 SSM 接口设计
5.1 先定契约:统一返回 JSON 结构
SSM 后端天然可以返回 JSON,所以接口层做好,用 Vue3 写前端完全没问题。和前端约定的返回结构统一是上面说的 Result 类,前端所有的请求都走同一个 axios 实例,response interceptor 里统一判断 code。
需要约定的接口文档,我习惯用 Swagger 注解或者简单的 Markdown 表格。接口命名统一 /api/xxx,传递参数用 JSON Body,查询参数用 Query String。这样 Vue3 前端拿 axios 直接就能对接,不需要任何后端模板。
5.2 跨域处理:后端 CORS 与前端代理两种方式
开发阶段,Vue3 的 dev 服务器默认跑在 5173,SSM 的 Tomcat 跑在 8080,直接 fetch 会触发跨域。解决方案有两个方向。
后端加全局 CORS 配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意 allowCredentials(true) 时,allowedOriginPatterns 不能用 "*",这是浏览器规范的限制,必须用具体的来源或者带通配符的 pattern。前端 axios 请求也要带上 withCredentials: true。
另一种更推荐的方式是配置 Vite 的代理,把开发环境的跨域问题直接挡在浏览器之外:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这种方式下浏览器只跟同源的 5173 通信,Vite 服务器帮后端转发请求,既绕开了跨域,生产环境部署时也可以用 Nginx 做同样的反向代理,模式完全一致。我在项目里用的是 Vite 代理,后端不开放跨域,安全边界更清晰。
5.3 axios 封装与真实接口联调
前端我封装了一个 request.js:
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000, withCredentials: true }) request.interceptors.request.use(config => { const token = localStorage.getItem('gym_token') if (token) { config.headers['Authorization'] = token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 401) { window.location.href = '/login' return Promise.reject(new Error('未登录')) } if (res.code !== 200) { return Promise.reject(new Error(res.message || '请求失败')) } return res }, error => { return Promise.reject(error) } ) export default request调用接口时:
<script setup> import request from '@/utils/request' import { onMounted, ref } from 'vue' const members = ref([]) const total = ref(0) async function loadMembers() { const res = await request.get('/member/list', { params: { pageNum: 1, pageSize: 10 } }) members.value = res.data.list total.value = res.data.total } onMounted(loadMembers) </script>这里有一个踩坑经验:baseURL 配成 /api 后,如果后端接口路径是 /api/member/list,那 axios 请求地址就要写 /member/list,两者拼接才是最终地址。很多人这里搞混,导致 404。我从一开始就统一约定:后端 Controller 的 RequestMapping 带 /api 前缀,前端 axios baseURL 也带 /api,然后请求参数不重复写 /api。
5.4 登录态在前后端分离下怎么存活
SSM 项目登录态有两条路线。
路线 A 是传统 Session + Cookie。登录成功后将用户信息放进 session,浏览器拿到 JSESSIONID 后自动带上,拦截器也能识别。前端要做的是给 axios 开启 withCredentials,否则浏览器不会发送 Cookie。这个方案实现最简单,但缺点是无法天然支持移动端、多端同时登录,后端 session 要是存 Tomcat 内存里,重启就丢失。我在内部管理系统里用这个方案,够用。
路线 B 是自定义 Token。登录接口返回一个 token,前端存 localStorage,每次请求在请求拦截器里手动加到 Header。后端不用 session,拦截器里解析 token 查到用户。这个方案灵活,和 Spring Security 接 token 认证也非常顺畅。如果以后这个健身房管理系统要加一个微信小程序会员端,我会切换到路线 B。
我实际项目里用的是路线 B 加一个简单的 token 表,因为保底够稳定,也不依赖 Redis。token 表结构很简单:token 字段、user_id、expire_time。登录时生成 UUID 存表里,拦截器查表判断是否有效。并发量不高的管理系统,这样比 Redis 少一个依赖。
6. 实测阶段最容易翻车的 5 个 SSM 问题
6.1 Spring 与 SpringMVC 容器冲突导致 Service 注入为 null
症状:项目能启动,但一访问接口就报空指针,Controller 里的 Service 是 null。
原因:在 springmvc.xml 里用 <context:component-scan base-package="com.gym"/> 扫描了所有包,连 Service 都扫描进去了。这样 Spring 容器和 SpringMVC 容器各创建了一个 Service 实例,Controller 里注入的可能是另一个容器里的空对象。更隐蔽的是,事务和 AOP 增强失效。
解决:SpringMVC 的 component-scan 只扫描 Controller 包,Service、Mapper、事务这些全部丢给 applicationContext.xml 的扫描去处理。我在 2.2 和 2.3 的配置里已经把这个边界划清楚了。
6.2 Mapper 接口与 XML 绑定报错
症状:启动时报 BindingException:Invalid bound statement (not found)。
原因:常见的有三种。一是 Mapper 接口的包路径和 XML 的 namespace 不匹配;二是接口方法名和 XML 里的 statement id 对不上;三是 Maven 构建时把 src/main/resources 之外的 XML 文件忽略了,classes 目录里根本没有 Mapper.xml(比如你把 XML 放在 java 源码目录下没有配置 resource 时)。
解决:第一种和第二种,检查 namespace 是否等于接口全限定名,方法名和 id 是否一致。第三种更隐蔽,需要在 pom.xml 的 build 里显式声明:
<build> <resources> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.properties</include> <include>**/*.xml</include> </includes> <filtering>false</filtering> </resource> </resources> </build>6.3 分页插件不生效
症状:PageHelper.startPage 之后查询出的 List 没有分页,total 是 0 或者返回全部数据。
原因:PageHelper 使用的是 ThreadLocal 传递分页参数,它只对"紧随其后的第一条 SQL"生效。如果你在执行 SQL 之前先跑了别的查询,或者把 startPage 放在循环里,分页参数就被污染了。另一个常见原因是 mybatis-config.xml 里没有注册 PageInterceptor 插件,或者 pagehelper 版本和 mybatis 版本不兼容。
解决:startPage 后面紧跟需要分页的 Mapper 方法调用,并且尽量把这个逻辑放在 Service 方法内部,不要到处乱调。我 3.2 的示例是最稳妥的写法。如果你在主从库环境里使用 PageHelper 5.x,还要注意把数据库方言参数配好,不然分页 SQL 会生成成方言不兼容的写法。
6.4 事务注解不生效
症状:@Transactional 写在方法上,抛异常后数据库数据没回滚。
原因:最典型的是同类的内部方法调用。A 方法调 B 方法,B 上有 @Transactional,但 A 里写的是 this.b(),绕过了 Spring 生成的代理对象,事务注解根本没被解析。另一个原因是方法不是 public 的,Spring 的代理默认只对 public 方法生效。再一个原因是数据库表用了 MyISAM 引擎,它本身不支持事务,怎么配都不回滚。
解决:事务方法一定放在 Service 接口实现类里,并且要通过注入的 Service 对象调用,不能 this 调用。如果必须内部调用,可以在类里注入自己的代理对象(利用 ObjectProvider 获取当前代理),或者把事务方法拆到单独的 Service 类里。数据库表统一用 InnoDB 引擎。这样处理后,再也不用怕数据写到一半崩了。
6.5 静态资源与中文乱码
静态资源 404 通常是因为 DispatcherServlet 映射了 /,把静态请求也拦截了。springmvc.xml 加 mvc:default-servlet-handler/ 或者 <mvc:resources mapping="/static/**" location="/static/"/>。
中文乱码三种场景:请求参数乱码,是 CharacterEncodingFilter 没配置或 forceEncoding 没有设置成 true;响应 JSON 中文乱码,是因为 Spring 默认的 StringHttpMessageConverter 使用 ISO-8859-1,需要改成 UTF-8;数据库存中文变问号,是 JDBC URL 没有加 characterEncoding=utf8,或者数据库表本身不是 utf8mb4。这三个我分别处理完之后,项目才真正算"中国本地化"跑通。
最后再分享一个不一定写进代码里的经验:SSM 项目能不能顺利跑起来,很多时候不是看框架本身,而是看环境。JDK 版本、Maven 仓库依赖、数据库编码、Tomcat 启动路径,任何一个对不上都能消耗一下午。我习惯先把整个工程放进一个干净的 Tomcat,用 JDK8 跑通全部流程,再考虑换新版本。这套健身房管理系统我现在还在维护,每次有新的框架思路就拿它当试验田,反而成了一块越用越顺手的练手工程。
本文还有配套的精品资源,点击获取