SSM框架整合实战:健身房管理系统从设计到前后端对接全解析
简介本资源是一套基于SSMSpringSpringMVCMyBatis框架开发的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_typeCREATE 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 );会员表 memberCREATE 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) );订单表 ordersCREATE 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-webmvc5.3.23SpringMVC包含 Spring 核心依赖mybatis3.5.10ORM 框架mybatis-spring2.0.7MyBatis 与 Spring 整合的桥梁mysql-connector-java8.0.28MySQL 驱动druid1.2.15数据库连接池pagehelper5.3.2分页插件jackson-databind2.13.4JSON 序列化servlet-api4.0.1Servlet 支持provided 作用域jstl1.2JSTL 标签库非前后端分离时需要这里要特别说一下 mybatis-spring 的版本。低于 2.0 的版本和 Spring 5 一起使用会有兼容问题最典型的报错是 NoSuchBeanDefinitionException 或者 ClassNotFound。如果你用的是 Spring 5 MyBatis 3.5mybatis-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-packagecom.gym context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan !-- Druid 数据源 -- bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/gym_db?useUnicodetrueamp;characterEncodingutf8amp;useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword valueroot/ property nameinitialSize value5/ property nameminIdle value5/ property namemaxActive value20/ /bean !-- SqlSessionFactory -- bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.gym.entity/ property namemapperLocations valueclasspath:mapper/*.xml/ !-- 别名配置MyBatis 全局配置可以引用外部文件 -- property nameconfigLocation valueclasspath:mybatis-config.xml/ /bean !-- 扫描 Mapper 接口生成代理对象 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.gym.mapper/ /bean !-- 事务管理器 -- bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean !-- 开启注解事务 -- tx:annotation-driven transaction-managertransactionManager/mybatis-config.xml 里我主要配置了驼峰映射和分页插件configuration settings setting namemapUnderscoreToCamelCase valuetrue/ /settings plugins plugin interceptorcom.github.pagehelper.PageInterceptor/ /plugins /configurationmapUnderscoreToCamelCase 这个配置强烈建议开启。数据库字段是 card_noJava 属性是 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-packagecom.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-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping !-- 启动 Spring 容器 -- listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param !-- SpringMVC 前端控制器 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping2.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业务逻辑不对找 ServiceSQL 查不出数据找 Mapper。千万别把业务逻辑写在 Controller 里虽然一个查询接口几十行代码也能跑但后面接权限、加日志、做事务的时候会非常痛苦。通用返回体我定义为 Result 类public class ResultT implements Serializable { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT 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 ResultPageResultMemberVO 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 ResultString renew(RequestBody RenewDTO dto) { memberService.renewCard(dto.getMemberId(), dto.getCardTypeId()); return Result.ok(续费成功); } }ServiceImpl 里用 PageHelper 做分页这是 PageHelper 的最高频用法Override public PageResultMemberVO pageQuery(int pageNum, int pageSize, String keyword, Integer status) { PageHelper.startPage(pageNum, pageSize); ListMemberVO list memberMapper.selectMemberPage(keyword, status); PageInfoMemberVO pageInfo new PageInfo(list); PageResultMemberVO result new PageResult(); result.setTotal(pageInfo.getTotal()); result.setList(pageInfo.getList()); return result; }Mapper 接口public interface MemberMapper { ListMemberVO selectMemberPage(Param(keyword) String keyword, Param(status) Integer status); }Mapper XML 里的动态 SQL 是重点关键词模糊搜索和状态筛选靠的是 和 标签select idselectMemberPage resultTypecom.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 testkeyword ! null and keyword ! AND (m.name LIKE CONCAT(%, #{keyword}, %) OR m.phone LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! 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 XMLupdate idincreaseBookedNumber 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;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }注册拦截器用 WebMvcConfigurerConfiguration 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位将盐拼到密码后面一起做 MD5public 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 服务器默认跑在 5173SSM 的 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.jsimport 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-packagecom.gym/ 扫描了所有包连 Service 都扫描进去了。这样 Spring 容器和 SpringMVC 容器各创建了一个 Service 实例Controller 里注入的可能是另一个容器里的空对象。更隐蔽的是事务和 AOP 增强失效。解决SpringMVC 的 component-scan 只扫描 Controller 包Service、Mapper、事务这些全部丢给 applicationContext.xml 的扫描去处理。我在 2.2 和 2.3 的配置里已经把这个边界划清楚了。6.2 Mapper 接口与 XML 绑定报错症状启动时报 BindingExceptionInvalid 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 directorysrc/main/resources/directory includes include**/*.properties/include include**/*.xml/include /includes filteringfalse/filtering /resource /resources /build6.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 没有加 characterEncodingutf8或者数据库表本身不是 utf8mb4。这三个我分别处理完之后项目才真正算中国本地化跑通。最后再分享一个不一定写进代码里的经验SSM 项目能不能顺利跑起来很多时候不是看框架本身而是看环境。JDK 版本、Maven 仓库依赖、数据库编码、Tomcat 启动路径任何一个对不上都能消耗一下午。我习惯先把整个工程放进一个干净的 Tomcat用 JDK8 跑通全部流程再考虑换新版本。这套健身房管理系统我现在还在维护每次有新的框架思路就拿它当试验田反而成了一块越用越顺手的练手工程。本文还有配套的精品资源点击获取