家教平台这类网站的代码量其实不大但业务状态多、角色权限乱真正动手做起来才发现坑全在细节里。我最近用Spring Boot Vue 完整做了一版“springboot家教平台网站vue”全栈项目把家长、教员、管理员三端业务跑通了前后端加起来差不多一万行代码从数据库设计到部署上线走了不少弯路这篇就把我的完整思路、核心代码片段、踩坑记录一次性整理出来给正在做类似毕设或接私活项目的同学做个参考。1. 项目整体定位与技术选型思路1.1 家教平台到底解决什么问题家教平台本质上是一个双边交易撮合系统一头是家长一头是教员大学生兼职或专职老师平台负责信息匹配、信任背书、订单管理和资金担保。这和普通电商网站有本质区别电商卖的是标准品家教卖的是非标服务所以系统在设计时要重点解决三个问题——信息真实性教员资质审核、预约匹配效率科目、区域、时间筛选、服务闭环订单状态流转评价体系。我接手这个项目的时候第一反应是先用现成的后台管理模板套一套但后来发现家教平台的用户端交互比管理端复杂得多家长要能按科目和区域筛选教员、查看教员详情页的资历和评价、发起预约教员要有可编辑的个人主页、可管理的可授课程表、可接单的预约列表平台管理员则要处理教员认证审核、举报处理、订单纠纷。两套界面逻辑完全不同直接套模板反而要改一大堆东西不如从头搭轻量框架。1.2 为什么是Spring Boot Vue这套组合技术选型上我几乎没有犹豫就定了 Spring Boot Vue原因很直接这个组合是当前Java全栈开发最主流、资料最多、招人最认的一套遇到问题随便搜就有现成方案。后端用 Spring Boot 2.7 搭配 MyBatis-Plus 操作 MySQL安全框架选 Spring Security JWT不需要引入复杂的微服务组件单体应用足够扛住初版业务。选 MyBatis-Plus 而不是 JPA 是因为家教平台的查询条件多且组合灵活——按区域筛选二环内、按授课科目筛选高中数学、按价格排序这类动态 SQL 用 MyBatis-Plus 的 LambdaQueryWrapper 写起来非常顺手而 JPA 在这种自定义查询上反而绕来绕去。前端用 Vue 3 Vite Element Plus Pinia。Vue 3 的组合式 API 写业务逻辑比 Vue 2 的 options API 清晰Vite 冷启动速度快开发体验好。Pinia 管理全局登录态和用户信息比 Vuex 轻量且类型友好。Element Plus 提供现成的表单、表格、弹窗组件做管理后台效率极高。提示如果你的目标是快速毕业或交付不建议一上来就搞微服务、Redis 集群、RabbitMQ 这套单体能跑通业务闭环就已经完成了 80% 的工作量。技术栈越重部署环境越复杂出问题排查越困难。1.3 功能模块拆分我把整个系统拆成了三个端共十个模块画一张脑图在脑子里立好框架再动手家长端注册登录、浏览教员列表、按条件搜索、查看教员详情、发起预约下单、在线支付、评价教员、个人中心。教员端注册申请、资质认证上传学生证/教师资格证、维护个人主页、管理可授课时间、处理预约订单、查看收入明细。管理端登录、教员认证审核、用户管理、科目分类管理、订单管理、评价管理与举报处理、数据看板。这个拆分决定了后端的 Controller 层天然分成三个包parent、teacher、admin每个包下面再按业务模块建 Service避免所有接口堆在一个 Controller 里变成面条代码。前端的 Vue 页面也对应分成views/parent、views/teacher、views/admin三个目录路由层面做权限隔离。2. 数据库设计与核心表结构2.1 用户体系家长、教员、管理员怎么建模家教平台的用户模型很有代表性它不是单一用户表加一个角色字段那么简单。家长和教员虽然都是平台的注册用户但教员拥有大量个人资料字段擅长科目、教学年限、资质证书、个人介绍、授课区域、收费标准、可授课时间段。这些字段如果全部塞进用户表表结构会非常臃肿而且和家长的字段差异巨大。我的设计是拆成三张表user基础账号表、user_role用户角色关联表、teacher_profile教员详情扩展表。user表存的是登录凭证字段为id、username、password、phone、email、avatar、status启用/禁用/待审核、create_time。密码使用 BCrypt 加密存储绝对不存明文。user_role表不采用常见的一种用户一条记录的存储而是保留多对多结构为将来一个用户同时是家长和教员的双重身份留余地。实际上家教平台很多用户确实是这样先注册家长找家教后来又申请成为教员两张表关联是必要的。teacher_profile表是教员专属信息表字段包括user_id、subject_ids擅长科目ID用逗号分隔、intro个人介绍、teaching_years教龄、cert_photos资质证书照片JSON数组、service_regions授课区域、hourly_rate每小时收费、verify_status0待审核、1通过、2驳回。注意subject_ids用逗号分隔字符串存储是一个刻意取舍。如果严格规范化应该建一张teacher_subject中间表但实际项目中科目数量很少变更且查询时基本是“某个教员擅长哪些科目”的展示场景逗号分隔反而少两次联表查询前端拿到后 split 一下就能渲染。这不是最佳实践但是业务实践下性价比最高的方案。2.2 家教业务核心科目、订单、评价科目分类是家教平台的基础数据数据量不超过50条我建了subject表字段极其简单id、parent_id、name、sort。parent_id用于支持两级分类比如“数学”下挂“小学数学”“初中数学”“高中数学”。家长按科目找家教时这种两级结构筛选效率最高。订单表orders是整个系统最核心的表字段包括id、order_no订单号、parent_id下单家长用户ID、teacher_id接单教员用户ID、subject_id授课科目、lesson_count课次默认1、total_amount总金额单位分、status订单状态、appointment_time预约时间、address授课地址、create_time、pay_time。金额字段存分而不是元这是支付系统的基本常识避免浮点数精度问题。status字段的取值就是我们订单状态机的具体体现我在代码里定义为常量0待支付、1已支付待上课、2已完成、3已取消、4退款中、5退款完成。评价表evaluation字段id、order_id关联订单、parent_id、teacher_id、rating1-5星、content评价内容、reply教员回复、create_time。一个订单只能评价一次所以order_id加了唯一索引防止重复评价刷评分。另外还建了favorite家长收藏教员、message站内消息通知、certification教员认证申请记录三张辅助表后面业务实现会用到。2.3 我踩过的设计坑JSON循环引用、软删除和金额单位数据库设计阶段有几次返工最典型的三个坑值得单独拿出来说。第一个坑是 JSON 序列化循环引用。最开始我在orders表里直接写了parent_user和teacher_user两个 MyBatis-Plus 关联映射结果查询订单时orders引用了useruser又通过 mybatis 的关联查询引回orders前端接口返回 500后台报StackOverflowError。解决办法是在字段上用JsonIgnoreProperties注解忽略反向引用或者干脆在 VO 层手动拼字段不把完整实体直接返回给前端。我最终选了后者——写一个OrderVO只包含前端需要的字段既解决循环引用问题又避免返回多余敏感信息比如密码哈希。第二个坑是软删除。家教平台的订单属于重要业务数据千万不能物理删除。但 MyBatis-Plus 默认的逻辑删除是全局生效的如果在application.yml里配置了logic-delete-field: deleted那所有表的查询都会自动带上where deleted 0。而有些表比如evaluation需要保留全部历史数据来统计平均分你不想让它删有些表比如user管理员封号只是把status改成禁用而不是删记录。一个全局配置管所有表反而容易误伤我的做法是不用全局逻辑删除在需要软删除的个别表上手动加deleted字段、手动控制 SQL。第三个坑是金额单位我一开始没绷住。开发环境中出现过一次订单金额 19.9 元显示成 19.9 分的情况查了半天发现是前端把后端返回的1990分直接当成元渲染了。解决方案很统一后端接口统一返回分前端在展示层定义一个formatMoney过滤器负责把分转成元的字符串显示。这个约定要写进接口文档里前后端对齐口径否则必然出错。3. 权限体系与登录态设计3.1 Spring Security JWT为什么够用家教平台并不需要特别复杂的权限模型三种角色、若干接口、按角色判断是否允许访问即可。我选用 Spring Security JWT 而不是传统的 Session 方案核心原因是前后端分离架构下 JWT 天然适配前端 Vue 项目运行在另一个端口开发环境5173生产环境80后端运行在 8080跨域请求如果依赖 Session 还需要处理 Cookie 跨域携带问题而 JWT 把登录态放在每次请求的 Authorization 头里完全没有跨域烦恼。JWT 在代码里的落地分三层登录接口签发 token、拦截器解析 token、Security 配置放行白名单。签发 token 用的是jjwt库token 里只放用户 ID 和角色编码不塞任何其他用户信息避免 token 过期后用户改了头像昵称前端显示的还是旧数据。// JwtUtil.java 核心方法 public String generateToken(Long userId, String roleCode) { Date now new Date(); Date expiryDate new Date(now.getTime() 7 * 24 * 60 * 60 * 1000); // 7天过期 return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, roleCode) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); }3.2 三种角色的权限控制实践Spring Security 的配置核心是拦截哪些接口、放行哪些接口。我把接口分成三类公开接口、需登录接口、需特定角色接口。公开接口包括注册登录接口、获取教员列表、查看教员详情、获取科目分类。为什么教员详情也公开因为家长在注册前也可以浏览平台上的教员信息来评估平台质量这不算敏感数据放行前端页面体验更好。需登录接口下单、支付、收藏、评价、查看个人订单列表。角色限定接口管理员的所有接口/api/admin/**教员认证接口/api/teacher/certification这类接口用 Spring Security 的hasRole()控制。Security 配置代码里最值得注意的是自定义JwtAuthenticationFilter这个过滤器要在 Security 责任链中优先执行把解析出来的用户 ID 和角色塞进 SecurityContext让后面的 Controller 可以直接通过AuthenticationPrincipal获取当前登录用户。// SecurityConfig.java 关键片段 http.csrf().disable() .cors().and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/**, /api/subject/**, /api/teacher/list, /api/teacher/detail/**).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .antMatchers(/api/teacher/**).hasAnyRole(TEACHER, ADMIN) .anyRequest().authenticated(); http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);3.3 前端路由守卫与登录态持久化后端做了权限控制前端还需要做一层路由拦截来保证用户体验。否则用户直接输地址访问一个需要登录的页面后端返回 401前端却白屏交互很糟糕。Vue 3 里我用的 Vue Router 4在beforeEach全局前置守卫中做登录校验// router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token) const userInfo useUserStore() if (to.meta.requiresAuth !token) { next(/login?redirect to.fullPath) return } if (to.meta.role token userInfo.role ! to.meta.role) { next(/403) return } next() })登录态持久化选择 localStorage 存 tokenPinia 存用户信息。刷新页面后 Pinia 数据会丢失所以在应用启动时从 localStorage 里读取 token 并调用getUserInfo接口拉取最新用户信息。这个流程要在main.js里做成async初始化否则会出现刷新后短暂的登录态闪烁。这里有个小细节token 存 localStorage 有 XSS 风险存 Cookie 又需要额外处理 CSRF 问题。对于这个量级的项目localStorage 严格前端 XSS 防护不渲染用户原始输入、使用 Vue 默认转义已经足够。如果后续做支付类业务接入建议研究一下 refresh token 双令牌机制上线阶段再升级不迟。4. 核心业务场景实战拆解4.1 教员注册与资质审核流程这是家教平台区别于普通电商系统的最大特色。家长付费前需要确信教员身份真实所以流程必须做到提交资料 → 人工审核 → 审核通过后教员才能接收预约。前端资质认证页面让用户上传三种材料1、学生证或教师资格证照片二选一2、身份证明按平台要求对部分信息打码3、个人形象照。上传组件用 Element Plus 的el-upload拿到上传后由后端服务将文件重命名并存储。文件存储我没有引入 OSS而是本地磁盘存储。在application.yml中配置一个上传目录比如/home/app/uploads/后端把文件写到这个目录同时把文件的相对访问路径存到certification表// FileUploadService.java public String uploadFile(MultipartFile file) { // 校验文件类型和大小 String originalFilename file.getOriginalFilename(); String extName originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString().replace(-, ) extName; // 按日期分目录存储 String datePath new SimpleDateFormat(yyyyMMdd).format(new Date()); File targetDir new File(uploadPath datePath); if (!targetDir.exists()) { targetDir.mkdirs(); } File targetFile new File(targetDir, newFileName); file.transferTo(targetFile); return /api/file/ datePath / newFileName; }这里的/api/file/**是一个静态资源映射接口把磁盘路径映射成可访问的 URL。如果以后部署到云服务器可以把这个目录挂载到对象存储接口返回值不用改只改存储实现即可设计一个StorageService接口就是为这个扩展预留余地。审核流程在管理端实现非常简单管理员查看待审核列表 → 点击审核 → 通过或驳回。但要注意一个业务细节驳回时要填写驳回原因如果只驳回不给原因用户不知道改什么二次申请依然提交错误材料白白浪费运营人力。这个需求我在certification表中额外加了reject_reason字段前端审核弹窗强制要求填写。4.2 找家教与预约下单的状态机设计家教平台的订单状态比普通电商多因为涉及线下的时间预约。我的状态机设计是0待支付→1已支付待上课→2已完成下单流程很清晰家长在教员详情页点击“预约试听”→ 选择科目、预约时间、填写地址 → 提交订单 → 跳转支付 → 支付成功后订单状态从 0 变为 1 → 通知教员有新的预约单 → 教员确认接单 → 按约定时间去上课 → 状态变为 2。这里的关键是防止重复下单。家长手抖点了两次提交按钮如果没有防重逻辑就会生成两笔一模一样的订单。我的做法是后端在下单接口里做幂等校验查询当前用户是否已有状态为待支付或已支付的订单预约时间有重叠则直接返回“您已有相同时间的待支付/进行中预约”。// OrderService.java 防止重复预约 private void validateNoDuplicateOrder(Long parentId, Long teacherId, LocalDateTime appointmentTime) { LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getParentId, parentId) .eq(Order::getTeacherId, teacherId) .eq(Order::getAppointmentTime, appointmentTime) .in(Order::getStatus, Arrays.asList(0, 1)); // 待支付、已支付状态 Long count orderMapper.selectCount(wrapper); if (count 0) { throw new BusinessException(您已在该时间段预约过该教员请勿重复提交); } }支付环节我接了模拟支付接口——自己写的一个假支付接口前端点击支付后调用后端/api/order/pay后端直接把它标记为已支付同时回调更新订单状态。真实项目接入支付宝或微信支付时后端只需要替换支付实现这一个类回调通知的 Controller 增加一个验签逻辑即可状态机不用改。状态变更必须返回前端一个明确结果我封装了统一响应结构ApiResponse状态码约定200 成功、400 参数错误、401 未登录、403 无权限、500 服务器异常。前端 axios 拦截器里对 401 做自动跳转登录页处理对 400 弹出后端返回的错误消息开发者只需要在页面里写业务代码不用每个请求都写一遍异常处理。4.3 支付确认、评价体系与数据动态更新这个场景是一个关键业务闭环订单完成之后家长评价评价影响平台对教员的展示排序。因为评价会直接影响其他家长的选择防止刷分尤其重要。我的做法很朴素只有购买过该教员课程且订单状态为已完成的家长才能评价evaluation表对order_id建唯一索引从数据库层面锁死一单一评。评价后教员平均分怎么算最开始我是这样做的每次新评价进来实时更新teacher_profile表里的avg_rating字段。这样教员列表页展示评分时直接读这个冗余字段不需要实时聚合子查询列表加载性能极好。但是要特别说明更新评分要加一个锁不然并发评价会出问题。比如两个订单同时完成两个评价同时提交如果两个 SQL 同时读取旧平均分然后写入新平均分最后结果就是丢了一条数据的影响。我的解决方案是加一个Transactional事务配合SELECT ... FOR UPDATE锁定这一行再计算平均分写入避免并发覆盖。等订单量和评价量真正大起来之后可以引入 Redis 做热点数据的异步聚合刷新初版业务完全用不上。评价列表页还要支持教员回复功能。家长评价后教员可以在个人中心的“评价管理”里回复这个回复和评价展示在一起形成完整互动。回复逻辑就简单多了直接在evaluation表的reply字段更新不需要单独建回复表因为没有多轮对话需求。4.4 消息通知与站内信家教平台有多个业务节点需要通知用户订单支付成功通知教员、家长取消订单通知教员、管理员审核结果通知申请者、家长新预约通知教员抢单。如果一开始就接入短信、邮件成本高且调试复杂我选择先做站内信通知。站内信表message字段id、receiver_id接收者用户ID、type1系统通知、2预约通知、3订单通知、4审核通知、title、content、is_read0未读、1已读、create_time。后端业务代码里在关键位置调用messageService.send()// OrderServiceImpl.java 支付成功后 messageService.send(order.getTeacherId(), 3, 新预约通知, 您有新的预约订单学员已支付成功请登录平台确认接单。);前端的消息角标是高频需求。底部导航栏或个人中心里放一个未读消息数需要能在不刷新页面的情况下知道有新消息。考虑到小程序和移动端都有类似需求后端提供了GET /api/message/unread-count接口前端每 30 秒轮询一次。这个方案简单但不优雅——更好的是 WebSocket 主动推送但初版项目轮询 30 秒一次一个用户一天也就轮询几千次服务器扛得住功能也完整。等用户量上来了再换 WebSocket 顺手优化。站内信每条消息的 “已读/未读” 状态用一个小红点展示在消息列表里用户点开某条消息后调用updateReadStatus接口把is_read改为 1这个交互做完之后整个消息模块就算跑通了。5. 部署上线与常见问题排查5.1 前后端分离部署Maven打包 Nginx托管本地开发完不等于项目完成部署上线才见真章。我的部署方案是典型的单机前后端分离部署适合小项目起步阶段后端mvn clean package -DskipTests打成 jar 包放到服务器/home/app/目录通过nohup java -jar home-tutor.jar --server.port8080 启动。监听 8080 端口。前端npm run build生成dist目录然后把 dist 里的静态文件放到 Nginx 的/usr/share/nginx/html/下。Nginx 监听 80 端口负责托管静态页面。关键配置是 Nginx 的反向代理把/api开头的请求转发到 8080 端口的后端服务server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # Vue Router history模式刷新404问题 } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这一行绝对不要漏。Vue Router 用 history 模式时用户手动刷新/teacher/123或直接输入二级路径地址Nginx 会尝试找这个文件找不到就返回 404加了 try_files 回退规则后所有路径都回到 index.html由前端路由接管。5.2 跨域与携带 Token 的常见配置前后端分离最常出的问题就是跨域。开发环境前端在http://localhost:5173后端是http://localhost:8080端口不同天然跨域每次请求浏览器都会先发一次 OPTIONS 预检请求如果后端没配置 CORS接口全部失败。后端 Spring Boot 的 CORS 配置比较方便// CorsConfig.java Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOrigins和allowCredentials(true)必须配合使用allowCredentials(true)表示允许浏览器携带 Cookie 或 Authorization 头但此时allowedOrigins不能写成*必须指定具体域名否则浏览器会拒绝响应。上线后要把这个配置改成正式域名生产环境的跨域就不用操心了因为 Nginx 已经把前后端收敛到同一个域名下了跨域问题当场消失。前端 axios 拦截器统一配置请求头// request.js service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }, error Promise.reject(error) )5.3 资质审核文件上传的两个隐藏坑文件上传这个功能看似简单实际踩了不少坑这里挑两个最典型的分享出来。第一个是文件大小限制。Spring Boot 默认最大上传文件是 1MB手机拍的照片动辄 5-10MB家长/教员的资质照片传不上去前端报 500。需要在application.yml里显式调大spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB第二个是静态资源映射配置。文件传到服务器磁盘上了但浏览器通过 URL 访问不到因为 Spring Boot 默认只处理classpath:/static/下的文件磁盘上的文件要手动配置资源映射// WebMvcConfig.java 添加资源映射 registry.addResourceHandler(/api/file/**) .addResourceLocations(file: uploadPath);这样访问/api/file/20240615/xxx.jpg时后端会去指定磁盘目录找文件。注意addResourceLocations的file:前缀不能少少了就变成 classpath 查找了。5.4 常见问题排查速查表我把这段时间遇到的问题整理成一个速查表开发中和部署后基本都能靠这张表解决 90% 的常见故障症状可能原因解决方案前端请求接口报 401token 丢失、过期、后端未放行该白名单接口检查 localStorage 是否有 token检查 SecurityConfig 白名单检查 token 过期时间跨域请求失败控制台报 CORS后端未配置 CORS 或 allowedOrigins 写了*且 allowCredentials 为 true按上述 CorsConfig 修改确保域名精确匹配刷新页面 404Nginx 未配置 try_files在 location / 中加try_files $uri $uri/ /index.html;图片上传后访问 404文件未落盘或静态资源映射未配置检查 uploadPath 目录是否存在检查 addResourceHandler 路径前端列表页超时后端 N1 查询导致 SQL 过多使用 MyBatis-Plus 的嵌套查询或 SQL 联表减少循环查库订单状态显示不对前端状态枚举和后端不一致前后端统一状态常量建议后端返回时附带状态说明文字接口返回 JSON 序列化超时实体类有循环引用用 VO 类代替实体直接返回或在字段上加忽略注解登录后马上掉线token 存储在内存中未持久化统一存 localStorage刷新前重新获取用户信息排障的经验归根到底一句话先看后端日志再看网络请求不要凭感觉猜。Spring Boot 的console日志对每个请求都有访问记录每次异常都有堆栈把堆栈贴到编译器的搜索结果里基本能直接定位。6. 一些心得与后续打算整套项目做下来我最深的体会是家教平台重点不在技术难度而在业务流程的完整性。技术选型只要不是反人类的方向都能把功能做出来真正比拼的是对业务的理解——审核流程怎么设计才能既严谨又不繁琐、订单状态怎么流转才能覆盖所有真实场景、评价权怎么控制才能避免刷分。这些设计思考远比写几行 CRUD 代码更有价值。在实操中我觉得有几个优化方向很值得继续做下去把文件上传从本地磁盘迁移到云端存储降低单机磁盘压力引入 Redis 缓存教员列表页的频繁查询把热数据响应时间从 200ms 降到 10ms 以内消息推送从轮询升级为 WebSocket 订阅推送提升用户体验。不过这些优化都是等用户量起来之后再做的事当下的系统已经能稳定支撑初期的业务运行。最后分享一个小技巧前端所有金额显示统一封装成一个过滤器组件后端所有返回的时间统一用LocalDateTime加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)格式化避免前端拿到一串数字还要自己处理这种前后端约定的统一口径越早定越好能省很多后期联调的麻烦。