1. 项目拆解:健身网站到底要做什么
先说个实际感受。我见过不少刚学完Java和前端的朋友,拿到“基于SSM+Vue的健身网站”这类题目时,第一反应就是去搜“健身网站源码”,然后下载、改个logo、改个名字,答辩一完就扔了。这种做法其实很亏,因为这类题目真正的价值不在于“做出了一个网站”,而在于你能不能讲清楚“为什么这么设计”“数据是怎么流转的”“遇到问题怎么排查”。这些才是面试和答辩时对方真正想听的。
这个“hi运动健身网站”,从系统类型上说,就是一个典型的业务管理类系统,核心逻辑可以拆成两条线:面向普通用户的C端业务和面向管理员的B端管理。C端用户要能注册登录、浏览健身课程、查看教练信息、在线预约课程、查看健身资讯、记录自己的打卡动态;B端管理员要能维护课程分类、发布课程、管理教练、审核预约、发布公告和资讯、管理用户状态。再把“记录打卡动态”这类功能做得更细一点,还会牵扯到内容发布和互动,比如用户发帖、评论、点赞。
这里我要强调一个很容易被忽视的点:这类“xxx管理系统”的题目,本质上考的是CRUD,但CRUD和CRUD之间差距很大。如果你只是对着数据表做增删改查,页面做出来就像一张Excel表格套了个壳;但如果你能把“用户端+管理端+登录鉴权+预约流程+状态流转”这条完整的业务链路跑通,那这个项目的含金量会立刻不一样。所以我在设计这个项目时,给自己定的标准是:不看代码,光看功能演示,就能看出这是一个“有业务逻辑”的系统,而不是“课程作业”。
再看技术选型。SSM是Spring + SpringMVC + MyBatis的组合,Vue是前端框架,这套搭配到今天依然是很多高校课程设计和中小型企业内部项目的标配。原因很简单:SSM足够轻,学起来有清晰的层次感——Controller管接收请求、Service管业务逻辑、Mapper管数据库操作,这种分层对新手极其友好;Vue则解决了原生JS操作DOM的繁琐问题,用数据驱动视图,配合Element UI这类组件库,两天就能把后台管理页面搭得像模像样。所以用这套技术栈做健身网站,不是因为它“最新最火”,而是因为它能让你在有限的时间内,把精力花在业务逻辑和前后端交互上,而不是纠结于过于复杂的环境配置和框架特性。
1.1 需求画像与核心功能拆解
我在动手写代码之前,习惯先把用户角色和功能清单列成一张表,后面写接口、建表都会围绕这张表展开。这个项目主要涉及两种角色:
| 角色 | 核心诉求 | 对应功能 |
|---|---|---|
| 普通用户 | 快速找到想练的课程、约到合适的教练、看到自己的训练记录 | 注册登录、课程浏览与筛选、教练查看、课程预约、个人打卡记录、资讯浏览 |
| 系统管理员 | 高效维护平台内容、管理用户和预约 | 登录、用户管理、课程分类管理、课程管理、教练管理、预约审核、资讯发布、轮播图管理 |
这看起来很简单,但每一块展开都有细节。比如“课程预约”就不是简单的“用户点一下按钮、数据库插入一条记录”就完了,你得考虑:同一节课能不能重复预约?用户爽约了怎么办?教练的排课时间和已有预约冲突怎么判断?这些想想都很大,但落到代码上其实就是几个状态字段和几条查询语句的问题。做项目最忌讳的是“功能看起来都有,但每一个都是浅尝辄止”,所以我会挑几个有代表性的功能做深,比如预约流程给足状态流转,资讯模块加上带图片和时间的列表展示,打卡动态用用户Id关联查询,这样整体项目就能呈现出一种“完整”的感觉。
1.2 为什么是SSM + Vue,这套组合的底气在哪
现在很多教程上来就推Spring Boot + Vue,说SSM“过时了”。我承认Spring Boot在配置简化上的确省事,但学习SSM有一个不可替代的好处:它把SpringMVC的请求处理流程、MyBatis的映射机制、Spring的依赖注入和AOP都“强制”你亲手配置了一遍。你会在web.xml里配DispatcherServlet,会写applicationContext.xml和spring-mvc.xml,会体会MyBatis的Mapper接口和XML映射文件怎么对应,会理解为什么扫描包、事务注解要这么写。这些知识一旦搞明白,再看Spring Boot就会觉得“哦,原来它只是帮我把这些配置自动完成了”,底层原理一通百通。所以如果你正在做这个题目,我建议老老实实走SSM的原始配置流程,不要图省事。
Vue这边也是一样。这个项目选Vue而不是JSP或者Thymeleaf,最大的收益是前后端分离。前端通过Axios向后端要数据,后端只负责按接口返回JSON,两者各干各的。这样就带来了一个实打实的好处:调试效率高。我一个人写前后端的时候,经常是后端接口还没写完,前端先拿Mock数据把页面渲染好了;后端接口一测通,前端把请求地址一换,页面立刻就有数据了。这种开发节奏在前后端不分离的时代想都不敢想。再加上Vue的组件化开发——导航栏、课程卡片、分页条、弹窗这些都能抽成组件,管理端和用户端很多UI还能复用,开发效率直线上升。
1.3 系统架构与部署形态
整个系统我建议用经典的“单体应用 + 前后端分离部署”模式:后端就是一个Maven打包出来的war包或jar包,跑在Tomcat上;前端是一个独立的Vue工程,开发时通过proxy代理转发接口请求,上线时build出静态文件,要么扔进Tomcat的webapps里,要么用Nginx单独托管。热词里有个“vue打包放进springboot中”,这个在SSM时代对应的做法就是:将前端build出来的dist目录,复制到Maven项目的src/main/webapp目录下,这样打出来的war包天生就带前端页面,部署一个Tomcat实例就能跑整套系统。这对课程设计尤其合适,因为答辩现场通常网络环境复杂,你少一个依赖就少一个事故点。
当然,如果你希望前后端彻底分离,那就把dist静态文件放到Nginx,后端接口放到Tomcat,通过反向代理配置接口路径的转发。两种方式我都试过,如果只是课程设计和找工作阶段的个人项目,我更推荐“合并打包”的方式,省心。
2. 数据库设计:别小看这几张表的设计逻辑
数据库是这类项目的命门。我见过太多人上来就建表,建到一半发现业务对不上,然后疯狂加字段、删字段,最后表结构一团糟,关联查询写不出来,只能凑合用内存去凑数据。所以我一般会先画一张“业务流转图”,再根据流转路径去设计表。
这个健身网站的核心业务流转大概是:
用户注册登录 → 浏览课程列表 → 查看课程详情 → 选择合适的教练 → 提交预约 → 管理员审核预约 → 用户到店训练 → 用户发布训练打卡动态。
围绕这条链路,核心表可以拆出这么几张:用户表(user)、管理员表(admin)、课程分类表(category)、课程表(course)、教练表(coach)、预约表(reservation)、资讯表(article)、打卡动态表(moment)。如果还需要轮播图,那就加一张banner表;如果想做评论互动,再加comment表。但我不建议第一次就建十几张表,7到9张表是这个项目的黄金数量,既能体现系统的完整性,又不至于让维护成本和编码量失控。
2.1 核心表结构设计要点
用户表是基础,字段基本是id、username、password、nickname、phone、avatar、sex、create_time、status。这里有两个细节:密码不要存明文,用MD5加密后再入库;status一定要有,用户禁用功能就靠它实现。admin表结构类似,就是换了个角色标志。
课程分类表和课程表是一对多的关系。category表就id、name、sort_order三个字段。course表字段要多一些:id、category_id、name、cover、description、price、difficulty(难度)、total_count(总名额)、booked_count(已预约人数)、status(上架/下架)、train_time(上课时间)。这里面booked_count是典型的“冗余字段”,它不属于必需的规范设计,但配合预约功能用起来非常方便,每次预约成功就update一下,避免每次都count一遍数据库。当然缺点是要注意并发问题,但在课程设计层面这种写法完全够用,这就是“在合适的场景做合适的取舍”。
教练表可以独立存在,也可以和用户表做关联。我的习惯是独立一张coach表,除了name、avatar、title(头衔)、years(从业年限)、specialty(擅长方向)、intro(个人介绍),再加上一个coach_no(教练编号)。注意,如果用“用户去预约教练”这种模式,那就需要把coach表和user表做上关联;如果只是展示教练信息,教练不参与系统登录,那coach表完全独立即可。我建议后者,因为业务更简单清晰,对新手也更友好。
预约表是整个系统的灵魂。字段包括:id、user_id、course_id、coach_id、appointment_date、status(待审核/已通过/已拒绝/已完成)、remark。这张表的设计决定了你的预约业务能“玩出多少花样”。status字段是重中之重,我后面写业务逻辑的时候会详细展开。
资讯表和打卡动态表本质上都是“内容发布”类表,字段思路接近:id、user_id(或admin_id)、title、content、cover、create_time、view_count。打卡动态可以额外加一个likes字段模拟点赞数。两表独立的原因是来源不同:资讯是管理员发布,动态是用户发布。
2.2 表关联关系的“少即是多”原则
我在教学员做项目时反复强调:能用逻辑关联解决的,不要建物理外键。换句话说,表与表之间不要直接写外键约束,而是通过业务字段去关联查询。比如预约表里存user_id和course_id,在查询预约记录时,通过关联查询把用户名、课程名、教练名一起查出来。这样做的好处有很多:删除数据时不会被外键约束卡死,插入数据时只要保证Id正确就行,数据库的执行效率也更高,最重要的是——你在写Mapper时能更清楚地控制查询逻辑。MyBatis里的关联查询用JOIN写起来并不复杂,但新手最容易犯的错是把JOIN滥用,一次查询涉及五六张表,出问题以后完全不知道从哪查起。我的建议是,单次查询最多关联三张表,如果再复杂,就拆成两次查询在Service层组装,代码可读性会好很多。
3. 后端SSM实现:分层、注解与关键配置拆解
后端代码的组织方式,我见过很多版本,最推荐的是标准四层结构:controller、service、mapper(dao)、entity(pojo),再加上config、common、utils这些辅助包。工程结构美观与否,关系到答辩时老师对你的第一印象。我推荐这样组织:
src/main/java ├── com.hi.fitness │ ├── controller # 控制层,只负责参数接收和返回 │ ├── service # 业务层,核心逻辑都放这里 │ │ └── impl # 业务实现类 │ ├── mapper # MyBatis Mapper接口 │ ├── entity # 实体类 │ ├── common # 统一返回结果、常量类 │ ├── config # 配置类(如拦截器、跨域配置) │ ├── interceptor # 登录拦截器 │ └── util # 工具类(如MD5加密)这一套结构不是摆着好看的。控制层保持“瘦”,业务逻辑全部下沉到Service,这样如果答辩时老师让你改一个业务规则,你只需要动ServiceImpl里的方法,不需要在Controller里揪逻辑,改起来极其舒服。
3.1 SSM常用注解,一个个说清楚
SSM无非就是Spring、SpringMVC、MyBatis三块,而这三块最直观的体现就是注解。我把这个项目里一定会用到的注解整理一遍。
Spring核心注解:
- @Component:通用的Spring容器Bean,基本用来标注不太能归类的组件。
- @Service:标注业务层实现类,语义清晰,配合扫描包自动注册成Bean。
- @Repository:标注数据访问层,也就是Mapper接口的实现类(MyBatis会动态生成代理实现)。
- @Autowired:按类型自动注入依赖。要注意的是,如果同一类型有多个实现类,会报“NoUniqueBeanDefinitionException”,这时候要用@Qualifier指定Bean名称。
- @Transactional:加在Service类或方法上,实现事务管理。预约课程时,插入预约记录和更新课程booked_count必须放在同一个事务里,保证要么都成功要么都失败。这条我建议直接在类级别加上,方法级别做细化控制。
SpringMVC注解:
- @Controller:标注这个类是一个控制器。
- @RestController:@Controller + @ResponseBody的组合,方法返回对象时自动转JSON。前后端分离项目我全部用@RestController。
- @RequestMapping:类级别和方法级别都能加,用来映射URL。类上加,方法上也加,形成一个完整的访问路径。
- @GetMapping、@PostMapping、@PutMapping、@DeleteMapping:@RequestMapping的细化版本,分别映射GET、POST、PUT、DELETE请求。做RESTful接口时四个都要用到。
- @PathVariable:从URL路径中取值,比如“/course/{id}”里取id。
- @RequestParam:从查询字符串里取值,比如“?pageNum=1&pageSize=10”。
- @RequestBody:把前端传来的JSON字符串自动绑定成Java对象。POST和PUT请求基本都要用。
MyBatis注解:
- @Mapper:标注在Mapper接口上,告诉MyBatis这个接口要生成代理实现。也可以在启动配置类上用@MapperScan统一扫描包。
- @Select、@Insert、@Update、@Delete:可以直接在接口方法上写简单SQL,但复杂SQL建议用XML。
- @Param:当SQL参数超过一个时,用@Param给每个参数命名,避免MyBatis参数绑定报错。
每错一次注解,背后都是一个知识点。我记得有一次项目里报了“Invalid bound statement (not found)”错误,查了半天发现是Mapper接口的类全限定名和XML文件里的namespace写错了。这种坑,自己踩一次就记住了,所以我建议你在做项目时多关注报错日志,而不是只盯着浏览器页面看效果。
3.2 统一返回结果体与全局异常处理
前后端分离项目里,接口返回的数据格式一定要统一,否则前端处理起来会极其痛苦。我习惯定义一个Result类,结构是这样的:
public class Result<T> { private Integer code; // 200成功,500失败 private String message; // 提示信息 private T data; // 业务数据 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); 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; } }前端拿到数据后,统一判断res.code === 200再取res.data,异常状态下直接弹message提示。这个规范成本很低,但带来的收益是整个联调过程极其清爽。
全局异常处理方面,可以使用SpringMVC的@ControllerAdvice加@ExceptionHandler注解。比如自定义一个业务异常类BizException,当业务校验不通过时抛出来,全局异常处理器统一捕获并返回Result.error(e.getMessage())。这样Controller里就完全不需要try-catch,Service里该抛就抛,代码干净得多。
3.3 登录鉴权:拦截器 + Session还是JWT?
登录鉴权是这个项目里比较容易被忽视但一定会被问到的问题。两种主流做法:一种是传统的Session,一种是JWT。课程设计项目我推荐Session,因为代码简单、方便演示,也符合SSM这套传统技术栈的气质。实现方式就是写一个LoginInterceptor,实现HandlerInterceptor接口,在preHandle方法里从session中取出登录用户,取不到就跳转到登录页或返回状态码。
但如果你的项目是前后端完全分离部署,我建议用JWT。前端登录后,后端签发一个token,前端存到localStorage里,每次请求在请求头中带上“Authorization: Bearer ”,后端通过拦截器校验token并解析出用户信息。这里的关键是写一个JwtUtil工具类,封装token的创建和解析。课程设计答辩时,老师问“你并发登录怎么控制”“刷新页面登录状态会不会丢”,你能解释清楚Session和JWT的区别和选型理由,这一关就稳了。
4. 前端Vue实现:组件化开发与关键功能落地
Vue这部分是很多同学最头疼的,倒不是语法有多难,而是“不知道页面从哪开始搭”。我建议按这个顺序来:先搭基础框架(路由+布局),再做登录页处理token,然后做用户端的列表页和详情页,最后做管理端的增删改查页。每一步都是独立的,风险可控。
4.1 Vue环境配置:版本选择很重要
现在Vue有Vue2和Vue3两个大版本,还有Vite和Vue CLI两种脚手架方式。课程设计选型,我强烈建议Vue2 + Element UI,为什么?因为网上现成的案例、组件封装、教程几乎都是这个组合,遇到Bug搜一下马上就有答案。当然,如果你对Vue3的Composition API比较熟,用Vue3 + Element Plus也行,但网上配套的“管理系统模板”相对少一些,出问题研究成本高。这不是说Vue3不好,而是说在项目期限内要追求最高的确定性。
环境配置这块,我踩过一个很典型的坑:Node版本过高,导致旧版Vue CLI创建项目失败或者node-sass编译报错。解决办法也很简单,要么用nvm切换Node到稳定版本(比如14或16),要么改用sass(dart-sass)替代node-sass。你可以用Vue CLI的图形化界面,命令行里执行vue ui,这样创建项目、安装依赖、运行开发服务器都通过浏览器界面操作,对新手非常友好。
4.2 路由设计:vue-router怎么规划页面
路由设计直接决定项目结构清晰度。我会把路由核心拆两条:
用户端页面(layout为外层框架),包括:首页、课程列表、课程详情、教练展示、预约管理、资讯列表、资讯详情、个人中心、登录、注册。
管理端页面(admin layout为外层框架),包括:仪表盘、用户管理、课程管理、分类管理、教练管理、预约审核、资讯管理、轮播图管理。
写路由时,用vue-router的懒加载方式,通过路由懒加载可以让首屏加载快很多。关键代码如下:
const routes = [ { path: '/', component: Layout, children: [ { path: '', name: 'Home', component: () => import('../views/Home.vue') }, { path: 'courseList', name: 'CourseList', component: () => import('../views/CourseList.vue') }, { path: 'courseDetail/:id', name: 'CourseDetail', component: () => import('../views/CourseDetail.vue') }, { path: 'login', name: 'Login', component: () => import('../views/Login.vue') } ] }, { path: '/admin', component: AdminLayout, children: [ { path: 'courseManage', name: 'CourseManage', component: () => import('../views/admin/CourseManage.vue') } ] } ]路由里面最常被面试问到的就是动态路由和路由守卫。动态路由是指根据用户角色或权限动态添加菜单和路由,比如管理员能看到管理端页面,普通用户看不到。实现上可以用router.addRoutes,配合后端返回的权限菜单数据。路由守卫用beforeEach,在跳转到每个页面之前判断登录状态。这段代码是我每做一个项目都会留好的“加分项”:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path === '/login') { next(); } else if (!token) { next('/login'); } else { next(); } });4.3 状态管理与Axios封装
Vue2配Vuex、Vue3配Pinia,这是约定俗成的组合。健身网站这类项目,状态管理主要存用户信息、登录状态、购物车或预约的临时数据。我用Vuex更多是存用户信息,登录成功之后commit一个mutation,把用户信息写进state,在个人中心直接this.$store.state.userInfo拿。注意Vuex的刷新丢数据问题,所以用户信息同时也要存一份到localStorage,刷新时再从localStorage回填。
Axios封装是前端工程质量的分水岭。我习惯在src/utils/request.js里封装一个axios实例:
import axios from 'axios'; import { Message } from 'element-ui'; import router from '../router'; const request = axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }); request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { Message.error(res.message); return Promise.reject(new Error(res.message)); } return res; }, error => { if (error.response && error.response.status === 401) { Message.error('登录状态已失效,请重新登录'); localStorage.removeItem('token'); router.push('/login'); } else { Message.error('网络请求失败'); } return Promise.reject(error); } ); export default request;封装好以后,所有接口调用都走这一个实例,统一错误提示、统一token携带、统一跨域处理,接口联调效率至少提升一倍。
4.4 核心页面的实现思路与组件化
首页一般做轮播图 + 热门课程 + 最新资讯。轮播图组件直接用Element UI的el-carousel,数据来源是后端banner表。热门课程调接口/course/hot,后端按预约量排序返回前6条。页面不要写得太满,清爽大方就好。
课程列表页要做筛选条件,左侧是课程分类,上面是搜索框,下面是卡片网格。这里要会Vue的核心语法:v-for遍历渲染课程卡片,:key绑定course.id;v-if判断列表空态;计算属性computed做前端条件筛选。卡片组件命名CourseCard,props接收一个course对象,点击跳转详情页。
课程详情页是重点页面,要展示课程封面、名称、难度、总名额、已预约人数、上课时间、详细介绍。预约按钮要判断登录状态:未登录跳登录页,已登录但已经预约过这节课,按钮置灰并显示“已预约”,预约满了显示“名额已满”。这套交互逻辑需要后端接口配合,写一个/reservation/check?courseId=xxx,后端返回当前用户是否已预约。
个人中心做成收藏、预约记录和打卡动态三个Tab。预约记录里能看到每条预约的状态,管理员审核通过后显示“已确认”,用户还能在“已完成”状态后发布打卡动态。这个设计流程闭环,答辩时就是绝佳的演示素材。
管理端页面用Element UI的el-table + el-dialog + el-form组合。表格展示列表数据,右上角“新增”按钮弹对话框填表单,每一行右侧有“编辑”“删除”按钮。这些逻辑套路完全一致,先写一个课程管理的完整增删改查,其他分类、教练、资讯管理全部照着抄,只是字段不同而已。
4.5 细节场景:视频播放、地图、按钮权限的落地思路
热词里有一些看上去跟健身网站不太搭的内容,比如vue播放m3u8、腾讯地图、按钮权限。但这些其实都能在健身网站里找到合理的应用场景,写进项目里反而是亮点。
m3u8视频播放:健身网站如果要做付费课程或录播课,视频播放是一个绕不开的功能。很多课程视频的流媒体格式是m3u8,这是HLS协议的标准格式。Vue里播放m3u8,最稳妥的方案是使用video.js + videojs-contrib-hls插件。安装方式:npm install video.js videojs-contrib-hls,然后在组件里初始化播放器并设置播放源。这个方案不挑浏览器,兼容性比原生video标签好很多。如果你用的是Vue3,还可以关注hls.js这个库,思路也是一样的,先拿到视频地址,再交给播放器渲染。
地图功能:健身网站如果需要展示门店位置,接入地图是刚需。很多教程会让你在高德或百度地图的开发平台注册账号、申请key、下载SDK,步骤繁琐。但如果你只是展示一个固定地址,用一个轻量方案——嵌套一个iframe调用地图的Web版页面,几分钟搞定。如果确实要做自定义标记点,那在Vue组件里通过JS SDK加载地图并添加Marker,需要注意在Vue的mounted钩子里初始化地图,避免DOM还没渲染完成就创建地图实例。
按钮权限控制:很多面试题会问到“前端按钮权限怎么控制”。在这个系统里可以做一个简化版:定义一个permissions数组存在Vuex里,管理员登录后从后端拿到这个权限列表。封装一个v-permission指令,在指令的inserted钩子里判断用户权限是否包含指令传入的值,如果不包含就移除这个按钮的DOM节点。这个实现十几行代码搞定,但答辩的效果非常足,比单纯“隐藏菜单”高级得多。
5. 实操:从零跑通一条核心业务闭环
这一节我以“用户预约课程”这条链路为例,把后端接口、前端页面、数据流转完整串联一遍。看完这条链路,项目中80%的CRUD开发就都能举一反三了。
5.1 后端:课程列表与预约接口的实现
先写课程列表接口。用户端首页和课程列表页需要调它。Controller层很简单:
@RestController @RequestMapping("/course") public class CourseController { @Autowired private CourseService courseService; @GetMapping("/list") public Result<List<CourseVO>> list(@RequestParam(required = false) Integer categoryId, @RequestParam(required = false) String keyword) { return Result.success(courseService.getCourseList(categoryId, keyword)); } }Service层的实现:
@Service public class CourseServiceImpl implements CourseService { @Autowired private CourseMapper courseMapper; @Override public List<CourseVO> getCourseList(Integer categoryId, String keyword) { return courseMapper.selectCourseList(categoryId, keyword); } }Mapper接口:
@Mapper public interface CourseMapper { List<CourseVO> selectCourseList(@Param("categoryId") Integer categoryId, @Param("keyword") String keyword); }对应XML文件:
<select id="selectCourseList" resultType="com.hi.fitness.vo.CourseVO"> SELECT c.*, ct.name AS categoryName FROM course c LEFT JOIN category ct ON c.category_id = ct.id <where> <if test="categoryId != null"> AND c.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND c.name LIKE CONCAT('%', #{keyword}, '%') </if> AND c.status = 1 </where> ORDER BY c.create_time DESC </select>这里的动态SQL用到了MyBatis的if标签和where标签,条件可空是列表页的标配,一定要掌握。
预约接口是整个后端逻辑的“含金量”所在:
@PostMapping("/reserve") public Result<String> reserve(@RequestBody ReservationDTO dto, HttpSession session) { User user = (User) session.getAttribute("loginUser"); if (user == null) { return Result.error("请先登录"); } return Result.success(reservationService.reserve(user.getId(), dto.getCourseId())); }Service层要做三件事:
@Override @Transactional public String reserve(Integer userId, Integer courseId) { // 1. 查询课程信息 Course course = courseMapper.selectById(courseId); if (course == null) { throw new BizException("课程不存在"); } // 2. 校验名额是否已满 if (course.getBookedCount() >= course.getTotalCount()) { throw new BizException("课程名额已满"); } // 3. 校验是否重复预约 Reservation exist = reservationMapper.selectByUserAndCourse(userId, courseId); if (exist != null) { throw new BizException("您已预约过该课程"); } // 4. 插入预约记录,更新已预约人数 Reservation reservation = new Reservation(); reservation.setUserId(userId); reservation.setCourseId(courseId); reservation.setStatus(0); // 0待审核 1已通过 2已拒绝 3已完成 reservationMapper.insert(reservation); courseMapper.updateBookedCount(courseId, 1); return "预约成功,请等待管理员审核"; }这段代码把每天写增删改查时最核心的几件事全涵盖了:入参校验、业务规则校验、事务保证、状态字段设计。预约成功后bookedCount加1,这行更新必须和insert放进同一个@Transactional事务里,否则插入成功但名额没更新,数据就会不一致。
5.2 前端:课程列表渲染与预约操作
后端接口写好后,前端先封装对应的API模块,新建src/api/course.js:
import request from '../utils/request'; export function getCourseList(params) { return request({ url: '/course/list', method: 'get', params }); } export function reserveCourse(data) { return request({ url: '/reservation/reserve', method: 'post', data }); }课程列表组件里这样用:
export default { data() { return { courseList: [], loading: false }; }, created() { this.fetchCourses(); }, methods: { async fetchCourses() { this.loading = true; try { const res = await getCourseList({}); this.courseList = res.data; } finally { this.loading = false; } }, handleReserve(course) { if (!this.checkLogin()) { this.$router.push('/login'); return; } this.$confirm(`确认预约课程“${course.name}”吗?`, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(async () => { const res = await reserveCourse({ courseId: course.id }); this.$message.success(res.message); this.fetchCourses(); }).catch(() => {}); } } };这套模式就是你项目里其他所有“点击触发业务”的标准范式:created钩子拉数据,methods里处理业务,操作成功后刷新列表。
5.3 打包部署:把Vue客户端放进SSM后端
开发完成后,下一步就是打包部署,这里我用“前端打进war包”的方案。先在前端工程根目录执行npm run build,build完成后会在dist目录生成静态文件。然后把这整个dist目录里的内容,复制粘贴到SSM后端项目的src/main/webapp下面。再用Maven执行mvn clean package,打成war包部署到Tomcat。
要注意几个细节。第一个:前端请求的baseURL要写相对路径或同域绝对路径,千万不要写localhost带端口。因为部署后前端静态资源由Tomcat同域服务,接口请求地址应该是以/api开头的同源路径。比如baseURL设置为'/api',后端Controller的RequestMapping也要统一加上/api前缀,或者用server.servlet.context-path来配置。第二个:Vue Router要开启mode: 'history'的话,部署后刷新404会让人头疼,我建议直接使用默认的hash模式,简单可靠。
还有一个小技巧:如果你不想每次改前端都重新复制dist到webapp,可以在pom.xml里配置maven-resources-plugin,把前端dist目录作为资源目录直接打进war包,这样打包一步到位:
<build> <resources> <resource> <directory>${project.basedir}/src/main/resources</directory> </resource> <resource> <directory>../hi-fitness-ui/dist</directory> <targetPath>static</targetPath> </resource> </resources> </build>当然,路径要根据你实际的前端工程位置调整。这个配置是我在多个项目里验证过的方案,能省去手工复制的重复劳动,值得收藏。
6. 常见问题与排查技巧实录
做这个项目的人,遇到的坑几乎都是同一个套路:环境问题、依赖问题、路径问题、跨域问题。我把我踩过和帮别人排查过的高频问题整理成一张速查表。
| 常见问题 | 现象 | 排查思路与解决方案 |
|---|---|---|
| Maven依赖冲突 | 启动Tomcat报ClassNotFoundException或NoSuchMethodError | 检查pom.xml中是否重复引入同一个jar的不同版本,用mvn dependency:tree查看依赖树,排除冲突版本 |
| MyBatis绑定错误 | 启动报Invalid bound statement (not found) | 检查Mapper接口类名和方法名是否与XML的namespace、id完全一致;检查XML文件是否在resources目录且路径与接口全限定名对应 |
| XML文件没打包进去 | 运行时找不到Mapper映射文件 | 检查pom.xml是否把xml文件过滤掉了,需要加上resources配置将src/main/java下的xml文件也打包进去 |
| 前端跨域 | 浏览器报CORS错误,Network里看到请求被block | 开发环境用Vue CLI的proxy代理;部署环境要么在后端配@CrossOrigin配置类,要么用Nginx反向代理统一域名 |
| 数据库连接失败 | 启动报Cannot create PoolableConnectionFactory | 检查MySQL服务是否启动、用户名密码是否正确、数据库名是否拼对、MySQL驱动版本是否和数据库版本匹配 |
| 中文乱码 | 查询结果或控制台日志中文变成问号 | 后端连接池配置加上characterEncoding=utf8,页面统一UTF-8,Tomcat的URIEncoding也要设置成UTF-8 |
| Vue项目npm install失败 | 各种node-sass、gyp报错 | 优先确认Node版本,建议用nvm切换到14或16;node-sass安装失败改用sass,或者配置淘宝镜像源 |
| 文件上传大小受限 | 上传图片提示超长或413 | 在SpringMVC配置multipart解析器,设置maxUploadSize;如果是Tomcat,还要注意maxSwallowSize和连接器的maxPostSize |
| 列表页数据显示undefined | 前端渲染字段名跟后端返回字段对不上 | 打开浏览器Network看实际返回的JSON字段名,跟页面模板里的字段逐一比对,注意下划线转驼峰的对应关系 |
| 定时任务不执行 | 有些项目想加“每日推荐”定时任务但没效果 | 检查定时任务类是否被Spring扫描到,@EnableScheduling是否在配置类上开启,时间表达式是否正确 |
6.1 耦合问题:@Autowired注入为null
这个问题出现频率极高,报了NullPointerException,原因几乎都是对象不是Spring容器管理的。常见场景:在工具类的静态方法里用@Autowired注入Mapper,或者在一个new出来的普通类里注入Service。Spring的依赖注入只对容器管理的Bean生效,你用new创建的对象,Spring根本不会帮你注入任何依赖。正确做法是把工具类也交给Spring管理,或者在工具类中通过SpringContextUtil获取Bean。这类问题的排查思路很直接:看报错栈里的类是怎么被创建出来的。
6.2 前端接口联调时最容易忽略的细节
我在带人做前后端联调时,发现新手最常犯的错误是前端传参格式跟后端要求不匹配。最常见的就是数组和对象的序列化差异。比如后端接口接收一个对象,前端却把对象拆成多个query参数传过去,后端自然拿不到数据。再比如后端用@RequestBody接收JSON请求体,前端Axios默认会把对象序列化成JSON字符串,但如果你给请求头设置了Content-Type: application/x-www-form-urlencoded,那后端就解析不出来了。封装Axios时一定注意post请求的Content-Type默认就是application/json;charset=UTF-8,不要轻易改。
还有一个实战经验:联调时不要只盯着浏览器控制台的报错,要打开Network面板看具体的请求URL、请求头和响应体。前端所谓的“接口有问题”,十有八九是请求参数不对、URL拼错、或者后端返回的其实是个错误信息但你没看响应体。学会了看Network,联调效率直接翻倍。
6.3 数据库层面的两个隐蔽坑
第一个是MySQL关键字冲突。我见过有人建表用user,这个在MySQL里其实没问题(它不是MySQL的保留关键字),但如果你用到order、group、desc这些词当字段名,SQL执行就会报语法错误。解决办法是写SQL时给字段名加反引号,但更推荐的做法是避免用关键字命名。第二个是自增主键冲突后继续自增,导致Id跳号,删除中间数据后id不连续。这个在逻辑上完全不影响功能,但有些同学会紧张。其实这个现象叫作“自增主键不连续”,MySQL本身就会这样,属于正常行为,不用管它。
7. 写在最后的几点体会
这个项目我前前后后带人做过好几轮,每次做完最大的感受就是:SSM + Vue做健身网站,难度不在于技术本身,而在于你愿不愿意把一条业务链路做到底。很多人做到一半就卡在“前端页面像后台模板”“后端接口只是查数据存数据”,原因就是没有把业务状态流转设计到极致。如果你能把“用户预约课程到管理员审核再到用户完成训练打卡”这条闭环做完整,把设计中每一步的道理都讲清楚,那不管是课程答辩还是面试聊项目,你都会非常有底气。
最后再分享一个小技巧。项目做完之后,别急着交差,花两个小时写一份README,把系统的功能截图、技术架构、数据库表结构、核心接口文档整理进去。这份README的价值,不只是让你答辩时照着讲,更重要的是它会逼你重新梳理一遍整个项目的设计思路。你梳理的过程中发现自己有哪些点讲不清楚,那大概率就是知识漏洞——趁现在赶紧补,才是做这个项目真正的收获。