ARTICLE DETAIL

资讯详情

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

基于Spring Boot+Vue的编程训练系统:从部署到判题链路全解析

基于Spring Boot+Vue的编程训练系统:从部署到判题链路全解析 简介基于SpringBoot和Vue前后端分离技术实现的编程训练系统是一款完整的Java毕业设计项目适合计算机专业学生、全栈开发初学者用于毕业设计选题参考或期末项目实践。资源包共含758个文件总体积47.62MB内有131个Java后端源码文件、38个Vue前端组件、151个JavaScript逻辑脚本、162个SVG图标以及CSS样式、GIF图片、HTML页面等静态资源并且附带数据库SQL文件、使用文档和演示视频目录按前后端分离方式组织业务模块、公共组件、配置与静态资源分区清楚便于导入开发工具后快速修改和部署。项目得到过导师认可毕业答辩评审分达到97分在Windows 10/11环境调试通过附有安装、运行批处理脚本和部署说明基本做到下载后按文档操作即可运行。已有150人浏览学习适合用来训练前后端联调能力也是毕业设计或期末作业的高分完整案例。1. 拿到这个编程训练系统zip之后先别看代码先看它解决了什么问题每年毕业季都会有一大批「基于Spring Boot Vue的编程训练系统」出现在各种资源包里。这个标题里真正值钱的不只是Spring Boot和Vue的壳而是「训练」两个字背后的业务闭环题库管理、在线做题、代码提交、自动判题、训练记录统计。你可以把它理解成一个简化版的OJOnline Judge加一个后台管理端前端用Vue做页面交互后端用Spring Boot暴露接口MySQL存业务数据。这类项目最大的价值是「麻雀虽小五脏俱全」它把Web开发最常见的增删改查、文件上传/下载、权限拦截、异步任务全串起来了很适合作为Java方向的毕业设计——能演示、能答辩、能二次开发。这篇笔记会按我实际接手这类项目时的顺序来写先怎么把系统跑起来再讲核心判题链路怎么调最后是坑在哪。2. 为什么这个毕设选型是Spring Boot Vue核心模块与判题链路2.1 前后端分离的模块划分先画清楚边界编程训练系统通常可以拆成两个端管理端和训练端。管理端面向教师或管理员负责维护题目、分类、用例和用户训练端面向学生负责浏览题目、提交代码、查看判题结果和训练记录。Spring Boot Vue的分工很直接——后端只提供REST API不关心页面渲染前端通过axios调用接口用Vue Router控制页面跳转。我接这类项目时第一步永远是看后端是否有spring-boot-starter-web、mybatis-plus或mybatis、mysql-connector-java、jjwt或spring-security这几类依赖。它们的组合基本决定了系统的能力边界有没有权限控制、接口风格统一不统一、数据库操作是写XML还是注解。前端则看vue-cli还是vite看src/views下有哪几个页面。注意这个选型在毕设答辩里是个加分话题为什么不用JSP、为什么不用单独Servlet答案就是前后端分离之后接口可以被多个端复用判题服务和业务服务也能拆开部署。模块划分上我见过做得比较完整的项目会拆出五个核心模块用户模块登录注册、角色区分、题库模块题目CRUD、分类筛选、用例管理、训练模块做题、提交代码、查看历史、判题模块编译运行、结果比对、状态回写、统计模块提交次数、通过率、训练曲线。如果你的zip里五个模块都齐了那这个系统做答辩完全够用如果缺统计模块后面我会讲怎么用最少的代码补上。2.2 自动判题的本质不是「检查代码对错」是「比对输出」很多第一次接触这类系统的同学会误以为判题是拿字符串匹配去套答案或者用正则检查代码里有没有关键函数。真正的OJ式判题是黑盒测试你把用户提交的源代码写到临时文件调用JDK编译成class再拿测试用例的输入数据去运行这个程序把它的标准输出和预期输出做比对一致就是通过Accepted不一致就是答案错误Wrong Answer编译失败就是编译错误Compile Error运行超时就是超时Time Limit Exceeded。这个链路里后端的代码模型一般是这样的提交记录表里存状态字段用0/1/2/3/4表示排队中、判题中、编译错误、通过、不通过。判题逻辑放在独立的Service里发布提交时先置为「排队中」判题开始再置为「判题中」最终回写结果。这里有一个关键设计判题必须与请求解耦。如果判题在主线程里同步做前端会长时间卡住而且遇到死循环代码会直接把后端拖崩。常见做法是用线程池或者简单的异步队列CompletableFuture.runAsync提交接口先返回「已接收」判题完成后前端轮询结果。2.3 数据模型设计五张核心表和它们的关联关系数据库部分这类系统的核心表我不建议少于五张sys_user用户表字段至少要有id、username、passwordBCrypt加密后的密文、roleadmin/student、create_time。question题目表字段有id、title、description、difficulty、category_id、input_description、output_description、sample_input、sample_output。test_case测试用例表字段有id、question_id、input_dataTEXT、expected_outputTEXT、sort_order。一张题对应多条用例判题时按sort_order依次跑。submission提交记录表字段有id、user_id、question_id、codeTEXT、language、status、error_message、run_time、memory_used、create_time。这张表是系统的「流水账」统计模块全靠它。training_plan可选训练计划表字段有id、user_id、plan_name、question_ids可以用逗号分隔存储也可以建中间表、finish_status。表关联很直观submission关联user和questiontest_case关联question。如果你拿到的zip里数据库脚本不仅建了表还插入了管理员账号、测试题和用例那会省很多事如果只有空表你需要自己准备一份测试题数据——哪怕是「两数之和」这种入门题都得把输入输出用例补齐否则判题演示就是空转。提示导入数据库脚本之后第一件事是检查sys_user表里管理员账号的密码是不是BCrypt密文。很多项目默认密码是123456但直接用123456去登录会失败——因为后端登录接口一定是拿BCrypt去比对不是明文比对。如果密文对不上用https://bcrypt-generator.com重新生成一份塞进去。3. 把zip变成能跑的系统环境、数据库与前后端启动全流程3.1 环境版本核对先确认三件套再动手这类zip项目最怕的就是「代码没坑环境有坑」。我踩过的版本雷区主要是这三个JDK版本、MySQL版本、Node版本。Spring Boot 2.x通常要求JDK 8或11Spring Boot 3.x要求JDK 17。如果项目里pom.xml的spring-boot-starter-parent版本是2.7.x你用JDK 17大概率也能跑但用JDK 8最稳如果版本是3.xJDK 8必挂——启动直接报UnsupportedClassVersionError。MySQL这边老项目多用5.7新项目多用8.0。如果数据库脚本里有engineInnoDB default charsetutf8mb4这种写法5.7和8.0都能兼容。真正会翻车的是连接驱动pom.xml里如果是mysql-connector-java8.0以前坐标但连的是MySQL 8大概率报Public Key Retrieval is not allowed。我自己习惯用8.0.33版本驱动连接串写成下面这种spring.datasource.urljdbc:mysql://localhost:3306/training_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue spring.datasource.usernameroot spring.datasource.password你的密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver注意allowPublicKeyRetrievaltrue必须加MySQL 8的驱动默认不允许通过非SSL通道获取公钥不加必报错。连接池这块如果项目用的是HikariCPSpring Boot默认检查一下maximum-pool-size默认10就够不需要调大如果用Druid记得监控页面/druid在答辩时别开在生产配置里。前端环境我建议用Node 14或16不要一上来上Node 20。老项目多半是vue-cli脚手架依赖里可能有node-sass这玩意儿在Node 16以上版本大概率编译失败换成sassdart-sass实现或者降Node版本都能解决。先跑一条命令看当前环境java -version mvn -version node -v npm -v mysql --version3.2 数据库初始化导入SQL脚本与手工补数据数据库初始化我一般用命令行而不是Navicat可视化导入因为SQL文件可能体积大可视化工具容易中途卡住。先登录MySQL再执行sourcemysql -uroot -p source /你的解压路径/sql/training_system.sql; show databases; use training_system; show tables;脚本导入后务必数一下表数量和项目文档里写的表清单对一下。如果发现缺表不要急着改代码先检查SQL脚本里有没有drop table if exists有些脚本是「先删旧表再建新表」执行顺序错乱会导致外键关联失败。如果脚本里没有管理员账号你需要手动插一条。密码字段要存BCrypt密文我一般先在项目任意一个测试类里写个BCryptPasswordEncoder生成密文再执行SQL插入INSERT INTO sys_user (username, password, role, create_time) VALUES (admin, $2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE /ByXxH1VYy5S, admin, NOW());注意上面密文是我随便写的示意直接复制大概率登录失败。正确做法是从你自己项目的Spring容器里生成或者用在线BCrypt生成器。3.3 启动后端先看端口和Swagger再切页面后端启动前确认application.yml里的端口没被占。Spring Boot默认8080很多同学电脑上会同时跑着其他Java服务。启动方式两种一种是在IDEA里直接跑主类另一种是打包成jar再跑。我推荐用jar方式做最终验收因为它和演示环境最接近mvn clean package -DskipTests java -jar target/training-system-0.0.1-SNAPSHOT.jar启动日志里看到Started Application in x.xxx seconds说明后端起来了。这时候别急着开前端先验证接口通不通curl http://localhost:8080/api/auth/login -X POST -H Content-Type: application/json -d {\username\:\admin\,\password\:\123456\}如果返回JSON里有token字段后端基本没问题。如果项目集成了Swaggerspringfox或springdoc直接打开http://localhost:8080/swagger-ui/index.html在页面上把登录、题目列表、提交记录这几个接口都点一遍比对着前端页面排查快得多。顺手把每个接口的请求参数记下来后面前端联调报错时对照。3.4 启动前端npm install的坑和代理配置前端启动最磨人的是npm install。我建议先把npm镜像切到国内源再安装npm config set registry https://registry.npmmirror.com cd frontend npm install如果项目依赖里有node-sass这一步大概率报node-gyp相关错误。解决方式有一个快速方案把package.json里的node-sass删掉换成sass然后重新npm install。前端代码本身引用node-sass的地方一般只有.scss文件的编译入口换成sass后语法几乎不用改。依赖装完后启动npm run serveVue CLI默认端口8080和后端冲突的话启动时会自动跳到8081。这里最关键的配置是后端地址代理。查看vue.config.js或config/index.js里有没有这一段没有就得补module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, ws: true } } } };这段配置的意思是前端页面里所有以/api开头的axios请求都会转发到后端的8080端口。没有代理就会出现典型的跨域问题——浏览器报Access to XMLHttpRequest has been blocked by CORS policy。有了代理前端代码里请求路径就写/api/auth/login而不是http://localhost:8080/api/auth/login。打开浏览器输入http://localhost:8081能弹出登录页用管理员账号登进去系统就跑通了。4. 实现题库与自动判题分页查询、代码提交与评测回写4.1 题库分页与条件查询MyBatis Plus一行搞定题库页面是训练系统最基础的功能核心就是一个带条件的分页查询按标题模糊搜索、按难度筛选、按分类筛选。用MyBatis Plus的时候我习惯直接用LambdaQueryWrapper比手写XML简单得多Service public class QuestionService { Autowired private QuestionMapper questionMapper; public IPageQuestion pageQuestion(int pageNum, int pageSize, String keyword, Integer difficulty) { PageQuestion page new Page(pageNum, pageSize); LambdaQueryWrapperQuestion wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Question::getTitle, keyword) .eq(difficulty ! null, Question::getDifficulty, difficulty) .orderByDesc(Question::getCreateTime); return questionMapper.selectPage(page, wrapper); } }这段代码的要点是like和eq前面的条件参数是布尔值只有条件成立时才拼接对应SQL这样前端传不传参数都不用写多个if。分页插件记得在Config类里配好Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }如果不配这个拦截器selectPage返回的分页对象里records是全表数据total也是0——这个坑我见过不止一次现象是「接口返回了但前端表格只有一页且翻不动」。前端那侧Vue页面里用el-pagination组件current-change事件触发时重新请求接口把pageNum和pageSize作为query参数传给后端fetchQuestionList() { this.loading true; this.$axios.get(/api/question/page, { params: { pageNum: this.currentPage, pageSize: this.pageSize, keyword: this.keyword, difficulty: this.difficulty } }).then(res { this.questionList res.data.records; this.total res.data.total; this.loading false; }); }Vue Router那边如果从题目列表跳到题目详情一般用this.$router.push({ name: QuestionDetail, query: { id: row.id } })详情页再通过this.$route.query.id接收。用query传参刷新页面后参数还在用params刷新就丢了这个区别在答辩演示时很容易被问。4.2 代码提交与判题ProcessBuilder做编译运行判题核心是我觉得这个项目最有技术含量、也是答辩时最值得讲的部分。先看提交接口的设计前端把questionId和codePOST到后端后端先插入一条submission记录状态置为0排队中然后用线程池异步执行判题。接口本身立刻返回提交记录的id前端再每隔1-2秒轮询一次状态。判题Service里最关键的代码是调用系统的javac和java命令。这里我坚持用ProcessBuilder而不是Runtime.getRuntime().exec()因为ProcessBuilder可以方便地把错误输出和标准输出分开也不会因为命令参数里带特殊字符而翻车public JudgeResult judge(Submission submission) throws Exception { // 1. 把用户提交的代码写到临时目录 Path tmpDir Files.createTempDirectory(judge_ submission.getId()); Path sourceFile tmpDir.resolve(Main.java); Files.write(sourceFile, submission.getCode().getBytes(StandardCharsets.UTF_8)); // 2. 编译 ProcessBuilder compilePb new ProcessBuilder(javac, -encoding, UTF-8, sourceFile.toString()); compilePb.directory(tmpDir.toFile()); Process compileProcess compilePb.start(); boolean compileFinished compileProcess.waitFor(5, TimeUnit.SECONDS); if (!compileFinished) { compileProcess.destroyForcibly(); return new JudgeResult(Status.COMPILE_ERROR, 编译超时); } if (compileProcess.exitValue() ! 0) { String errorMsg readStream(compileProcess.getErrorStream()); return new JudgeResult(Status.COMPILE_ERROR, errorMsg); } // 3. 读取测试用例逐个运行比对 ListTestCase testCases testCaseMapper.selectList( new LambdaQueryWrapperTestCase().eq(TestCase::getQuestionId, submission.getQuestionId())); for (TestCase tc : testCases) { ProcessBuilder runPb new ProcessBuilder(java, -cp, tmpDir.toString(), Main); runPb.directory(tmpDir.toFile()); Process runProcess runPb.start(); // 输入测试数据到程序标准输入 try (OutputStream os runProcess.getOutputStream()) { os.write(tc.getInputData().getBytes(StandardCharsets.UTF_8)); } boolean runFinished runProcess.waitFor(3, TimeUnit.SECONDS); if (!runFinished) { runProcess.destroyForcibly(); return new JudgeResult(Status.TIME_LIMIT_EXCEEDED, 运行超时); } String actualOutput readStream(runProcess.getInputStream()).trim(); String expectedOutput tc.getExpectedOutput().trim(); if (!actualOutput.equals(expectedOutput)) { return new JudgeResult(Status.WRONG_ANSWER, 预期: expectedOutput 实际: actualOutput); } } return new JudgeResult(Status.ACCEPTED, ); }这段代码有三个细节要说明。第一waitFor必须带超时参数javac编译卡死、用户代码死循环都要靠它兜底不带超时等于把系统扔给用户代码摆布。第二测试用例的输入是用getOutputStream()写入的这模拟的是用户在终端里键盘输入的效果——有的程序用Scanner读输入有的用BufferedReader统一从标准输入喂数据最兼容。第三tmpDir用Files.createTempDirectory创建判题结束后要记得递归删除临时目录否则跑一次考试就堆积几百个临时文件。4.3 状态回写与前端轮询别让用户盯着空白页干等判题完成后把结果回写到submission表包括状态码、错误信息、运行耗时和内存占用。前端页面在用户点击「提交」之后立即启动轮询submitCode() { this.$axios.post(/api/submission, { questionId: this.questionId, code: this.editorContent }).then(res { this.submissionId res.data.id; this.timer setInterval(this.fetchJudgeResult, 2000); }); }, fetchJudgeResult() { this.$axios.get(/api/submission/${this.submissionId}).then(res { const status res.data.status; if (status ! 0 status ! 1) { clearInterval(this.timer); this.judgeStatus status; } }); }轮询间隔我一般定2秒不要用1秒——判题本身需要编译运行多个用例1秒轮询会放大服务器压力而且后端线程池处理不过来时反而显得系统很卡。状态码0和1是排队中和判题中前端显示「队列中…」和「判题中…」的动效遇到2编译错误就把后端的错误信息直接展示在代码编辑器下方遇到3/4则展示通过或未通过。这块答辩时容易被追问如果判题线程池满了怎么办答案可以在配置里体现Bean定义线程池时设corePoolSize4、maxPoolSize8、queueCapacity100超出队列容量的提交直接返回「系统繁忙请稍后重试」。这个设计比「无限排队」诚实得多也更容易在答辩时自圆其说。5. 本地跑通与演示答辩的避坑清单5个高频翻车点5.1 坑一后端起不来报Public Key Retrieval is not allowed现象启动Spring Boot时日志抛java.sql.SQLNonTransientConnectionException提示Public Key Retrieval is not allowed。原因MySQL 8默认使用caching_sha2_password认证插件且驱动不允许非SSL连接下自动获取公钥。解决在数据库连接URL后面追加参数如上文写到的allowPublicKeyRetrievaltrueuseSSLfalse。如果你用的是Druid连接池还要确认druid的filters配置里没有config否则连接串的参数会被覆盖掉。这个坑几乎人人会遇到答辩前一定自己先启动一次不要在评委面前表演「现场排错」。5.2 坑二前端页面白屏控制台报Uncaught SyntaxError现象npm run serve之后浏览器打开localhost:8081页面空白F12控制台报Uncaught SyntaxError: Unexpected token 。原因前端静态资源路径写的是绝对路径/js/app.js而后端没有对应的静态资源映射或者vue.config.js里publicPath没配置。这个报错常见于直接把dist目录扔给后端托管的情况开发模式跑serve时反而少见。解决如果项目是前后端分开部署开发模式用代理如果要把前端打包后塞进Spring Boot的static目录做单包部署就在vue.config.js里设publicPath: ./同时后端加一个WebMvcConfigurer把非/api的路径都转发到index.htmlConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/).setViewName(forward:/index.html); } }5.3 坑三刷新页面就404路由历史模式在作怪现象系统登录页能打开但进入/question/detail页面后按一下F5浏览器直接白屏地址栏显示Cannot GET /question/detail。原因Vue Router开了HTML5历史模式history: createWebHistory()刷新时浏览器拿着/question/detail这个真实路径去请求后端而后端没有这个路由。解决要么把路由模式改成哈希模式createWebHashHistory()URL变成/#/question/detail刷新永远不会404要么在后端加一个forwardToIndex的过滤器把非/api开头的GET请求全部转发到index.html。我建议毕设项目直接用哈希模式少一个配置文件少一个坑答辩时没人会因为你用哈希模式扣分。5.4 坑四明明登录成功了提交代码却报401现象登录接口返回了token前端也把token存在localStorage里了但点「提交代码」时后端返回401。原因后端接口有JWT拦截器校验请求头里的Authorization字段前端axios请求拦截器没有把token加进header。看后端日志如果在OncePerRequestFilter里打印了「token为空」或「token无效」基本就是这个问题。解决在axios封装文件里加请求拦截器axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; });还有个隐藏坑JWT拦截器把浏览器预检的OPTIONS请求也拦截了前端请求带Content-Type: application/json时会先发OPTIONS预检后端拦截器直接返回401导致真正的POST请求发不出去。解决过滤器里加一行放行判断if (OPTIONS.equals(request.getMethod())) { chain.doFilter(request, response); return; }答辩时能主动说出这个细节很加分。5.5 坑五判题一直显示「队列中」后端日志有线程卡死现象提交代码后前端一直轮询到状态1判题中就不再变化后端日志没有任何异常但CPU占用率异常高。原因判题进程java Main死循环不退出而waitFor超时参数没生效——有些JDK版本里waitFor(long, TimeUnit)在子进程有子进程时会失效或者编译出来的class在System.exit之前一直占着CPU。解决在waitFor超时后除了destroyForcibly()还要把进程组的子进程一起杀掉Linux下可以用kill -9 进程组更省事的方案是在runPb里加redirectErrorStream(true)避免子进程写满错误流的缓冲区导致阻塞。另外一个常见做法是给java命令加-Xmx256m限制内存防止恶意代码把服务器内存撑爆。6. 答辩前的最后一小时把演示视频变成你自己的验收脚本很多同学拿到这套zip后会先看演示视频跟着视频点一遍界面就觉得系统没问题了。但我建议反过来先把演示视频里出现的每个操作点抄出来变成一张验收清单然后照着清单在你自己跑起来的系统上逐条操作。原因很简单——演示视频大概率是原作者的运行环境你的环境不一样数据库数据不一样版本也会不一样视频里能跑通不代表你本地能跑通更不代表答辩时你点一遍也能跑通。我自己的验收脚本一般是十二条启动后端、启动前端、用管理员登录、创建一个新题目、编辑题目并添加两个测试用例、用学生账号登录、进入题目列表筛选、打开一道题并提交正确代码、等待判题结果展示「通过」、再提交一段错误代码、查看提交历史、查看统计页面的通过率曲线。其中「正确代码」和「错误代码」要提前准备好复制粘贴到编辑器里就行不要现场手打。最后一个加分技巧是给自己准备「一句话功能说明」比如被问到「判题系统怎么防止用户代码跑死服务器」回答思路是我用线程池隔离了判题任务每个判题进程独立运行并设置三秒超时超时强制销毁进程。这句话既讲清了实现又带出了技术深度。我做这类demo项目的习惯是拿到任何一份源码都先破坏性地改一次配置再跑通只有亲手把坑踩过一遍答辩被追问时心里才真的不慌——因为被你踩过的坑评委大概率也踩过你多答出一步他就知道你是真做过而不是只看了演示视频。希望这篇笔记能帮你把这个编程训练系统从「zip里的文件夹」变成「答辩台上讲得清的东西」。本文还有配套的精品资源点击获取
返回列表