☰
少儿编程在线判题系统:SSM+Flask混合架构实战
2026/10/10 7:35:49 网站建设 项目流程

最近接了个很有意思的项目,帮客户做少儿编程在线培训系统。这个题目的需求其实很典型:培训机构有课程、有老师、有学生,但线下教学解决不了课后练习的问题。孩子上完课回家,想练几道编程题,没有平台;老师想布置作业,又不想一份份手工改代码。于是客户最初给我提的需求就三个关键词:课程能看、题能在线做、作业能自动判。听起来是个中规中矩的在线教育系统,但真正动手后发现,课程管理、用户管理这些常规CRUD不算什么,"在线编程自动判题"才是这个系统的灵魂。最终我给这个项目的技术方案是:Java+SSM做主业务,Python Flask做评测服务,两者通过HTTP接口协同工作。这篇文章就把从设计到落地的完整过程复盘一遍,给正在准备类似毕设或者想了解在线判题实现的同学做个参考。

1. 少儿编程平台需求拆解:三个角色和一套闭环

做这类系统,第一步不是写代码,而是把需求理清楚。少儿编程在线培训系统跟普通的在线教育平台不太一样,它的使用者是一群低龄用户,操作要足够简单,反馈要足够直观,激励机制还要有趣。围绕这个产品定位,我先把系统的用户分成三类,每一类的痛点都列了出来。

1.1 三类用户画像与核心痛点

学生端是最核心的使用者。少儿编程的学生年龄大概在7到15岁,以Python和图形化编程(类似Scratch)为主。他们的痛点是:课堂上听懂了一题,回家没人讲,自己写代码写到一半卡住,不知道错在哪。所以学生端必须有在线编辑器,写完就能运行,运行后最好能直接告诉他对不对,而不是甩给他一大段报错日志。

教师端的痛点是批改效率。一个班20个学生,一次作业20份代码,如果手工去跑每份代码、对照每道题的结果,每天光批改就要两三个小时。教师端需要的是:布置作业的时候指定题目,学生提交后系统自动判分,教师只关注谁没交、谁的正确率低。

管理员端的痛点是资源管理。课程、教师、学生、分类、公告,这些东西如果还在用Excel登记,数据就完全没法沉淀。管理员端需要一套后台,把课程上架、用户管理、数据统计都可视化。把这些痛点翻译成功能,系统的边界就清楚了。

1.2 "学、练、评"闭环的业务主线

整个业务主线和线下培训机构的习惯保持一致,我把它概括成三个字:学、练、评。

学:学生浏览课程中心,按分类(Scratch入门、Python基础、算法启蒙)选择课程,进入课程详情后按章节学习图文和视频内容。学完一个章节,系统会提示"去练习"。

练:练习就是做编程题。题库里的题目分难度,每道题有题目描述、输入输出样例、初始模板代码。学生打开在线编辑器,写代码,点运行,系统返回测试用例通过情况。做对了,获得积分;做错了,会看到是第几个用例没过,以及期望输出和实际输出的差异。

评:这是教学环节的闭环。教师布置作业,可以绑定一道或几道题目,学生提交后系统自动评出分数。教师端能看到班级整体的正确率统计,知道哪些知识点需要加强讲解,比手工批改高效得多。

这个闭环设计好在哪儿?好在它贴近线下培训机构的真实业务流程,老师布置的东西在家里能完成,完成后系统自动反馈,学生有即时成就感。对于做毕设来说,这样的主线也方便写成论文里的业务流程图和用例图。

2. 技术选型定调:Java+SSM做业务,Flask做评测

技术选型是这个项目最值得说道的地方,因为它不是一个单纯的Java项目,也不是一个单纯的Python项目,而是两条技术线各自负责自己擅长的事。

2.1 SSM这套经典组合为什么还不过时

主业务用Java+SSM(Spring+SpringMVC+MyBatis),是我一开始就确定的方向。原因很实在:这个项目有大量标准的CRUD操作,用户管理、课程管理、作业管理、公告管理,SSM的分层结构处理这些正合适。Spring负责对象管理和事务,SpringMVC负责接收请求和返回视图,MyBatis负责数据持久化,每一层职责都很清楚。

另外,对于毕业设计场景,SSM还有一个隐藏优势:参考资料极多。你遇到任何一个配置报错,比如mapper找不到、依赖冲突、扫描包没扫到,搜索一下基本都能找到现成的解决方案。相比那些特别新的框架,SSM虽然"老",但稳定、够用、容易讲清楚。论文里写架构设计时,SSM的分层模型也特别好画。

当然用SSM也有要注意的地方。比如Spring版本和JDK版本的匹配,我用的是Spring 4.3.x + JDK 1.8,这套组合很稳定。如果用Spring 5.x就要注意JDK版本差异,避免踩坑。MyBatis的mapper XML和接口绑定要仔细检查namespace,项目里曾经因为Copy了一个mapper文件忘记改namespace,导致启动时报错,这个问题我后面还会提到。

2.2 在线评测为什么交给Python Flask

真正让我决定混搭技术栈的,是"在线评测"这个模块。

如果纯用Java写判题服务,倒也能做。用Runtime.exec()去执行python命令。但这里有个现实的麻烦:代码要跑在受限环境里,要控制CPU时间、内存,还要处理可能的恶意代码和死循环。Java写这个逻辑不是不行,但开发效率低,调试起来也麻烦。Python的subprocess和resource模块做进程控制和资源管理特别顺手,Flask写一个HTTP接口也就几十行代码。于是我把评测服务独立成了一个Python Flask应用,Java主站通过HTTP调用它,两边完全解耦。

这种"Java做业务、Python做计算"的混合架构,在真实企业里也很常见。比如推荐系统、文件处理、算法服务,都适合单独拆成微服务。做进毕设里,等于给答辩老师展示了一个明确的信号:我不仅会Java CRUD,我还懂服务拆分,知道什么任务该用什么工具。技术栈的丰富度自然而然就有了,不用刻意堆砌。

2.3 两个服务之间如何沟通

主站和评测服务的通信非常简单,就是HTTP请求。Spring的Service层发起POST请求到Flask,把学生的代码、题目对应的测试用例、语言类型作为JSON参数传过去,Flask执行完成后返回JSON结果。

这里要强调一个设计原则:评测服务本身不知道"题目"这种业务概念,它只负责"给一段代码和一批输入输出对,返回运行结果"。题目、学生、提交记录这些数据,全都留在Java这一侧。这样通信的协议就特别小,传输的内容只有必要的代码和用例,后期Flask那边独立升级、独立重启,都不会影响主站的用户在线做题。

3. 核心表结构设计:课程、编程题、提交记录怎么存

数据库设计决定了系统的边界和后续开发的顺畅程度。这个系统的表不少,但真正有设计讲究的是课程相关的表、编程题相关的表、以及提交记录表。我挑核心的几张来讲。

3.1 用户、课程、章节这三张基础表

用户表user,字段包括id、username、password、nickname、avatar、role、age、points、status、create_time。角色用int类型,0是管理员,1是教师,2是学生,不要用字符串存角色名,查询和扩展都麻烦。status字段用来处理逻辑删除和账号禁用,默认1表示正常,0表示禁用。很多毕设里直接物理删除用户,我强烈建议保留status字段,因为学生账号要关联学习记录和提交记录,物理删除会破坏数据完整性。

课程表course,字段有id、title、category_id、cover、summary、difficulty、teacher_id、create_time。difficulty用0、1、2表示入门、初级、进阶。category_id关联分类表,分类表很简单,就id和name两个字段,我一并建了。

章节表chapter,字段id、course_id、title、content_type、content_text、video_url、sort_order。content_type用来区分图文还是视频。学生学完一个章节后,往study_record表里插一条记录,方便课程详情页显示"已学习"进度。

这三张表是课程学习主线的基础,设计上没有什么花哨的,关键是字段别省,尤其是sort_order这种排序字段,一开始没有的话,后面调整章节顺序会非常痛苦。

3.2 编程题和测试用例表:评测系统的数据基石

编程题表exercise,字段包括id、title、description、input_desc、output_desc、sample_input、sample_output、template_code、language_type、difficulty、time_limit、memory_limit、create_time。sample_input和sample_output是给学生在题目页看到的例子,和内部测试用例分开存储,这样页面上展示的样例和评测用的用例可以不同,避免学生直接猜用例。

测试用例表test_case,字段id、exercise_id、input_data、expect_output、sort_order。一道题对应多条用例,评测时把这些用例组装成列表传给Flask。input_data和expect_output建议用TEXT类型,因为用例可能很长,尤其算法题的大数据测试。

设计这组表时最需要注意的点是:测试用例一定要分公开和隐藏。前端展示的样例是公开的,评测用的完整用例集是隐藏的。学生提交代码时,前端不会把测试用例发到浏览器里,而是由Java后端从test_case表查出全部用例,再交给评测服务。这样才能保证测评的公平性。

下面给出测试用例和提交记录两张表的核心建表SQL,方便直接复用:

CREATE TABLE test_case ( id INT PRIMARY KEY AUTO_INCREMENT, exercise_id INT NOT NULL, input_data TEXT, expect_output TEXT, sort_order INT DEFAULT 0, KEY idx_exercise (exercise_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE submission ( id INT PRIMARY KEY AUTO_INCREMENT, exercise_id INT NOT NULL, student_id INT NOT NULL, submit_code LONGTEXT, status TINYINT DEFAULT 0 COMMENT '0排队 1通过 2失败 3超时 4运行错误', judge_result TEXT, run_time DECIMAL(6,3), memory_use INT, score INT DEFAULT 0, create_time DATETIME, KEY idx_student_exercise (student_id, exercise_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.3 提交记录表:评测结果与状态机设计

提交记录表submission是这个系统里数据增长最快、也最需要好好设计的表。字段包括id、exercise_id、student_id、submit_code、status、judge_result、run_time、memory_use、score、create_time。

status字段是整个评测链路的核心,我用int类型定义了一套状态机:

  • 0:排队中,Java端刚接收到请求,还没开始评测
  • 1:通过,所有测试用例都跑对了
  • 2:失败,部分或者全部用例没通过
  • 3:超时,代码运行时间超出题目限制
  • 4:运行错误,程序抛出异常或非法退出

judge_result字段用TEXT类型存评测详情,例如"通过用例 3/5,第2个用例期望输出5,实际输出7",这个字符串学生端要直接展示,所以写的时候就要把信息组织成人话,不要直接抛Python异常堆栈。

表设计完成后,建议给submission表加一个student_id和exercise_id的联合索引。因为这个系统的高频查询是"某个学生对某道题的最近提交记录",没有联合索引的话,数据量一上来查询会明显变慢。项目上线后提交记录从几百条涨到几千条时,查询速度肉眼可见地下降,后来加了索引问题才消失。

4. 在线编程评测链路:从CodeMirror到沙箱运行

在线编程评测是整个系统的灵魂,也是我最想详细讲的部分。它的链路很长:前端编辑器、Java提交接口、异步调度、Flask评测服务、结果回写、前端轮询展示,每一个环节都有不少细节。

4.1 前端编辑器:CodeMirror与题目页交互

编辑器我选的是CodeMirror,理由简单:比Monaco轻,加载速度快,少儿编程的代码量不大,CodeMirror足够用。页面引入CodeMirror后,初始化时设置语言模式,Python题就加载python模式,Java题就加载clike模式。还要设置tab为4个空格,因为Python对缩进敏感,很多孩子初学不知道tab和空格的区别,直接在编辑器里统一处理成空格能省不少麻烦。

题目页的交互流程是这样的:学生打开一道题,看到题目描述、样例输入输出、右侧是编辑器,编辑器里已经填好了template_code模板代码。学生写完后点"运行",前端先做简单的空代码校验,然后把code、exerciseId、languageType打包成JSON,POST到Java后端的提交接口。

这里有一个容易被忽视的问题:学生在编辑器里输入的代码可能包含各种字符,JSON传输时一定要做转义处理。我用的方式是前端用JSON.stringify,后端用Fastjson解析,两边约定好编码是UTF-8,不要用默认编码去读请求体,否则中文注释会变成乱码。

4.2 Java端提交接口与异步调度

Java端收到提交请求后,要做的事情包括:保存submission记录、调用评测服务、更新结果。在线评测最忌讳的是让用户长时间等一个同步请求,因为评测一个Python程序可能需要一两秒甚至更久,正常HTTP请求等着会超时。我的做法是异步化:

首先,controller接收请求后,立刻创建一个submission记录,status置为0(排队中),然后把submissionId返回给前端。前端拿到submissionId后开始轮询结果,轮询间隔设1秒。

真正的评测逻辑放在一个独立的Service方法里,用Spring的@Async注解异步执行。方法内部先从数据库查出题目的所有测试用例,组装成评测请求对象,通过RestTemplate发送到Flask服务。Flask返回结果后,解析JSON,更新submission记录的状态和评测详情。

核心的调度代码大致是这样的:

@Async("judgeTaskExecutor") public void judgeAsync(Integer submissionId) { Submission submission = submissionMapper.selectById(submissionId); Exercise exercise = exerciseMapper.selectById(submission.getExerciseId()); List<TestCase> cases = testCaseMapper.selectByExerciseId(exercise.getId()); JudgeRequest req = new JudgeRequest(); req.setCode(submission.getSubmitCode()); req.setLanguage(submission.getLanguageType()); req.setTimeLimit(exercise.getTimeLimit()); req.setTestCases(cases.stream() .map(c -> new JudgeRequest.TestCase(c.getInputData(), c.getExpectOutput())) .collect(Collectors.toList())); String resultJson = restTemplate.postForObject("http://127.0.0.1:5001/api/judge", req, String.class); // 解析resultJson,更新submission的status、judgeResult、score }

这里有一个经验性的建议:异步线程池的配置要提前想好。最初没有配置线程池,Spring默认的SimpleAsyncTaskExecutor每个任务都会新建线程,并发一高系统资源就会告急。后来在spring-mvc.xml里配置了一个线程池,核心线程数5、最大10、队列容量50,评测任务走这个池子,系统稳定了很多。

4.3 Flask评测服务的运行链路与安全兜底

Flask端是评测链路的最终执行者。它接收的参数是:code、language、test_cases(数组,每个元素包含input_data和expect_output)。执行流程如下:

第一步,把代码写入一个临时文件。Python代码写入.py文件,Java代码写入.java文件然后编译。这里要注意文件路径不能写在项目根目录,要放在一个独立的临时目录,评测完成后清理掉。

第二步,对每组测试用例执行一次子进程。每次执行的关键是给进程设置资源限制。subprocess.run()支持timeout参数,设置成题目的time_limit字段。同时用resource模块限制CPU时间,这是防止死循环的杀手锏。resource.setrlimit(resource.RLIMIT_CPU, (2, 2))这样的调用,能让进程在超过2秒CPU时间后立即被杀掉。

第三步,捕获子进程的标准输出和标准错误。Python代码print的内容从stdout获取,报错信息从stderr获取。读的时候要指定encoding='utf-8',否则中文输出在Windows环境下很容易乱码。这部分代码被乱码坑过,后来统一用utf-8读写才稳定。

第四步,比对输出和期望输出。比对逻辑不能直接做字符串全等比较,因为数值精度、末尾换行、空格差异都可能造成误判。我采用的做法是按行比对,每行去掉首尾空白,空行跳过。这样既严格又宽容,符合OJ(Online Judge)系统常见做法。

第五步,把所有用例的评测结果汇总成一个JSON返回给Java端。JSON内容包括passedCount、totalCount、firstFailedIndex、expectedOutput、actualOutput、runTime。

安全兜底方面,除了超时限制,还应该处理危险模块问题。少儿编程平台理论上不该运行import os、import socket这类代码。我在执行前做了一道静态检查,用正则和AST解析扫描代码里的顶层import语句,命中危险模块列表就直接拒绝执行,返回"代码包含不允许使用的模块"的结果。这道检查不能防止全部攻击,但对付一群7到15岁的孩子已经足够了。如果平台真的对外开放,建议上Docker做真沙箱,这里面的安全差距要心里有数。

5. Flask判题服务实战:接口定义与判题逻辑

这一章单独把Flask服务拿出来讲,因为很多人第一次接触"Java调Python"这种架构,最容易在服务本身和联调环节出问题。

5.1 Flask接口定义与判题核心代码

Flask服务的核心是一个POST接口。接收结构固定的JSON:

{ "code": "print('hello')", "language": "python", "time_limit": 2, "test_cases": [ {"input_data": "", "expect_output": "hello"}, {"input_data": "5\n3", "expect_output": "8"} ] }

这里把timeLimit也从前端传过来了,因为不同题目允许的运行时间不同。判题函数run_code的核心代码如下:

def run_code(code, language, test_cases, time_limit): results = [] workdir = tempfile.mkdtemp() try: if language == 'python': src_path = os.path.join(workdir, 'main.py') with open(src_path, 'w', encoding='utf-8') as f: f.write(code) cmd = ['python3', src_path] elif language == 'java': src_path = os.path.join(workdir, 'Main.java') with open(src_path, 'w', encoding='utf-8') as f: f.write(code) subprocess.run(['javac', src_path], capture_output=True, timeout=10) cmd = ['java', '-cp', workdir, 'Main'] for i, case in enumerate(test_cases): try: proc = subprocess.run( cmd, input=case['input_data'], capture_output=True, text=True, encoding='utf-8', timeout=time_limit ) actual = normalize_output(proc.stdout) expected = normalize_output(case['expect_output']) results.append({ 'case_no': i + 1, 'passed': actual == expected, 'stdout': proc.stdout, 'stderr': proc.stderr }) except subprocess.TimeoutExpired: results.append({'case_no': i + 1, 'passed': False, 'error': 'timeout'}) finally: shutil.rmtree(workdir, ignore_errors=True) return results

这段代码的主干就是subprocess.run跑一次进程,会比对一次输出。每次执行都有独立的timeout保护。Java题会先编译再执行,编译本身也有超时限制,避免学生提交一个语法错乱的大文件把服务拖垮。finally块里清理临时目录,这个习惯很重要,不然服务器上的/tmp会积累大量垃圾文件。

5.2 判题结果的返回结构与前端展示

判题结果返回给Java后,Java端会解析并更新submission记录。约定的返回JSON结构是:

{ "status": 1, "passed_count": 2, "total_count": 3, "run_time": 0.32, "first_failed": { "case_no": 2, "expected": "8", "actual": "7", "stderr": "" } }

前端拿到这个结果后,展示逻辑就很简单:status为1显示"全部通过",为2显示"第2个用例未通过,期望输出8,实际输出7"。这里我在前端加了一个小细节:通过时用绿色提示条和"太棒了,全部通过"这种鼓励文案,没通过时提示条是橙色,文案换成"还差一点点,再看看有用例哪里没对"。

为什么要加这些文案?因为少儿用户对挫败感的耐受度很低。你直接给他看一排红色的FAIL,他可能不想再做了。但告诉他"还差一点点",他会更有动力去修改。这个细节是在做界面优化时临时加的,后来客户反馈说孩子做错的题,反而更愿意去改代码了。

5.3 联调时的三个经典翻车点

第一是跨域问题。前端页面跑在Tomcat的8080端口,Flask跑在5000端口,浏览器直接从JS发请求到Flask会有跨域限制。我的解决方式有两种:一是前端不发请求给Flask,而是统一发给Java后端,由Java后端转发,这也是最终采用的方式;二是如果确实需要前端直连Flask,那就在Flask里加上Flask-CORS扩展。推荐第一种,通信链路统一,也方便Java端做权限控制。

第二是超时配置。Java端的RestTemplate默认连接超时和读取超时都比较长,在评测链路里要主动设置,我配置成连接超时3秒、读取超时10秒。这样假设Flask明明已经卡死,Java端也不会无限等下去,submission记录会被更新为"评测服务异常,请稍后再试"。

第三是gunicorn的worker数量。本地调试用Flask自带的开发服务器没问题,但部署到服务器后一定要用gunicorn这类WSGI服务器启动。之前直接用Flask自带的开发服务部署,并发一高就卡死,后来改用gunicorn -w 4 -b 0.0.0.0:5001 app:app启动,4个worker进程同时处理评测请求,稳定多了。

6. 调试部署实录:本地跑通到服务器上线

这一章聊聊从源码到上线过程中的调试经历。这个系统涉及的中间件不少,Tomcat、MySQL、Gunicorn、Nginx,每个环节都有可能出现"第一次没想到"的问题。

6.1 本地开发环境的搭建与SSM配置细节

本地开发环境用的组合是:IDEA写Java,PyCharm写Flask,MySQL 5.7存数据,Tomcat 8.5跑主站。Maven管理Java依赖,Python依赖直接用requirements.txt安装。

SSM项目拿到手后,第一件事不是启动,而是检查配置文件。最容易出问题的三个点:

第一是Spring的组件扫描路径。controller、service、mapper这三层的包路径如果不一致,或者部分包漏扫,启动时不会报错,但注入会失败,运行时才报NullPointerException。经验是扫描路径写共同父包,让Spring自动扫描所有子包。

第二是MyBatis的mapper XML路径。在spring-mybatis.xml里配mapperLocations时,要用classpath:mapper/*.xml这种写法,并且确保resources目录下的XML和Mapper接口的namespace完全一致。我自己就犯过一次错误,把某个mapper文件的namespace写成别的接口,启动时MyBatis抛BindingException,排查了好久。

第三是数据库连接串。MySQL连接串一定要加上characterEncoding=utf8和useSSL=false,不然中文会乱码、SSL握手还会报警告。生产环境的连接串还要注意serverTimezone,避免日期查询差8小时。

6.2 主站打包和Flask服务部署

主站打包很简单,Maven命令mvn clean package打成war包,丢到Tomcat的webapps目录下,启动Tomcat后通过 http://服务器IP:8080/项目名/ 访问。

Flask服务部署到服务器时,建议按这个步骤走:

cd /opt/judge python3 -m venv venv source venv/bin/activate pip install flask gunicorn gunicorn -w 4 -b 127.0.0.1:5001 app:app --daemon

注意gunicorn监听的是127.0.0.1:5001,而不是0.0.0.0。因为评测服务不对外网暴露,只由Java后端通过内网地址调用。对外访问统一走Nginx。

Nginx配置里做了两件事:第一,把 /api/judge 开头的请求反向代理到127.0.0.1:5001的Flask服务;第二,其他所有请求走Tomcat的8080端口。这样一个域名就能同时访问两个服务,前端不必关心后端有多少个节点。

6.3 上线后遇到的三个实际问题

这个系统上线后,遇到过的真实问题,挑三个有代表性的说说:

第一个问题:静态资源404。把war包部署到Tomcat后,页面上的CSS和图片加载不出来。排查后发现是项目访问路径带了上下文名,资源路径写成了绝对路径/css/style.css,而部署后实际路径是/项目名/css/style.css。后来统一改成相对路径,问题解决。

第二个问题:数据库连接池耗尽。上线初期用户量不大没事,后来有班级集中提交作业时,Tomcat默认的连接池配置撑不住,后台日志报"无法获取连接"。我把连接池最大活跃连接数调大,并且给业务方法加上事务管理,确保查询完的连接及时归还,之后基本没有再出现。

第三个问题:评测服务偶发超时。查日志发现某些题目测试用例特别大,Flask执行时间超过了Java端RestTemplate设置的读取超时10秒。解决办法有两个:一是把Java端读取超时放宽到30秒;二是给题目设计timeLimit字段时,大数据用例多的题适当调低测试用例数量,从源头控制耗时。这两个方案同时执行后,超时情况基本消失。

写在最后

这个项目做完,我自己最大的感受是:少儿编程在线培训系统,表面上是教育平台,本质上是一个"业务系统+在线判题服务"的结合体。业务系统部分用Java+SSM做得很稳,而整个平台的灵魂——代码评测,交给了更擅长处理进程控制和资源管理的Python Flask,两边通过HTTP解除耦合,各自演进都很方便。后来我又在系统上做了不少优化,比如把判题结果缓存起来,同一个学生重复提交同一份代码不重复执行;再比如给通过用例的反馈加鼓励文案,这些细节对孩子用户来说比炫酷的技术重要得多。

如果你也准备拿这个题目做毕业设计,我给你的核心建议是:先把"在线评测"这条链路从CodeMirror到Flask完整跑通,再去补课程、作业这些外围模块。评测链路一通,系统的技术含金量就在那里了,剩下的功能都是在这个基础上加砖添瓦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询