简介:这是一套基于SpringBoot和Vue.js开发的线上教育培训办公系统完整源码包,面向需构建在线教育平台的开发者、高校学生以及毕业设计人员。系统覆盖课程发布、学习资源管理、教育直播、课程报名、作业提交、成绩查询、用户管理、后台管理、在线学习和远程教育等完整业务链路;教师可维护课程并在线授课与批改作业,学生可报名、下载资料、直播学习、提交作业与查看成绩,管理员能统一管理用户和系统配置,适合作为全栈项目实战、毕业设计参考或二次开发底座。压缩包共包含871个文件,以Java后端源码、Vue前端组件、JavaScript逻辑、CSS样式和HTML页面为主,同时配有大量SVG图标、GIF演示图、PNG/JPG图片、XML/JSON配置以及启动批处理脚本,整体大小约29.91MB,目录结构清晰便于按模块检索。资源内附带说明文档和教程辅助资料,能帮助使用者快速完成环境搭建、模块部署与功能拓展,理解前后端交互方式和核心业务实现。目前已有50人学习下载,对于希望掌握在线教育系统开发流程的读者具有直接的参考价值。
1. 线上教育培训办公系统:SpringBoot加Vuejs怎么把课程、直播、作业、成绩串成一条线
如果你接到的需求是把线下培训机构的教务流程搬到线上,需要面对的就是一个类似标题里这种“线上教育培训办公系统”:老师要发课程、开直播、布置作业、批改打分,学生要报名、看视频、交作业、查成绩,管理员还要盯着后台。整套东西从功能清单看很乱,但核心其实就是两条线——用户权限线(谁能干什么)和教学数据线(课程从发布到结课的状态流转)。用SpringBoot和Vuejs做这套系统,SpringBoot负责把课程、报名、作业、成绩这些数据变成稳定的API,Vue负责把直播页、作业提交页、成绩排行榜这些前端交互做成像在线教育产品的样子。
我在做过几个类似项目之后最大的感受是:这个项目的难点不在任何一个单独功能,而在把“课程发布—报名—学习—作业—成绩”这条主链串起来的时候,数据状态经常对不上。比如直播还没结束就允许交作业、成绩出来又收不到通知、老师改完分数前端缓存还显示旧值。这些坑会在后面单独说。这套方案最适合中等规模的教育培训机构或者做毕业设计、课程设计的开发者,技术栈不新但非常稳,照着做一两个月能上线。
2. 数据模型与RBAC设计:把20个页面的业务拆成9张表
2.1 先按角色画权限矩阵,再建表
这个系统我第一次做的时候直接上手建表,结果做到后台管理时发现“老师能看到学生成绩,但学生不能看到班级均分”这种规则散落在各个接口里,后来被迫返工。正确的做法是先画出角色权限矩阵,再定表结构。标题里的用户管理、后台管理、在线学习、远程教育这些词,落在系统里就是三类角色:学生(报名、学习、交作业、查成绩)、教师(发课、直播、批作业、录成绩)、管理员(审核课程、管理用户、看统计)。
角色的权限矩阵决定了我们怎么设计表。用户表和角色表之间需要多对多关系,因为一个老师可能同时是某个课程的助教,一个管理员也可能作为学生去报名学习。下面这张矩阵是我们最终采用的方案:
| 功能模块 | 学生 | 教师 | 管理员 |
|---|---|---|---|
| 课程浏览/报名 | 是 | 是 | 是 |
| 课程发布/编辑 | 否 | 是 | 否(管理员仅审核) |
| 教育直播开播/结束 | 否 | 是 | 否 |
| 作业提交 | 是(本人) | 否 | 否 |
| 作业批改 | 否 | 是(自己课程) | 否 |
| 成绩录入/修改 | 否 | 是(自己课程) | 是(仅修改异常) |
| 用户管理 | 否 | 否 | 是 |
| 后台数据统计 | 否 | 否 | 是 |
2.2 核心表SQL:用户、课程、报名、作业、成绩
表数量要看是否把直播单独建表。我建议把直播记录拆成独立表,因为一个课程可以开多场直播,每次直播的时长、回放地址、参与人数都不一样。总计下来,用户表、角色表、用户角色关联表、课程分类表、课程表、报名表、直播记录表、作业表、成绩表,这9张表足够覆盖标题里所有功能。下面是我常用的建表SQL片段,省略了部分外键和索引:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt密文', `real_name` VARCHAR(30) COMMENT '真实姓名', `phone` VARCHAR(20), `avatar_url` VARCHAR(255), `status` TINYINT DEFAULT 1 COMMENT '1正常 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE `course` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `teacher_id` BIGINT NOT NULL COMMENT '授课教师ID', `category_id` BIGINT COMMENT '课程分类ID', `title` VARCHAR(100) NOT NULL, `cover_url` VARCHAR(255) COMMENT '封面图OSS地址', `description` TEXT COMMENT '富文本内容', `status` TINYINT DEFAULT 0 COMMENT '0待审核 1已发布 2已下线', `publish_time` DATETIME COMMENT '实际上架时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_publish` (`status`, `publish_time`) ) ENGINE=InnoDB COMMENT='课程表'; CREATE TABLE `course_signup` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `course_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `signup_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_course_student` (`course_id`, `student_id`) ) ENGINE=InnoDB COMMENT='课程报名表'; CREATE TABLE `homework` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `course_id` BIGINT NOT NULL, `teacher_id` BIGINT NOT NULL, `title` VARCHAR(100) NOT NULL, `content` TEXT COMMENT '作业要求', `deadline` DATETIME NOT NULL COMMENT '截止时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB COMMENT='作业表'; CREATE TABLE `score` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `course_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `homework_id` BIGINT COMMENT '关联作业,可不填', `score` DECIMAL(5,2) COMMENT '百分制成绩', `comment` VARCHAR(255) COMMENT '教师评语', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_course_student` (`course_id`, `student_id`, `homework_id`) ) ENGINE=InnoDB COMMENT='成绩表';这段SQL的关键在于唯一索引。course_signup表的uk_course_student保证一个学生不能重复报名同一门课,这是线上课程报名最容易出现的并发重复问题,靠INSERT IGNORE或者ON DUPLICATE KEY UPDATE才能兜住。score表的唯一索引则是防止老师重复录入成绩,批改作业时经常出现老师点了两次保存,没有这个索引成绩表里就有两条记录。
字段类型上,score用DECIMAL(5,2)而不是INT,因为成绩可能有小数点或者平时分加卷面分。content字段用TEXT,课程描述富文本内容可能超过64KB,VARCHAR(255)远远不够。状态字段统一用TINYINT加注释,避免用字符串,检索和统计性能差很多。
2.3 字段类型与索引的取舍
这个系统的数据量级不会很大,单表百万级封顶,索引不需要过度设计。但有两个索引我建议必需:一是course表的(status, publish_time)联合索引,因为首页和课程列表页最常按“已发布状态 + 发布时间倒序”查询,这个索引能让列表页的排序稳定在毫秒级;二是homework表的course_id索引,作业列表和教师批改列表按课程查都是高频操作。
单据状态字段的设计也有个常见错误:直接用Boolean或者只有0/1两个取值。课程状态至少要有待审核、已发布、已下架、已结束四个状态,直播状态则要有未开始、直播中、已结束、回放可用,用TINYINT枚举比Boolean灵活得多。另外在SpringBoot项目里,MyBatis Plus的自动填充功能可以省去大量重复的create_time赋值,我习惯在实体类字段上配置@TableField(fill = FieldFill.INSERT),框架会在插入时自动写入当前时间。
3. 课程发布与教育直播:后端接口、文件上传与签名串
3.1 课程发布接口:富文本、封面图与分类级联
课程发布是这个系统的第一个核心事务,它要同时处理封面图上传、富文本描述、分类级联选择。我在Controller层单独暴露一个/api/course/upload接口用于上传图片,前端用Element Plus的Upload组件先把图片传上去拿到URL,再连同课程表单一起提交/api/course/create。这样切分的好处是发课失败不会产生孤儿图片,上传成功但表单没提交的场景可以由定时任务清理临时目录。
@RestController @RequestMapping("/api/course") public class CourseController { private final CourseService courseService; public CourseController(CourseService courseService) { this.courseService = courseService; } @PostMapping("/create") public ApiResult<Long> create(@RequestBody @Validated CourseCreateDto dto, @RequestAttribute("userId") Long teacherId) { // @RequestAttribute 由 JWT 过滤器解析后写入,避免从参数里传 userId Long courseId = courseService.createCourse(dto.toEntity(teacherId)); return ApiResult.success(courseId); } }这里@RequestAttribute("userId")取值,而不是前端传teacherId,就是为了防止学生手动构造请求冒充教师发布课程。服务层的事务边界要放在createCourse内部:插入课程表、生成默认的直播记录、给教师添加管理关联,这三步要么全成功要么全失败。参数说明上,CourseCreateDto里title用@NotBlank、categoryId用@NotNull,description允许为空字符串但要做XSS过滤,我用的方案是入库前把富文本里的<script>和onerror属性全部剥离,纯前端过滤不可靠。
分类级联的实现不想多建表的话,可以在course_category表里加一个parent_id字段,浅层级联只需要两级。后台管理页面加载时一次性把全部分类拉下来构建树形结构,缓存在Redis里,分类变更时删除缓存键,下次自动刷新。这样的好处是SpringBoot的启动配置里不需要额外的初始化逻辑,接口速度和实现复杂度都能兼顾。
3.2 教育直播签名与推拉流地址生成
标题里的“教育直播”是整个系统里最容易让新手翻车的模块,原因在于直播功能自带的技术栈和业务系统没什么关系,又要人在SpringBoot里融合。常见的做法是借用第三方的云直播能力,比如推流端用OBS,播放端用支持HLS或HTTP-FLV的播放器,业务系统只需要做一件事:生成有时效性的推流地址和播放地址。
public class LiveUrlUtil { private static final String APP_ID = "your_app_id"; private static final String APP_SECRET = "your_app_secret"; private static final long VALID_SECONDS = 2 * 3600L; public static String buildPushUrl(String streamKey) { long expire = System.currentTimeMillis() / 1000 + VALID_SECONDS; String txTime = Long.toHexString(expire).toUpperCase(); String sign = md5(APP_SECRET + streamKey + txTime); return String.format("rtmp://push.example.com/live/%s?sign=%s&txTime=%s", streamKey, sign, txTime); } public static String buildPlayUrl(String streamKey) { long expire = System.currentTimeMillis() / 1000 + VALID_SECONDS; String txTime = Long.toHexString(expire).toUpperCase(); String sign = md5(APP_SECRET + streamKey + txTime); return String.format("http://play.example.com/live/%s.m3u8?sign=%s&txTime=%s", streamKey, sign, txTime); } private static String md5(String input) { // 标准MD5,省略实现 } }这套签名串的逻辑在主流云直播平台上大同小异:把streamKey、密钥、过期时间拼起来做MD5,签名过期后推拉流全部失败。参数上要注意两点,一是VALID_SECONDS不能设太久,直播地址泄露出去被别人盗播,两小时是比较合理的默认值;二是如果同一课程有多场直播,streamKey不能复用,我通常用courseId + "_" + lessonId作为流名,这样回放管理和断流续播都清晰。
播放端我建议优先选择HLS地址而非RTMP或HTTP-FLV,因为在线教育的场景里学生经常用手机4G网络,HLS基于HTTP协议,对Nginx和CDN的缓存都友好。唯一的问题是HLS延迟在3到10秒,互动性强的直播课会难受,这时可以给直播中状态的同学用HTTP-FLV,回放统一走HLS,前端判断直播状态切换地址即可。
3.3 课程状态机与定时任务
课程状态不能只靠教师手动切换,因为直播结束、课时完成这些事件驱动需要自动流转。我在course表里加了status字段后,还会在内存里维护一个简单的状态机:待审核只能由管理员审核变为已发布,已发布到了直播开始时间自动变为直播中,直播结束变为已结束。实现上不用引入复杂的规则引擎,SpringBoot自带的@Scheduled定时任务就够了。
@Component public class CourseStatusJob { @Scheduled(cron = "0 */5 * * * ?") public void refreshCourseStatus() { // 每5分钟执行一次,把开始时间小于当前时间且状态为1的课程置为直播中 // 把结束时间小于当前时间且状态为2的课程置为已结束 } }定时任务的参数要和业务频率匹配。每5分钟扫一次对线上教学系统足够了,如果课程报名开始时间需要精确到秒,那正常直播也不会卡在秒级。跑完定时任务注意把状态变化的消息推送给前端,常见做法是每5分钟前端拉一次课程详情接口,状态变了页面按钮就会从“等待开播”变为“进入直播”。
4. 从在线学习到作业提交:Vue前端界面与API交互
4.1 视频播放页与学习进度上报
在线学习模块的前端核心是视频播放页面。别再装一个万能的播放器插件,对SpringBoot+Vue的项目来说,video.js或者httpx(指原生HTML5 video能力)足够。播放页面对学生需要做三件事:拉取视频地址、上报学习进度、播放结束后更新课程状态。Vue单页里我习惯把播放器封装成独立组件,这样课程回放和直播回放两个页面复用同一套进度逻辑。
<template> <div class="player-container"> <video ref="videoEl" :src="videoUrl" controls @timeupdate="onTimeUpdate" @ended="onVideoEnded" ></video> <div v-if="loading">加载中...</div> </div> </template> <script setup> import { ref, onMounted } from 'vue'; import { useRoute } from 'vue-router'; import { getVideoUrl, reportProgress } from '@/api/course'; const route = useRoute(); const videoEl = ref(null); const loading = ref(true); const videoUrl = ref(''); onMounted(async () => { const courseId = route.params.courseId; const res = await getVideoUrl(courseId); videoUrl.value = res.data.url; loading.value = false; }); function onTimeUpdate() { const currentTime = videoEl.value.currentTime; if (Math.floor(currentTime) % 30 === 0) { // 每30秒上报一次进度,避免频繁请求 reportProgress({ courseId: route.params.courseId, progress: Math.floor(currentTime), }); } } function onVideoEnded() { reportProgress({ courseId: route.params.courseId, progress: 999 }); } </script>进度上报参数里有几个细节:progress我存储的是播放秒数,不是百分比,因为视频总时长可能变化,存秒数换算百分比最准确。@timeupdate事件触发频率很高,不节流的话每秒钟会有4次请求,会把后端数据库打出不少垃圾数据。加一个每30秒或整30秒上报一次的判断,性能基本没有压力。progress: 999是约定值,表示看到末尾,后端根据这个值把报名记录更新为已完成状态。
4.2 作业提交与附件上传:FormData与进度条
作业提交页面最常遇到的问题是附件上传失败时用户不知道,或者上传大文件时页面卡死。前端的axios上传时原生支持进度回调,Element Plus的el-upload也封装了on-progress事件,在Vue3里我通常直接封装一个提交按钮组件。
<script setup> import { ref } from 'vue'; import axios from 'axios'; const props = defineProps({ assignmentId: { type: Number, required: true }, }); const fileList = ref([]); const uploading = ref(false); const progress = ref(0); async function submitHomework() { if (fileList.value.length === 0) return; uploading.value = true; progress.value = 0; const formData = new FormData(); formData.append('assignmentId', props.assignmentId); fileList.value.forEach((file) => { formData.append('files', file.raw); }); try { await axios.post('/api/homework/submit', formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (e) => { progress.value = Math.round((e.loaded / e.total) * 100); }, }); } finally { uploading.value = false; } } </script>这里的坑集中在跨域和网关层。Vue项目的vite开发代理只作用于开发环境,线上部署时前端页面和后端API如果不同源,multipart/form-data请求会先触发预检OPTIONS,后端如果没有正确响应OPTIONS请求,作业上传直接失败。解决方式有两种,要么生产环境把Vue打包后放进SpringBoot的static目录实现同源,要么在CORS配置里把allowedMethods加上OPTIONS。我推荐把Vue打包进SpringBoot,细节在最后一章说。
附件大小也要预先约定。后端spring.servlet.multipart.max-file-size参数默认只有1MB,作业附件动辄就是几十MB的压缩包,不调大必然翻车。我一般设为100MB,同时在前端做一层校验,超过100MB给出明确提示而不是等请求失败。
4.3 成绩查询与未读通知
成绩查询页面看起来简单,但学生视角的“成绩”往往是多门课程的成绩汇总,还要区分“已出分”“待批改”“未提交作业”三种状态。我在前端用一个聚合接口/api/student/scores一次性返回所有成绩记录,而不是每门课单独查询。后端用一条SQL按学生ID关联课程表、作业表、成绩表,返回最近六个学期的记录。
前端展示成绩排行榜时要注意权限:学生只能看到自己的分数,老师和管理员可以看到班级均分,这个逻辑在接口层做过滤,前端不能只隐藏列。实现方案是成绩接口返回数据时加一个viewPermission字段,学生为1时前端不展示均分栏,为2时展示。这样比前端角色判断更安全,因为接口本身就是数据边界。
5. 用户管理与后台管理:JWT、动态菜单与操作日志
5.1 登录态与权限拦截:JWT过滤器怎么写才不踩坑
后台管理系统的登录方案我选了JWT而非Session,理由是这个系统将来可能要拆小程序或者APP端,JWT的无状态特性对多端接入天然友好。但JWT在SpringBoot里落地有不少细节,网上随便找的过滤器代码大多只校验了签名,却没校验用户是否已被禁用。
@Component public class JwtAuthFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; private final UserMapper userMapper; public JwtAuthFilter(JwtTokenProvider tokenProvider, UserMapper userMapper) { this.tokenProvider = tokenProvider; this.userMapper = userMapper; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String token = resolveToken(request); if (token != null && tokenProvider.validateToken(token)) { Long userId = tokenProvider.getUserId(token); User user = userMapper.selectById(userId); if (user != null && user.getStatus() == 1) { request.setAttribute("userId", userId); request.setAttribute("userRole", user.getRole()); chain.doFilter(request, response); return; } } response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unauthorized"); } private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); return (bearer != null && bearer.startsWith("Bearer ")) ? bearer.substring(7) : null; } }这段过滤器踩过最深的坑是:只校验了tokenProvider.validateToken(token)却忽略了用户状态。学生被管理员禁用账号后,JWT本身还有效,他还能继续访问接口。我在做后台管理时把这个Bug当血泪教训记录下来,现在过滤器里每次都要查一次用户表确认status == 1。JWT的过期时间参数我设置为24小时,客户端在请求时发现401就跳登录页重新登录,这个方案比Access Token + Refresh Token的复杂度低很多,内部后台系统完全够用。
5.2 Vue动态菜单与按钮级权限
后台管理的前端菜单不能写死,因为不同角色登录后看到的菜单不一样。这里的常规实现是登录接口返回用户角色,前端根据角色表配置菜单路由。我用Vue Router的addRoute方法动态添加路由:
// 登录成功后 const roleMenus = userInfo.menus; // 数组如 ['course', 'homework', 'score'] roleMenus.forEach((menuKey) => { const routeConfig = menuMap[menuKey]; if (routeConfig) { router.addRoute('layout', routeConfig); } });menuMap是前端唯一的模块注册表,每个模块包含路由地址和组件引用。按钮权限同理,在自定义指令v-permission中检查用户权限列表,没有权限就移除按钮DOM。要注意的边界是:动态路由必须放在路由守卫的下一个周期执行,否则刷新页面时路由还没有注册完就跳转,会进404页。这个问题的标准处理方式是守卫里判断router.hasRoute(route.name),如果路由未注册就等待一次router.isReady()再放行。
5.3 后台用户管理与操作日志落库
用户管理功能在后端就是标准的CRUD,管理员可以启用、禁用、重置密码、分配角色。难的是操作日志,管理员改了什么、删了什么,没有日志就说不清楚。我用AOP做一个@OperationLog注解,标注在Controller方法上。
@Aspect @Component public class OperationLogAspect { @Around("@annotation(operationLog)") public Object recordLog(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable { long start = System.currentTimeMillis(); try { Object result = pjp.proceed(); saveLog(operationLog.action(), pjp.getArgs(), "SUCCESS", System.currentTimeMillis() - start); return result; } catch (Exception e) { saveLog(operationLog.action(), pjp.getArgs(), "ERROR", System.currentTimeMillis() - start); throw e; } } }日志表只需要包含操作人ID、操作模块、请求参数摘要、状态、耗时即可。注意不要盲目把整个请求体存入日志,富文本内容可能几千字,全存会把日志表撑爆,存一个参数摘要比如“id=123&title=***”就够了。操作日志的检索条件固定,按管理员ID和时间段查询的频率最高,建一个(operator_id, create_time)联合索引就够了。
6. 避坑指南:教育培训系统最常见的6个翻车现场
6.1 课程报名重复提交,报了一千条记录
排出这个坑之前我项目的报名表没有唯一索引,学生狂点报名按钮,服务端并发下插入了两条重复记录。后来加了uk_course_student唯一索引,再配合INSERT IGNORE,前端不管点几次后端只有一条生效。排查时看日志里的Duplicate entry错误,如果出现就说明前端按钮没有做防重复提交,应当在点击后立即设置按钮disabled。
6.2 直播推流地址十分钟就失效
第一次上线直播时,教师说地址刚复制到OBS推流就报错。排查发现我把VALID_SECONDS设成了1800,看起来是30分钟,但OBS从配置到推流可能超过这个时间。老师打开页面复制地址,再打开OBS填地址,再调整一遍参数,半小时过去了。解决方式是把有效期改为2小时,并且在直播地址生成页面显示过期倒计时,提醒老师“过期后刷新页面重新获取”。
6.3 大文件作业上传Nginx提示413
作业附件超过Nginx默认的client_max_body_size就会返回413错误,前端展示为“Network Error”。这个坑很容易误解为后端接口没写好。排查时先看Nginx错误日志,确认是否含client intended to send too large body。解决方式是在Nginx的http或server块设置client_max_body_size 100m,同时保证SpringBoot的max-file-size和max-request-size都调大到一致。
6.4 SpringBoot版本太高导致的MyBatis异常
用SpringBoot 3.2加MyBatis Plus时报了Invalid bound statement (not found),查了半天发现是MyBatis Plus的starter版本和SpringBoot 3.x不兼容。SpringBoot 3.0之后javax变成了jakarta命名空间,旧版mapper扫描失效。解决方式是把MyBatis Plus升级到3.5.5以上对应SpringBoot 3.x的专用版本,不要在老的Java 8项目里硬上SpringBoot 3.x。血泪经验:先确认JDK版本再决定SpringBoot大版本,JDK17以上才考虑SpringBoot 3,否则直接SpringBoot 2.7更稳。
6.5 作业附件存本地磁盘把服务器占满
开发阶段附件上传到本地目录没问题,跑了一个月后磁盘告警。原因是最开始没有给文件存储做路径隔离,所有课程的图片、视频、作业附件全部堆在/tmp目录。排查用du -sh /tmp发现占用几十GB。解决方式是统一封装文件存储Service接口,开发环境用本地磁盘,生产环境换对象存储OSS或者MinIO,附件路径按/课程ID/日期/文件名结构存放。
6.6 成绩查询接口慢,慢SQL一查少了条件
成绩查询列表页首屏加载要两秒多,打开慢SQL日志看到一条关联查询没有走索引。原因是JOIN时课程表的status字段虽然建了索引,但SQL只按student_id过滤成绩表,成绩表上与课程ID没有建索引。排查方式是开启SpringBoot的spring.datasource.sql-slow-query相关日志或者用阿里云DAS看慢查询记录。解决方式是在score表的course_id和student_id各建一个普通索引,查询时间直接降到百毫秒内。
7. 上线前的最后一步:接口压测与静态资源托管
7.1 用AB和JMeter快速定位瓶颈
上线前不需要复杂的压测平台,我习惯先用ApacheBench打一下核心接口。比如课程列表页和报名接口,ab的压测结果能快速暴露数据库连接池和慢查询的问题。
# 模拟100个并发用户,总共发起10000次课程列表请求 ab -n 10000 -c 100 -k \ -H "Authorization: Bearer $TOKEN" \ "https://yourdomain.com/api/course/list?page=1&size=10"参数说明:-n是总请求数,-c是并发数,-k开启长连接减少TCP握手开销。观察两个关键指标:Failed requests不能有非0值;Requests per second如果低于500就需要看是网络层还是数据库层。课程列表压测慢时优先检查SQL的EXPLAIN是否走索引,以及Redis缓存是否命中。报名接口压测更要注意唯一索引兜底,没有唯一索引压测时必然产生重复数据。
7.2 Vue打包放进SpringBoot的两种方式与回滚
标题里热门的“vue打包放进springboot中”其实是这系统生产部署的标准做法。方式一:把Vue构建的dist目录拷贝到SpringBoot的src/main/resources/static下,重新打包成一个jar。方式二:SpringBoot的WebMvcConfigurer里配置静态资源映射指到服务器上外置的Vue目录。我推荐方式一,交付维护都省心。
# 前端构建 npm run build # 把dist下的内容复制到SpringBoot的static目录 cp -r dist/* src/main/resources/static/ # 后端打包 mvn clean package -DskipTests # 启动并指定堆内存 java -Xms512m -Xmx1024m -jar target/edu-office-system.jar回滚的做法很简单,启动前把上一版本的jar文件备份,新版本出问题就直接替换回来。我现在习惯在发布目录保留app-1.0.0.jar和app-1.0.1.jar两个文件,发布命令里只改版本号,回滚时改一下软链指向重启进程。前端放静态目录后,注意SpringBoot默认static目录下的index.html如果路由是history模式,需要配置HistoryApiFallback,否则用户刷新子页面直接404。这段配置我通常放在WebMvcConfigurer里,把非API路径全部转发到/index.html。希望这篇笔记能帮你少走几个星期的弯路,把精力花在真正有价值的功能打磨上。
本文还有配套的精品资源,点击获取