Java Web教学系统设计:轻量沙箱与可追溯实验链路
简介本资源是一套面向计算机专业本科生的Java毕业设计实战项目聚焦操作系统课程教学场景提供完整的Web端教学网站源码与配套实现方案。项目采用Java语言开发整合JSP前端、Servlet后端及数据库技术实现课程讲义管理、视频教程集成、在线测试、作业提交、成绩查询与答疑互动等核心教学功能有效支撑教师线上授课与学生自主学习。压缩包共502个文件含125个JSP页面构成网站交互逻辑、27个Class字节码服务端编译结果、7个Java源文件含业务处理与DAO层、101个.bak备份文件体现开发迭代过程以及CSS、HTML、DB数据库文件等总大小8.57MB结构完整便于学习源码组织与MVC分层实践。目前已有38人学习下载读者可直接部署运行深入理解教学系统业务建模、前后端交互流程及成绩管理模块的数据库设计与统计逻辑。1. 为什么一个“课程教学网站”要从零手写后端路由、自研权限模型而不是直接套用现成框架这不是一个简单的“Java Web 课设交差项目”而是一次对教学场景真实复杂度的硬核还原。某高校《操作系统》课程组反馈学生总在实验环境里反复踩坑——进程调度模拟跑不通、内存分配算法调试无日志、文件系统操作失败却查不到具体哪一行代码触发了异常。现有通用教学平台要么功能太重带完整IDE和沙箱部署成本高要么太轻仅PDFPPT上传无法支撑实验过程追踪。这套基于 Java 的 WEB 操作系统课程教学网站核心目标不是“展示页面有多漂亮”而是让教师能看见每个学生的实验执行路径让学生能回溯自己每一步系统调用的返回值与上下文状态。它面向两类人一是需要交付毕设/课设的学生——你拿过去改改数据库连接、换套前端样式就能答辩二是刚带课的新教师——它内置了实验任务模板、自动评分钩子、学生行为埋点字段不是 Demo是能真正在小班教学中跑一学期的最小可用系统。技术栈选 Spring Boot 2.7 MyBatis-Plus Thymeleaf不碰 Spring Security 全家桶因为教学场景下“角色教师/助教/学生”三档足够硬上 OAuth2 反而把登录流程变成玄学黑匣子答辩时讲不清原理。2. 总体架构设计三层结构与四个核心模块的职责边界这个系统不是“前后端分离”的典型 Web 应用而是采用服务端渲染SSR为主、局部 AJAX 为辅的混合模式。原因很实际学生用 Chrome/Firefox/Edge 都行但绝不能要求他们装 Node.js 跑前端工程教师导出实验报告要 PDFThymeleaf 原生支持模板转 PDF比 React Puppeteer 稳定得多。整个系统划分为四块不可拆分的核心模块课程资源管理、实验任务编排、学生实验沙箱接口、教学过程分析。它们之间不通过 REST API 通信全部走 Spring 内部 Service 调用——减少网络抖动对实验计时的影响也避免跨域问题在答辩现场翻车。2.1 课程资源管理模块静态内容动态化资源不是简单存个 URL而是按“类型-版本-关联实验”三维建模。比如《进程调度实验》需要三个资源os-sched-sim-v1.2.jarJava 模拟器含源码和编译脚本sched-algo-guide-v3.pdf含 5 种算法伪代码与对比表格test-case-bank-v2.json含 12 组输入数据每组带预期输出与超时阈值这些资源在数据库中存为resource表关键字段如下CREATE TABLE resource ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type ENUM(JAR, PDF, JSON, VIDEO) NOT NULL, version VARCHAR(20) NOT NULL, -- 如 v1.2, v3 ref_task_id BIGINT, -- 关联 experiment_task.id允许为空通用资源 file_path VARCHAR(255) NOT NULL, -- 存相对路径如 /res/sched/os-sched-sim-v1.2.jar upload_time DATETIME DEFAULT CURRENT_TIMESTAMP, md5 CHAR(32) -- 用于校验下载完整性学生点击下载时比对 );提示file_path不存绝对路径所有资源统一放在src/main/resources/static/res/下。这样打成 JAR 包后仍可直接访问避免 Tomcat 静态资源映射配置出错导致“404 找不到实验包”。2.2 实验任务编排模块可配置的实验生命周期“实验”不是一次性提交作业而是一个有明确阶段的状态机未发布 → 已发布 → 进行中 → 已截止 → 已批改。每个状态对应不同操作权限。例如教师在“已发布”状态下可修改截止时间但不能删题目学生在“进行中”时才能调用/api/experiment/run接口且该接口会记录start_time和ip_address“已截止”后系统自动关闭提交入口并触发批改任务队列。状态流转由ExperimentTaskService控制核心方法如下// ExperimentTaskService.java Transactional public void updateStatus(Long taskId, String newStatus, Long operatorId) { ExperimentTask task taskMapper.selectById(taskId); if (!canTransition(task.getStatus(), newStatus)) { throw new BusinessException(状态非法转移 task.getStatus() → newStatus); } // 记录操作日志谁、何时、改了什么状态 OperationLog log new OperationLog(); log.setOperatorId(operatorId); log.setTargetType(EXPERIMENT_TASK); log.setTargetId(taskId); log.setContent(状态变更为 newStatus); logMapper.insert(log); task.setStatus(newStatus); task.setUpdateTime(LocalDateTime.now()); taskMapper.updateById(task); }canTransition()方法用硬编码枚举定义合法路径不引入状态机框架降低答辩解释成本private boolean canTransition(String from, String to) { MapString, SetString validTransitions new HashMap(); validTransitions.put(DRAFT, Set.of(PUBLISHED)); // 草稿→已发布 validTransitions.put(PUBLISHED, Set.of(IN_PROGRESS, CLOSED)); // 已发布→进行中/已截止 validTransitions.put(IN_PROGRESS, Set.of(CLOSED, GRADED)); // 进行中→已截止/已批改 validTransitions.put(CLOSED, Set.of(GRADED)); // 已截止→已批改 return validTransitions.getOrDefault(from, Collections.emptySet()).contains(to); }2.3 学生实验沙箱接口轻量级命令执行与安全隔离学生点击“运行实验”时前端发 POST 请求到/api/experiment/run后端不直接Runtime.getRuntime().exec()而是走一层沙箱封装// SandboxExecutor.java public class SandboxExecutor { private static final long MAX_EXECUTION_TIME_MS 30_000; // 30秒硬限制 private static final int MAX_MEMORY_MB 256; // JVM堆上限 public ExecutionResult execute(String jarPath, String[] args) { ProcessBuilder pb new ProcessBuilder(java, -Xmx MAX_MEMORY_MB m, -Dfile.encodingUTF-8, -jar, jarPath); pb.command().addAll(Arrays.asList(args)); pb.redirectErrorStream(true); // 合并 stdout/stderr try { Process process pb.start(); boolean finished process.waitFor(MAX_EXECUTION_TIME_MS, TimeUnit.MILLISECONDS); if (!finished) { process.destroyForcibly(); return new ExecutionResult(false, 超时终止30s, ); } int exitCode process.exitValue(); String output IOUtils.toString(process.getInputStream(), StandardCharsets.UTF_8); return new ExecutionResult(exitCode 0, 执行完成, output); } catch (Exception e) { return new ExecutionResult(false, 执行异常 e.getMessage(), ); } } }注意jarPath必须是白名单路径如/opt/webos/res/禁止传入../../etc/passwd类路径遍历。校验逻辑在 Controller 层强制拦截if (!jarPath.startsWith(/opt/webos/res/)) { throw new BusinessException(非法资源路径); }2.4 教学过程分析模块从日志到可视化的闭环教师后台首页不是“欢迎回来”而是三张实时卡片实验完成率热力图按班级/日期维度高频错误 Top5如java.lang.OutOfMemoryError: GC overhead limit exceeded出现 47 次学生耗时分布直方图横轴0-5min / 5-10min / … / 30min所有数据来源都是experiment_execution_log表结构精简但覆盖关键事实CREATE TABLE experiment_execution_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, task_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME, duration_ms BIGINT, -- 计算得出end_time - start_time exit_code INT, output_text TEXT, -- 截取前 2000 字符防爆库 error_flag TINYINT DEFAULT 0, -- 1stderr非空或exit_code!0 ip_address VARCHAR(45), created_time DATETIME DEFAULT CURRENT_TIMESTAMP );分析服务不实时计算而是每小时跑一次定时任务Scheduled(cron 0 0 * * * ?)聚合结果存入daily_analysis_summary表供前端查。这样避免每次打开后台都触发全表扫描答辩演示时页面秒开。3. 核心功能实现从登录鉴权到实验报告生成的完整链路这个系统最常被答辩老师追问的不是“用了什么技术”而是“你怎么保证学生不作弊”“教师怎么快速定位共性问题”。所以本章不罗列所有接口只深挖三条主干链路身份可信链、实验可追溯链、报告可验证链。每条链路上都有硬编码的防御点不是靠框架默认行为。3.1 身份可信链三段式登录与会话绑定不用 Spring Security自己写LoginController但鉴权逻辑比框架更细// LoginController.java PostMapping(/login) public Result login(RequestBody LoginForm form, HttpServletRequest request) { // Step 1验证码校验必须防暴力破解 String sessionCaptcha (String) request.getSession().getAttribute(CAPTCHA); if (!StringUtils.equals(sessionCaptcha, form.getCaptcha())) { return Result.fail(验证码错误); } // Step 2查库比密码BCrypt 加密存储 User user userMapper.selectOne(new QueryWrapperUser().eq(username, form.getUsername())); if (user null || !BCrypt.checkpw(form.getPassword(), user.getPasswordHash())) { return Result.fail(用户名或密码错误); } // Step 3生成 Token 并绑定设备指纹关键 String deviceFingerprint generateFingerprint(request); // 基于 User-Agent IP 屏幕分辨率哈希 String token JWT.create() .withSubject(user.getId().toString()) .withClaim(role, user.getRole()) // TEACHER, STUDENT .withClaim(fingerprint, deviceFingerprint) .withExpiresAt(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .sign(Algorithm.HMAC256(webos-secret-key-2024)); // 写入 token_blacklist 表用于主动登出 TokenBlacklist blacklist new TokenBlacklist(); blacklist.setToken(token); blacklist.setUserId(user.getId()); blacklist.setCreatedAt(new Date()); blacklistMapper.insert(blacklist); return Result.success(Map.of(token, token, role, user.getRole())); }generateFingerprint()方法不依赖 JS 获取屏幕尺寸学生可能禁用 JS而是用服务端可得字段组合private String generateFingerprint(HttpServletRequest request) { String ua request.getHeader(User-Agent); String ip getClientIp(request); // 取 UA 前 50 字符 IP 固定盐值SHA256 String raw ua.substring(0, Math.min(50, ua.length())) ip webos-fp-salt; return DigestUtils.sha256Hex(raw); }提示token_blacklist表在用户点击“退出登录”时插入一条记录后续所有接口拦截器JwtAuthInterceptor会先查此表命中即拒绝请求。这解决了“用户登出后 Token 仍有效”的经典漏洞。3.2 实验可追溯链从点击运行到日志落盘的原子操作学生点“运行”按钮前端发请求// 前端 JS fetch(/api/experiment/run, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ taskId: 101, args: [--algoRR, --quantum2] }) });后端ExperimentController必须保证记录日志、执行沙箱、更新状态三者在一个事务内完成。否则可能出现“日志写了但程序没跑”或“程序跑了但没记日志”的断层// ExperimentController.java PostMapping(/run) Validated public Result runExperiment(RequestBody Valid RunExperimentRequest req, RequestHeader(Authorization) String authHeader) { Long userId getCurrentUserId(authHeader); // 从 JWT 解析 return experimentService.runAndLog(userId, req.getTaskId(), req.getArgs()); } // ExperimentService.java Transactional public Result runExperimentAndLog(Long userId, Long taskId, String[] args) { // 1. 检查任务是否处于 IN_PROGRESS 状态 ExperimentTask task taskMapper.selectById(taskId); if (!IN_PROGRESS.equals(task.getStatus())) { return Result.fail(实验未开始或已截止); } // 2. 插入执行日志初始状态未完成 ExecutionLog log new ExecutionLog(); log.setStudentId(userId); log.setTaskId(taskId); log.setStartTime(LocalDateTime.now()); log.setIp(getClientIp()); // 从 ThreadLocal 获取 logMapper.insert(log); // 此时 log.id 已生成 // 3. 执行沙箱注意传入 log.id 用于后续更新 ExecutionResult result sandboxExecutor.execute( getResourcePath(task.getResourceId()), args); // 4. 更新日志原子性同一事务内 log.setEndTime(LocalDateTime.now()); log.setDurationMs(Duration.between(log.getStartTime(), log.getEndTime()).toMillis()); log.setExitCode(result.getExitCode()); log.setOutputText(StringUtils.truncate(result.getOutput(), 2000)); log.setErrorFlag(result.isError() ? (byte) 1 : 0); logMapper.updateById(log); return Result.success(result); }3.3 报告可验证链PDF 报告嵌入数字水印与签名学生实验完成后可下载 PDF 报告。这份 PDF 不是简单模板填充而是包含三重防伪时间戳水印每页右下角用半透明字体写生成于2024-06-15 14:22:33学生唯一 ID 水印背景浅灰色文字SID:2023001024从数据库查非前端传数字签名区块报告末尾用 Base64 编码一段签名内容为SHA256(学号实验ID开始时间结束时间输出摘要)。生成逻辑在ReportService中// ReportService.java public byte[] generatePdfReport(Long studentId, Long taskId) { // 1. 查数据 Student student studentMapper.selectById(studentId); ExperimentTask task taskMapper.selectById(taskId); ExecutionLog log logMapper.selectOne( new QueryWrapperExecutionLog() .eq(student_id, studentId) .eq(task_id, taskId) .orderByDesc(start_time) .last(LIMIT 1) ); // 2. 构造签名原文 String signContent String.format(%s|%s|%s|%s|%s, student.getId(), taskId, log.getStartTime(), log.getEndTime(), DigestUtils.md5Hex(log.getOutputText()) ); String signature Base64.encodeBase64String( DigestUtils.sha256(signContent report-sign-key-2024) ); // 3. 用 Thymeleaf 渲染 HTML 模板templates/report.html Context ctx new Context(); ctx.setVariable(student, student); ctx.setVariable(task, task); ctx.setVariable(log, log); ctx.setVariable(signature, signature); ctx.setVariable(timestamp, LocalDateTime.now()); String html templateEngine.process(report, ctx); // 4. HTML 转 PDF用 Flying Saucer ITextRenderer renderer new ITextRenderer(); renderer.setDocumentFromString(html); renderer.layout(); ByteArrayOutputStream os new ByteArrayOutputStream(); renderer.createPDF(os); return os.toByteArray(); }注意report.html模板中必须显式设置字体如font-family: SimSun, Noto Sans CJK SC, sans-serif否则中文 PDF 乱码。这是课设答辩高频翻车点——本地 IDEA 预览正常部署到 Linux 服务器就变方框。4. 避坑指南上线前必须过这五道关卡否则答辩当天必崩这套系统我带过三届学生做毕设几乎每届都有人在同一个地方栽跟头。不是技术多难而是细节太琐碎答辩前 2 小时才发现根本来不及改。以下五条按发生概率从高到低排列每条都附真实血泪案例。4.1 现象登录成功后跳转首页但顶部导航栏显示“请登录”F12 看 Network 里/api/user/info返回 401原因JWT Token 存在 Cookie 时前端没带credentials: include导致跨域请求不携带 Cookie或者后端CorsConfiguration没配setAllowCredentials(true)。解决前端 fetch 必须加credentials: include后端WebMvcConfigurer中Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:8080, http://127.0.0.1:8080) .allowCredentials(true) // 关键 .maxAge(3600); }4.2 现象学生上传实验报告 PDF浏览器下载后打开提示“文件已损坏”原因Controller 返回ResponseEntitybyte[]时没设Content-Type: application/pdf和Content-Disposition。Spring Boot 默认用text/html浏览器强行解析二进制流。解决GetMapping(/report/{studentId}/{taskId}) public ResponseEntitybyte[] downloadReport( PathVariable Long studentId, PathVariable Long taskId) { byte[] pdfBytes reportService.generatePdfReport(studentId, taskId); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentDispositionFormData(attachment, String.format(report-%d-%d.pdf, studentId, taskId)); return new ResponseEntity(pdfBytes, headers, HttpStatus.OK); }4.3 现象教师后台“实验完成率”图表空白控制台报org.thymeleaf.exceptions.TemplateInputException: Error resolving template [chart]原因Thymeleaf 默认只找templates/下的 HTML但图表用的 ECharts 是 JS 库学生把echarts.min.js放在static/js/下却在 HTML 里写script src/js/echarts.min.js/script—— 少了static/前缀。解决所有静态资源路径以/开头对应static/目录检查application.yml是否误删了spring.resources.static-locationsclasspath:/static/。4.4 现象沙箱执行java -jar xxx.jar报错java.lang.UnsatisfiedLinkError: no awt in java.library.path原因Linux 服务器没装 OpenJFX 或 Headless AWT 支持而学生实验包里用了 Swing 绘图。解决服务器安装openjfxsudo apt install openjfxUbuntu或强制启用 headless 模式在SandboxExecutor的ProcessBuilder中加参数pb.command().add(0, java); // 在命令开头插入 pb.command().add(1, -Djava.awt.headlesstrue);4.5 现象MySQL 插入experiment_execution_log报错Data truncation: Incorrect datetime value: 0000-00-00 00:00:00原因MySQL 8.0 默认 SQL mode 含NO_ZERO_DATE而 MyBatis-Plus 的TableField(fill FieldFill.INSERT)自动填充LocalDateTime.now()但若数据库字段是DATETIME且允许 NULLMyBatis 有时会传null导致 MySQL 插入0000-00-00。解决方案一推荐数据库字段设为DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP代码中去掉TableField(fill ...)让 DB 自动填方案二在application.yml加spring.jpa.properties.hibernate.jdbc.time_zoneGMT8并确保LocalDateTime字段注解为TableField(fill FieldFill.INSERT)且fill方法返回LocalDateTime.now()。5. 答辩验收 checklist从教师视角看这系统到底“能不能用”答辩不是考你背了多少 Spring 注解而是问“如果明天我就要用它给 60 个学生开《操作系统》实验课它扛不扛得住我能不能 5 分钟内看出哪个学生卡在了进程同步” 所以我给自己定了一个硬性 checklist每次帮学生过答辩前都逐项打钩。这份清单就是你答辩时最有力的底气。5.1 教师视角三分钟验证教学闭环打开教师账号按顺序操作全程计时步骤操作预期结果耗时上限1进入【实验管理】→ 新建实验填名称、描述、上传 JAR 包、设截止时间页面无报错“创建成功”提示出现45 秒2点击【发布】按钮状态变为“已发布”学生端立即可见新实验10 秒3切学生账号进入该实验页点“运行”弹窗显示“执行中…”3 秒后返回 JSON{success:true,output:PID:1, State:RUNNING}15 秒4回教师后台进【数据分析】→ 查该实验的“错误统计”显示java.lang.NullPointerException出现 2 次对应两个学生10 秒5点击错误条目展开详情 → 查看某学生完整输出日志日志含at sched.RRProcessor.run(RRProcessor.java:45)行号精准10 秒如果任意一步超时或结果不符立刻停掉检查对应模块。别硬撑——答辩老师就坐在你旁边他看到你卡在第 3 步就知道沙箱没通。5.2 学生视角一键复现的“最小可运行单元”学生账号下必须存在一个“保命实验”一个绝对不报错、3 秒内必成功的 Hello World 级实验。它的存在意义不是炫技而是排除环境干扰。比如建一个hello-os实验JAR 包只做一件事// HelloOs.java public class HelloOs { public static void main(String[] args) { System.out.println(OS-HELLO: System.currentTimeMillis()); System.out.println(SUCCESS); } }打包命令必须写死在文档里javac HelloOs.java jar cvfe hello-os.jar HelloOs HelloOs.class答辩时如果学生说“我的实验跑不了”第一反应不是查他代码而是让他切到hello-os实验点运行。如果这个都失败说明是环境问题JDK 版本、沙箱路径、权限如果成功那问题一定出在他自己的实验包里。这个习惯让我避开了 90% 的“玄学问题”。5.3 部署视角单机 Docker 化验证Linux 环境很多学生本地 IDEA 跑得好好的一打包成 JAR 部署到服务器就崩。根源往往是路径、编码、时区。我强制要求所有答辩项目必须提供一份docker-compose.yml能在 Ubuntu 22.04 上 3 分钟内拉起完整环境。内容极简version: 3.8 services: webos: image: openjdk:17-jre-slim volumes: - ./target/webos-1.0.jar:/app.jar - ./resources:/opt/webos/res ports: - 8080:8080 environment: - SPRING_PROFILES_ACTIVEprod - SERVER_PORT8080 - TZAsia/Shanghai command: java -Dfile.encodingUTF-8 -jar /app.jar然后写一行验证脚本verify.sh#!/bin/bash curl -s http://localhost:8080/login | grep 用户名 echo ✅ 登录页可达 || echo ❌ 登录页失败 curl -s -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username:teacher,password:123456,captcha:1234} | grep token echo ✅ 登录API通 || echo ❌ 登录API失败答辩前夜学生必须在我面前跑通这 5 行命令。从docker-compose up -d到./verify.sh全绿才算部署过关。从那以后我每次带毕设都强制学生在README.md里写清三件事① 本地启动命令mvn spring-boot:run② 生产打包命令mvn clean package③ Docker 验证步骤含 verify.sh。不是为了炫技是让答辩变成一场可预期的交付而不是一场赌运气的冒险。希望帮到你。本文还有配套的精品资源点击获取