ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java远程教育网站开发实战:Spring Boot+MyBatis从课程表到作业提交

Java远程教育网站开发实战:Spring Boot+MyBatis从课程表到作业提交 简介这份资源是一份基于Java的远程教育网站设计与实现毕业论文文档面向计算机相关专业学生、毕业设计开发者以及需要在线教育系统参考方案的技术人员。内容围绕远程教育平台的设计思路与实现过程展开涵盖学生端、教师端、试卷与试题管理、考试记录与答题详情、在线考试、公告管理及用户权限等多个功能模块并采用MySQL作为数据存储方案兼顾数据安全性与可操作性。资源包共1个docx文件大小约3.15MB文档结构完整包含摘要、目录及正文等毕业论文标准组成部分便于直接参考写作框架与系统设计逻辑。目前已有40人学习下载适合需要完成类似课题、了解教育信息化系统功能划分与数据库设计思路的读者借鉴使用。1. 基于 Java 的远程教育网站从课程表到作业提交一套能跑通的落地路径远程教育网站听起来像是个被做烂了的题目但真正动手写的时候你会发现它比图书管理、商品管理这类 CRUD 系统多出好几个硬骨头课程有先修关系、视频要防盗链、作业要限时提交、不同角色看到的菜单完全不同。我带过几届毕业设计十个选“远程教育网站”的学生里有八个卡在权限模型和文件上传这两块最后交出来的东西要么是单角色裸奔要么视频直接暴露真实路径。这篇笔记不讲空泛的“系统概述”而是按一个能上线的 Java 远程教育网站该有的样子把技术选型、数据库设计、核心接口、部署踩坑一条线拉通。适合正在做课程设计或毕业设计的同学也适合想用 Spring Boot 快速搭一个内部培训平台的开发。读完你能拿到一套可复现的骨架知道每个参数为什么这么设以及哪些地方最容易翻车。2. 技术选型与工程骨架为什么是 Spring Boot MyBatis 而不是别的2.1 后端框架的取舍Spring Boot 的自动配置省掉了什么远程教育网站的功能模块其实很固定用户认证、课程管理、章节视频、作业提交、成绩统计、公告通知。这些模块没有一个需要用到响应式编程或微服务拆分单体应用完全扛得住。选 Spring Boot 的理由很直接——它把 Tomcat 内嵌了把 Jackson 序列化配好了把数据源连接池默认成了 HikariCP。你不需要再写 web.xml不需要手动配 DispatcherServlet一个SpringBootApplication注解就能把整个上下文拉起来。我一般会锁定 Spring Boot 2.7.x 这个版本线原因是它同时兼容 Java 8 和 Java 11而很多学校的实验环境还停留在 JDK 8。如果你用 JDK 17那就直接上 Spring Boot 3.x但要注意 3.x 把 javax 包名换成了 jakarta老代码迁移时Resource和PostConstruct这些注解的 import 路径全要改这是个不大不小的坑。持久层用 MyBatis 而不是 JPA主要考虑是远程教育网站里“课程列表带教师姓名和分类名称”这种联表查询很常见MyBatis 的 XML 映射写起来更直观SQL 完全可控。JPA 的懒加载在 JSON 序列化时容易触发 N1 问题排查起来费时间。如果你更习惯 MyBatis-Plus那也行但要注意它的selectPage在联表分页时需要手动写 count 语句别指望它全自动。2.2 数据库表结构五张核心表撑起整个业务远程教育网站的数据模型不需要搞得太复杂下面这五张表是必须的其余都是围绕它们做扩展。表名作用关键字段user存所有角色用户id, username, password, role, avatarcourse课程主表id, title, cover, teacher_id, category_id, pricechapter章节与视频id, course_id, title, video_url, duration, sorthomework作业提交记录id, course_id, student_id, file_path, submit_time, scoreenrollment选课关系id, course_id, student_id, enroll_timerole 字段用枚举值区分STUDENT、TEACHER、ADMIN。不要用多张表分别存学生和教师那样登录逻辑要查两次得不偿失。video_url 存的是相对路径比如/upload/video/2025/01/abc.mp4绝对路径只在 Nginx 配置里出现这样迁移服务器时不用改数据库。建表时注意两个细节一是enrollment表要对(course_id, student_id)建唯一索引防止重复选课二是homework表的submit_time用datetime而不是timestamp因为 timestamp 有 2038 年问题虽然远程教育网站未必能活到那时候但习惯要养好。2.3 最小可运行工程的搭建步骤下面这套命令是在空目录下从零拉起一个能返回 JSON 的后端服务每一步都验证过。# 用 Spring Initializr 的 curl 方式生成骨架避免手动建目录 curl https://start.spring.io/starter.zip \ -d typemaven-project \ -d languagejava \ -d bootVersion2.7.18 \ -d groupIdcom.edu \ -d artifactIdremote-edu \ -d nameremote-edu \ -d packageNamecom.edu.remote \ -d javaVersion8 \ -d dependenciesweb,mybatis,mysql,lombok \ -o remote-edu.zip unzip remote-edu.zip -d remote-edu cd remote-edu这段命令做了一件事向 start.spring.io 发一个带参数的请求让它生成一个包含 Web、MyBatis、MySQL 驱动和 Lombok 的 Maven 工程压缩包。bootVersion2.7.18是 2.7.x 的最后一个稳定版javaVersion8保证在旧环境也能编译。解压后你会看到标准的src/main/java和src/main/resources目录结构。接下来改application.yml把数据库连接配上server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/remote_edu?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 500MB max-request-size: 500MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.edu.remote.entitycontext-path: /api让所有接口自动带上/api前缀前端代理时少写一层。max-file-size设成 500MB 是为了支持视频上传但注意这个值不能超过 Tomcat 的maxSwallowSize否则大文件上传会静默失败。serverTimezone必须显式指定不然 MySQL 8 会报时区错误这是血泪经验。启动类上加MapperScan(com.edu.remote.mapper)然后写一个最简单的 Controller 测试RestController RequestMapping(/test) public class TestController { GetMapping(/ping) public MapString, Object ping() { MapString, Object result new HashMap(); result.put(code, 200); result.put(msg, pong); return result; } }跑mvn spring-boot:run浏览器访问http://localhost:8080/api/test/ping看到{code:200,msg:pong}就说明骨架通了。这一步看着简单但很多人卡在端口占用或数据库连不上先把这个最小闭环跑通再往下做能省掉后面一半的排查时间。3. 权限与课程管理把角色隔离和视频防盗链做扎实3.1 基于拦截器的角色校验为什么不用 Spring SecuritySpring Security 功能全但配置复杂一个远程教育网站用拦截器加注解就能搞定权限。我的做法是自定义一个RequireRole注解配合HandlerInterceptor在请求进入 Controller 之前校验当前登录用户的角色。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }拦截器里从 session 或 token 中取出用户角色和注解上声明的角色数组做比对不匹配就返回 403。这样在 Controller 方法上写RequireRole({TEACHER,ADMIN})就能限制只有教师和管理员能访问。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) return true; HandlerMethod method (HandlerMethod) handler; RequireRole requireRole method.getMethodAnnotation(RequireRole.class); if (requireRole null) return true; User user (User) request.getSession().getAttribute(user); if (user null) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } for (String role : requireRole.value()) { if (role.equals(user.getRole())) return true; } response.setStatus(403); response.getWriter().write({\code\:403,\msg\:\无权限\}); return false; } }这段代码的关键点是先判断 handler 是不是 HandlerMethod因为静态资源请求也会走拦截器不判断会抛异常。返回 401 和 403 时直接写 JSON不要用sendRedirect否则前端拿到的是 HTML 登录页解析会出错。注册拦截器时用addPathPatterns(/**)但excludePathPatterns(/user/login, /user/register)把登录注册放行。3.2 视频上传与防盗链文件存本地还是对象存储课程视频是远程教育网站最占资源的部分。如果只是课程设计存本地磁盘完全够用但要注意三个参数上传路径、访问路径、防盗链。上传路径我一般设在项目外的独立目录比如/data/edu/upload/而不是放在src/main/resources下。原因是打成 jar 包后resources 目录是只读的写进去的文件重启就丢。在application.yml里配一个自定义属性edu: upload: base-path: /data/edu/upload/ access-prefix: /files/上传接口用MultipartFile接收按日期分目录存储PostMapping(/upload/video) RequireRole({TEACHER,ADMIN}) public MapString, Object uploadVideo(RequestParam(file) MultipartFile file) throws IOException { String dateDir new SimpleDateFormat(yyyy/MM).format(new Date()); File dir new File(basePath dateDir); if (!dir.exists()) dir.mkdirs(); String originalName file.getOriginalFilename(); String suffix originalName.substring(originalName.lastIndexOf(.)); String newName UUID.randomUUID().toString().replace(-, ) suffix; File dest new File(dir, newName); file.transferTo(dest); MapString, Object result new HashMap(); result.put(code, 200); result.put(url, accessPrefix dateDir / newName); return result; }UUID重命名是为了防止中文文件名和重复文件名导致的覆盖。transferTo底层用的是FileChannel比InputStream拷贝快但要注意目标文件不能已存在否则会抛异常。防盗链靠 Nginx 配置核心是校验Referer头location /files/ { alias /data/edu/upload/; valid_referers none blocked server_names *.yourdomain.com; if ($invalid_referer) { return 403; } }valid_referers里加上自己的域名其他来源的请求直接 403。注意none和blocked要保留否则用户直接在浏览器地址栏输入视频地址也会被拦这属于误伤。如果你不想配 Nginx也可以在 Java 层用GetMapping读文件流时校验Referer但性能不如 Nginx。3.3 课程与章节的增删改查接口课程管理是典型的树形结构课程下有章节章节下有视频。查询课程详情时要把章节列表一起返回用 MyBatis 的collection映射做嵌套查询。resultMap idCourseDetailMap typecom.edu.remote.entity.Course id propertyid columnid/ result propertytitle columntitle/ collection propertychapters ofTypecom.edu.remote.entity.Chapter id propertyid columnchapter_id/ result propertytitle columnchapter_title/ result propertyvideoUrl columnvideo_url/ /collection /resultMap select idselectCourseDetail resultMapCourseDetailMap SELECT c.id, c.title, ch.id AS chapter_id, ch.title AS chapter_title, ch.video_url FROM course c LEFT JOIN chapter ch ON ch.course_id c.id WHERE c.id #{courseId} ORDER BY ch.sort ASC /select这里用LEFT JOIN而不是两次查询减少数据库往返。ORDER BY ch.sort保证章节顺序和教师拖拽排序的结果一致。注意chapter_id和chapter_title的别名不能省否则 MyBatis 映射不到。新增章节时要校验course_id是否存在以及当前用户是不是该课程的教师。这个校验放在 Service 层public void addChapter(Long courseId, Chapter chapter, Long userId) { Course course courseMapper.selectById(courseId); if (course null) throw new RuntimeException(课程不存在); if (!course.getTeacherId().equals(userId)) throw new RuntimeException(无权操作); chapter.setCourseId(courseId); chapterMapper.insert(chapter); }RuntimeException在实际项目中会被全局异常处理器捕获返回统一的错误码。不要用System.out.println打日志用Slf4j加log.error方便排查。4. 作业提交与成绩统计文件上传的边界和并发选课的处理4.1 作业提交的限时与文件类型校验作业提交比视频上传多两个约束截止时间和文件类型。截止时间存在homework表的deadline字段提交时先比对当前时间。public MapString, Object submitHomework(Long homeworkId, MultipartFile file, Long studentId) { Homework hw homeworkMapper.selectById(homeworkId); if (hw null) return error(作业不存在); if (new Date().after(hw.getDeadline())) return error(已过截止时间); String originalName file.getOriginalFilename(); String suffix originalName.substring(originalName.lastIndexOf(.) 1).toLowerCase(); ListString allowed Arrays.asList(pdf, doc, docx, zip, rar); if (!allowed.contains(suffix)) return error(不支持的文件类型); // 检查是否已提交过 HomeworkSubmit exist submitMapper.selectByHomeworkAndStudent(homeworkId, studentId); if (exist ! null) return error(请勿重复提交); // 保存文件并记录 String path saveFile(file, homework); HomeworkSubmit submit new HomeworkSubmit(); submit.setHomeworkId(homeworkId); submit.setStudentId(studentId); submit.setFilePath(path); submit.setSubmitTime(new Date()); submitMapper.insert(submit); return success(); }allowed列表里不要加exe和js这是安全底线。selectByHomeworkAndStudent查重时要用LIMIT 1避免全表扫描。如果允许补交就把查重逻辑改成更新已有记录但要在submit_time上留痕。4.2 选课并发唯一索引兜底而不是先查后插选课接口在高并发下容易出现同一学生选同一门课两次的情况。很多人写代码是“先 select 判断有没有没有就 insert”这在并发下会翻车因为两个请求可能同时通过 select 检查。正确做法是给enrollment表的(course_id, student_id)加唯一索引然后直接 insert捕获DuplicateKeyExceptionpublic MapString, Object enroll(Long courseId, Long studentId) { try { Enrollment e new Enrollment(); e.setCourseId(courseId); e.setStudentId(studentId); e.setEnrollTime(new Date()); enrollmentMapper.insert(e); return success(选课成功); } catch (DuplicateKeyException ex) { return error(已选过该课程); } }唯一索引是数据库层面的最后一道防线比应用层判断可靠得多。捕获异常后返回友好提示不要直接把堆栈抛给前端。如果课程有容量限制那还要在 insert 前用SELECT ... FOR UPDATE锁课程行但那是另一个话题了。4.3 成绩统计的 SQL 写法教师需要看到每份作业的提交人数、已批改人数、平均分。用一条 SQL 搞定SELECT h.id AS homework_id, h.title, COUNT(s.id) AS submit_count, SUM(CASE WHEN s.score IS NOT NULL THEN 1 ELSE 0 END) AS graded_count, AVG(s.score) AS avg_score FROM homework h LEFT JOIN homework_submit s ON s.homework_id h.id WHERE h.course_id #{courseId} GROUP BY h.id, h.titleSUM(CASE WHEN ...)是统计已批改数量的常用技巧比子查询快。AVG会自动忽略 NULL 值所以未批改的记录不会拉低平均分。如果avg_score返回 null说明还没有任何批改前端显示“暂无”即可。5. 避坑与排查远程教育网站开发中最容易翻车的五件事5.1 视频上传到一半失败前端只看到 500现象上传大视频时进度条走到一半突然报错后端日志显示FileSizeLimitExceededException。原因Spring Boot 的max-file-size配了 500MB但 Tomcat 的maxSwallowSize默认只有 2MB超过这个值 Tomcat 会主动断开连接。解决在application.yml里加server.tomcat.max-swallow-size: -1表示不限制。同时 Nginx 的client_max_body_size也要同步改成 500m否则请求根本到不了 Java 层。5.2 中文文件名下载后变成乱码现象学生下载教师上传的课件文件名显示为%E8%AF%BE%E4%BB%B6.pdf。原因HTTP 响应头里的Content-Disposition没有对文件名做 URL 编码或者编码方式不对。解决用URLEncoder.encode(fileName, UTF-8)编码后再拼接到响应头并且把空格替换成%20。注意URLEncoder会把空格编成而 HTTP 头里不会被解析为空格所以要手动替换。5.3 课程列表分页查询越翻越慢现象课程列表第一页很快翻到第 100 页时响应超过 3 秒。原因用了LIMIT offset, sizeoffset 越大MySQL 要扫描的行越多。解决改成基于游标的分页用WHERE id last_id ORDER BY id LIMIT size。前端传上一页最后一条的 id而不是页码。如果必须用页码那就限制最大页数比如只允许翻到第 50 页。5.4 教师删除课程后学生的选课记录变成孤儿数据现象课程被删除但enrollment表里还留着对应的course_id学生个人中心显示“未知课程”。原因删除课程时只删了course表没有级联处理关联表。解决在 Service 层的删除方法里加事务先删chapter和enrollment再删course。或者用数据库外键的ON DELETE CASCADE但外键在分库分表时会失效所以应用层级联更可控。5.5 定时任务统计昨日学习时长时重复执行现象每天凌晨统计任务跑了两次报表数据翻倍。原因如果部署了两个实例每个实例的定时任务都会触发。解决用 Redis 的SETNX加锁key 设为task:daily:20250101过期时间 24 小时。抢到锁的实例才执行执行完不主动删 key让它自然过期。这样即使两个实例同时启动也只有一个能跑。6. 用 AOP 记录操作日志与接口耗时一个能直接抄的切面远程教育网站上线后最怕的是“学生说提交了作业但系统没记录”。这时候如果没有操作日志排查就是黑匣子。我一般会写一个 AOP 切面把所有带Log注解的方法的入参、出参、耗时、异常都记下来。Aspect Component Slf4j public class LogAspect { Around(annotation(com.edu.remote.annotation.Log)) public Object around(ProceedingJoinPoint point) throws Throwable { long start System.currentTimeMillis(); String methodName point.getSignature().toShortString(); Object[] args point.getArgs(); try { Object result point.proceed(); long cost System.currentTimeMillis() - start; log.info(method{}, args{}, cost{}ms, result{}, methodName, JSON.toJSONString(args), cost, JSON.toJSONString(result)); return result; } catch (Throwable e) { long cost System.currentTimeMillis() - start; log.error(method{}, args{}, cost{}ms, error{}, methodName, JSON.toJSONString(args), cost, e.getMessage()); throw e; } } }Around是环绕通知能在方法执行前后都插入逻辑。point.proceed()是真正调用目标方法必须放在 try 里否则异常会中断日志记录。JSON.toJSONString用的是 Fastjson如果你用 Jackson换成ObjectMapper.writeValueAsString即可。注意别把MultipartFile直接序列化会抛异常可以在切面里过滤掉MultipartFile类型的参数。这个切面配合Log注解使用在需要记录的 Controller 方法上加一行Log就行。日志按天滚动用 Logback 配RollingFileAppender保留 30 天。这样出问题时直接 grep 日志文件输入学生 id 或课程 id就能还原整个操作链路。我自己的习惯是任何涉及“写”操作的方法都加Log查询方法不加否则日志量太大。另外耗时超过 1000ms 的请求我会单独打一条WARN级别的日志方便后续做性能优化。这个切面代码量不大但上线后能帮你省下大量“扯皮”时间。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进