ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot + Vue 家教预约平台系统开发实战全解析

Spring Boot + Vue 家教预约平台系统开发实战全解析 简介基于Java与Spring Boot、Vue开发的家教预约平台系统定位为毕业设计或课程设计的完整项目方案适合需要快速搭建前后端分离家教信息网站的大学生与开发者。资源包共包含368个文件大小仅9.59兆内有87个Java后台源码、44个Vue前端组件、42个TypeScript逻辑文件以及大量JPEG图片与SVG图标还提供SQL初始化脚本、部署脚本和界面预览图目录结构直观便于二次开发。系统前台覆盖首页、家教详情、家教驻场与用户中心后台则包含家教管理、分类管理、标签管理、评论管理、用户管理、运营管理、日志管理、统计分析、系统信息等模块功能较完整贴近真实业务场景并内置管理员默认账号便于快速体验。已有117人学习下载适合作为JavaWeb课程设计或毕业设计的参考也可帮助研究者理解前后端分离项目的整体架构、Spring Boot拦截器与控制器等典型代码组织方式。1. 家教预约平台项目先分清网站与平台拿到“基于 Java 的家教信息网站Spring Boot Vue 开发的家教预约平台系统”这个题目第一件事不是建项目而是想清楚网站和平台的区别。网站做到教师信息展示、课程浏览就能收工平台必须跑通“预约”这条完整业务链学生选课提交预约、教师确认或婉拒、课后互相评价每一步都涉及状态变更和角色权限。Vue 负责把操作界面做得顺手Spring Boot 负责把状态流转规则写严密两边通过 RESTful 接口协作。这套系统真正难的地方在于预约状态如何设计不失控、权限如何控制不让学生改掉教师的确认结果、时间冲突如何在校验层挡住。下面按业务建模、后端接口、前端联调、答辩准备四个部分展开目标是把这套系统做到“能演示、经得起追问”。适合已经写过 Spring Boot 和 Vue 基础 Demo、准备用完整项目做毕业设计或课程设计的人。2. 业务建模与状态机家教预约平台的表结构与角色边界2.1 三角色与五张表的 ER 划分别把教师资料塞进用户表家教平台牵扯三个角色学生、教师、管理员。学生登录浏览课程、提交预约教师维护课程、处理预约管理员审核教师入驻、管理全站内容。最省事的做法是单张 user 表用 role 字段区分密码只存加密后的值。role 用字符串 ADMIN / TEACHER / STUDENT 而不用 0 / 1 / 2理由很简单这种代码要给别人读字符串能一眼看出含义省得再看一遍注释。教师资料不要全塞进 user 表。user 表只放登录凭证和通用字段教龄、科目、个人简介、教学时间段这些放 teacher_profile 表通过 user_id 关联。否则以后加“可授课时间段”字段user 表会越改越臃肿。课程表归属于教师预约表是系统核心表承担整条状态流转评价表通过预约 ID 关联保证一单一评。常见做法是先画一张简单 ER 图把表关系定下来再写 SQL。表结构可以按下面对齐表名核心字段在系统里的作用userid, username, password, role, phone统一登录与角色判断teacher_profileid, user_id, subject, intro, teaching_time教师资料扩展避免污染 user 表courseid, teacher_id, title, cover, price, duration课程发布与展示bookingid, student_id, course_id, teacher_id, status, book_date, remark预约记录与状态流转reviewid, booking_id, score, content, create_time课后评价一单一评booking 表里冗余 teacher_id 是因为列表页要按教师筛选与其每次联表 course 再取 teacher不如下单时直接快照一份。价格同理在 booking 里冗余一份避免教师改价后历史预约金额跟着变。这种冗余在答辩时被问到解释成“查询优先的取舍”就能过关。2.2 预约状态先设计成枚举代码里不裸写 0/1/2预约表的 status 字段是整个系统的核心。如果状态用硬编码Service 层就会写出一堆if (status 0)魔法数字后来人根本不知道 0 是什么。常见做法是先定义一个枚举类把 code 和 desc 绑定再提供 fromCode 静态方法把入库的 tinyint 还原成可读枚举public enum BookingStatus { PENDING(0, 待确认), CONFIRMED(1, 已确认), CANCELED(2, 已取消), COMPLETED(3, 已完成); private final int code; private final String desc; BookingStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static BookingStatus fromCode(int code) { for (BookingStatus status : values()) { if (status.code code) { return status; } } throw new IllegalArgumentException(未知预约状态: code); } }状态迁移用大白话说就是学生提交后是 PENDING教师确认后变成 CONFIRMED上课结束由教师标记 COMPLETEDPENDING 和 CONFIRMED 状态下都可以取消取消后为 CANCELED。代码里做流转判断时先用 fromCode 取出当前状态再比较能杜绝 0 / 1 写法带来的歧义。至于为什么存数据库时用 tinyint 而不是直接存字符串一是 tinyint 只占 1 字节索引用起来更小二是枚举类在代码层约束了合法值不会出现有人手滑写了个 PENDDING 进库的情况。DB 存数字、代码层映射成枚举这是后端八股文里典型的状态机设计。2.3 为什么是 Spring Boot Vue这个组合对毕设项目的性价比最高Spring Boot 的价值体现在内置 Tomcat、自动配置和 starter 生态。同一套业务逻辑SSM 要配合各种 XML 配置Spring Boot 一个启动类就解决这对毕设周期的性价比很高。配合 MyBatis-Plus单表 CRUD 连 XML 都不用写条件查询用 LambdaQueryWrapper 就能拼出来。前端选 Vue 是因为组件化对“学生端、教师端、管理端三套页面共用一个壳”的场景相当合适。一个布局组件加嵌套路由三端页面共用侧边栏和顶栏比 JSP 时代复制多套模板清爽得多。新写项目直接用 Vue 3 Vite不要在 Vue 2 上考证。一个高频坑是版本匹配。现在的毕设大多选 Spring Boot 3.x它要求 JDK 17 及以上包名也从 javax.* 全面迁移到了 jakarta.*。照抄网上 Spring Boot 2 教程时会报“程序包 javax.servlet 不存在”这不是代码拼错是依赖链路整体升了一代。同理MyBatis-Plus、Knife4j、Redis 客户端都要用兼容 Boot 3 的版本。立项前先确认组件版本矩阵能省掉后期半天排错。3. 后端实现Spring Boot 分层、JWT 认证与预约接口的关键代码3.1 目录分层与统一响应体 Result后端分包按 controller / service / mapper / entity / common / config 拆开肉眼扫一遍就能看出哪段代码属于哪一层src/main/java/com/example/tutor/ ├── controller/ # 只做参数接收与校验不写业务逻辑 ├── service/ # 业务规则与状态流转 ├── mapper/ # MyBatis-Plus Mapper 接口 ├── entity/ # 数据库表实体 ├── common/ # Result、BizException、枚举 └── config/ # WebMvcConfigurer、拦截器注册所有接口返回值统一用 ResultT 包裹前端拿到固定结构不用猜Data public class ResultT { private int code; private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg success; r.data data; return r; } public static T ResultT error(String msg) { ResultT r new Result(); r.code 500; r.msg msg; return r; } }为什么要统一包裹一是前端 axios 响应拦截器只需要解析 {code, msg, data} 这一种结构二是业务异常可以由全局异常处理器统一转成 Result.error不会出现“有的接口直接抛字段错误、有的返回 null”这种不一致。Controller 里再配合 RestControllerAdvice 捕获 BizException把业务错误和系统错误区分开。3.2 登录认证JWT 工具类、拦截器与放行规则登录思路很常规用户名密码校验通过后签发 JWT前端存到 localStorage之后每个请求头带 Authorization: Bearer token。服务端用拦截器统一解析 token把 userId 和 role 放进 request 属性传给 Controller。这里用的是拦截器而不是过滤器因为拦截器属于 Spring MVC 容器能拿到 Handler 方法做更细粒度的权限控制过滤器是 Servlet 规范层面的东西。JwtUtil 负责生成和解析 tokenpublic class JwtUtil { private static final String SECRET tutor-secret-key; private static final long EXPIRE 7 * 24 * 3600_000L; public static String generateToken(Integer userId, String role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }SECRET 在实际项目里要放到 application.yml 配置里不要写死在类里。代码里先写常量是为了教学上能一眼看全流程答辩时补一句“生产环境会放到配置中心”就行。AuthInterceptor 拦截并解析请求头Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { return reject(response, 未登录); } try { Claims claims JwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { return reject(response, 登录已过期); } } private boolean reject(HttpServletResponse response, String msg) throws IOException { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\ msg \}); return false; } }拦截器注册和跨域配置放在同一个 WebConfig 里Configuration public class WebConfig implements WebMvcConfigurer { Resource private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE); } }三个要点addPathPatterns 配 /api/** 全拦再把登录注册排除不要反过来只拦截某个子路径否则新接口容易忘加拦截实现 WebMvcConfigurer 而不是继承 WebMvcConfigurationSupport后者会把 Spring Boot 的静态资源映射、消息转换器等自动配置覆盖掉这是面试高频追问点allowedOrigins 如果后续接带 cookie 的请求不能配成 *要显式写前端地址。3.3 预约状态流转的 Service 层先校验再落库状态机先定义清楚把合法流转用表格列出来当前状态可执行操作目标状态操作者待确认 PENDING确认 / 取消已确认 / 已取消教师 / 学生或教师已确认 CONFIRMED完成 / 取消已完成 / 已取消教师已取消 CANCELED无——已完成 COMPLETED无——核心的服务方法就这么写Service RequiredArgsConstructor public class BookingServiceImpl implements BookingService { private final BookingMapper bookingMapper; private final CourseMapper courseMapper; Override Transactional(rollbackFor Exception.class) public Booking createBooking(Integer studentId, Integer courseId, String bookDate) { Course course courseMapper.selectById(courseId); if (course null) { throw new BizException(课程不存在); } Long conflict bookingMapper.selectCount(new LambdaQueryWrapperBooking() .eq(Booking::getCourseId, courseId) .eq(Booking::getBookDate, bookDate) .in(Booking::getStatus, BookingStatus.PENDING.getCode(), BookingStatus.CONFIRMED.getCode())); if (conflict 0) { throw new BizException(该课程该时段已有预约); } Booking booking new Booking(); booking.setStudentId(studentId); booking.setCourseId(courseId); booking.setTeacherId(course.getTeacherId()); booking.setBookDate(bookDate); booking.setStatus(BookingStatus.PENDING.getCode()); bookingMapper.insert(booking); return booking; } Override Transactional(rollbackFor Exception.class) public void confirmBooking(Integer teacherId, Integer bookingId) { Booking booking bookingMapper.selectById(bookingId); if (booking null) { throw new BizException(预约不存在); } if (!booking.getTeacherId().equals(teacherId)) { throw new BizException(无权处理该预约); } if (booking.getStatus() ! BookingStatus.PENDING.getCode()) { throw new BizException(当前状态不可确认); } booking.setStatus(BookingStatus.CONFIRMED.getCode()); bookingMapper.updateById(booking); } }逻辑说明createBooking 里先查课程再查时间冲突最后才 insertTransactional 保证中途抛异常时事务回滚不会留下半截数据。时间冲突用 selectCount 判断这只够挡住并发不高的毕设场景真要防并发重单需要给预约表加唯一约束或改成 select for update。答辩被问“并发冲突怎么办”时先承认当前实现是查了再插再说 select for update 的改进方向不要硬吹。confirmBooking 里的三段校验顺序值得记一下先查存在性再查操作权限最后查状态是否允许。顺序反了会出现“A 教师操作 B 教师的预约时先报状态错误再报无权”的误导信息。取消操作的写法完全一致PENDING 和 CONFIRMED 才允许走取消分支。3.4 前后端联调最容易翻车的三个点跨域、时间格式、分页参数第一跨域。前端跑在 5173后端跑在 8080浏览器会拦截非同源请求。上面 WebConfig 里的 addCorsMappings 就是标准解法。如果用了 withCredentials 带凭证模式allowedOrigins 不能配 *。第二时间格式。前端日期组件拿到的是 2025-06-01T14:30 这类 ISO 字符串后端 LocalDateTime 不能直接反序列化。在 application.yml 里统一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8请求参数里对 LocalDateTime 字段再补一个 JsonFormat(pattern yyyy-MM-dd HH:mm:ss)。这样响应时间的格式也统一了前端表格不用自己拼字符串。第三分页。MyBatis-Plus 分页插件接收 current 和 size前端传 /api/booking/list?current1size10。手写 SQL 时则用SELECT id, student_id, course_id, status, book_date FROM booking WHERE student_id #{studentId} ORDER BY create_time DESC LIMIT #{offset}, #{size}offset 要由后端用 (current - 1) * size 算出来不能直接信任前端传的 offset。分页插件压掉了 SQL 写法差异但面试被问到底层原理时能说清 limit offset size 就够了。4. 前端实现Vue 路由守卫、axios 拦截器与预约处理页面4.1 Vue 项目结构与路由表划分前端用 Vue 3 Vite 起步目录按角色职责划分src/ ├── api/request.js # axios 实例与拦截器 ├── api/booking.js # 预约相关接口 ├── router/index.js # 路由与守卫 ├── layout/MainLayout.vue # 公共布局壳 └── views/ ├── student/ # 课程列表、我的预约 ├── teacher/ # 课程管理、预约处理 └── admin/ # 用户审核、教师管理路由表给每个页面配上 meta.roles让后面的导航守卫有据可依{ path: /teacher/bookings, component: TeacherBookings, meta: { roles: [TEACHER] } }页面级权限声明在路由配置里和业务代码解耦。后续加新页面时只需要改路由表一个地方。4.2 路由守卫做登录态控制meta.roles 限制可访问角色router.beforeEach 里做两件事没有 token 一律踢到登录页有 token 但角色不在 meta.roles 里跳到 403router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() return } if (!token) { next(/login) return } const role localStorage.getItem(role) const roles to.meta.roles || [] if (roles.length !roles.includes(role)) { next(/403) return } next() })这里能挡住用户界面的误操作但要说清楚前端守卫只是体验层面的限制真正安全边界是后端接口的权限校验。另一个细节是登录后回跳query 里带一个 redirect登录成功用 router.push(redirect) 回原页面。演示时从课程详情被踢到登录页登录后回到课程详情观感完全不一样。还要留意打包部署时的经典问题项目如果用 history 模式路由打包后部署到静态服务器刷新 /teacher/bookings 这类子路径会 404。常见做法是 nginx 配 try_files $uri $uri/ /index.html或者干脆用 hash 模式跳过这个坑。Vue 打包后布局异常的排查一般也从这里开始。4.3 axios 封装里注入 token响应层统一处理 401api/request.js 的写法集中处理三件事axios 实例带上 baseURL、请求拦截器加 Authorization 头、响应拦截器剥壳并处理 401import axios from axios const request axios.create({ baseURL: http://localhost:8080/api }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( res { const body res.data if (body.code 200) { return body.data } return Promise.reject(new Error(body.msg)) }, err { if (err.response err.response.status 401) { localStorage.clear() window.location.href /login } return Promise.reject(err) } )响应拦截器把 Result 的壳剥掉后页面组件拿到的直接就是 data不用每个页面重复写if (res.code 200)。401 统一跳登录页的逻辑也因为集中在这里而显得干净。baseURL 在开发环境写死生产环境则由 nginx 把 /api 转发给后端服务前端代码里不写绝对地址。4.4 教师端预约处理页面状态按钮由后端接口决定可点性教师端预约处理页核心是列表加操作列这里以 Element Plus 为例template el-table :databookingList el-table-column propcourseTitle label课程/el-table-column el-table-column propbookDate label预约时间/el-table-column el-table-column propstudentName label学生/el-table-column el-table-column label状态 template #default{ row } el-tag{{ statusText[row.status] }}/el-tag /template /el-table-column el-table-column label操作 template #default{ row } el-button typeprimary sizesmall :disabledrow.status ! 0 clickconfirmBooking(row) 确认/el-button /template /el-table-column /el-table /template按钮的 :disabled 判断只是辅助这个细节很重要后端 confirmBooking 里有 status 判断和 teacherId 校验所以哪怕有人绕过界面直接调接口也改不掉状态。前端管交互引导后端管业务规则两者各司其职。statusText 的映射直接对应后端 BookingStatus 枚举的 desc保持两边文案一致。5. 交稿前必做演示数据、冷启动脚本与答辩高频追问5.1 冷启动把后端和前端用命令跑起来别依赖 IDEA答辩机器上未必装了完整 IDE最稳的方式是一份冷启动步骤从解压到浏览器出页面三步完成。后端在项目根目录执行 mvn spring-boot:run前端在 frontend 目录执行 npm install 再 npm run dev。写成 start.sh 或直接在 README 里贴这几行# 终端 1启动后端默认 8080 mvn spring-boot:run # 终端 2启动前端默认 5173 cd frontend npm install npm run dev端口被占用时Windows 用 netstat -ano | findstr 8080 查macOS / Linux 用 lsof -i:8080。启动前端前先 node -v 确认 Node 版本Vite 5 对 Node 版本有要求版本太低会直接报错。5.2 初始化数据固定演示账号与演示数据空数据库演示非常劝退第一次进系统必须能看到课程和教师。在 sql/init.sql 里放初始化数据账号固定写死并在 README 里列出INSERT INTO user (id, username, password, role) VALUES (1, admin, MD5(123456), ADMIN), (2, teacher01, MD5(123456), TEACHER), (3, student01, MD5(123456), STUDENT);密码用 MD5 是毕设常规做法但要在文档里注明生产环境要换 BCrypt。如果担心演示中途数据结构被改乱可以在 Spring Boot 里配置 spring.sql.init.modealways 配合 data.sql 每次启动重置代价是手工造的数据也会丢二选一要提前决定。5.3 答辩追问拦截器、状态设计、权限边界“JWT 解析出来的 userId 是怎么传到 Controller 的”是高频问题。回答思路登录接口生成 token 返回前端后续请求带 Authorization 头拦截器 preHandle 解析 token把 userId 放进 request attributeController 里 request.getAttribute 取回。顺便讲清拦截器和过滤器的区别再说一句“真正权限判断还是后端接口前端只是引导”闭环完整。“状态为什么用数字存”直接搬 2.2 的结论节省空间、索引用着快、枚举在代码层约束合法值。“为什么用 MyBatis-Plus 而不是原生 MyBatis”答单表 CRUD 不用写 XML、LambdaQueryWrapper 写条件查询方便。这三个问题本质上是 Java 后端面试八股的高频原题答辩前按这个思路顺一遍比背接口清单有用得多。5.4 加分项用 Knife4j 现场展示接口文档答辩展示时从登录页点进系统找功能很费时更快的方式是打开接口文档页直接演示各接口。Knife4j 在 Spring Boot 3 项目里加这个 starterdependency groupIdcom.github.xiaoymin/groupId artifactIdknife4j-openapi3-jakarta-spring-boot-starter/artifactId /dependency启动后访问 /doc.html无需登录就能看到全部接口、参数示例和返回值结构。演示时顺着“先调登录拿 token → 调课程列表 → 提交预约 → 教师端确认”走一遍状态流转过程直接展现在评委面前。加依赖时确认当前 starter 与 Spring Boot 版本匹配Boot 3 必须用带 jakarta 的构件名版本号在 README 里写清楚即可。本文还有配套的精品资源点击获取
返回列表