简介:这是一套面向教育机构与开发学习者的试卷管理系统完整设计源码,覆盖试题管理、试卷生成与成绩分析等典型业务,既可作为高校毕业设计或课程实训的参考项目,也适合企业快速落地教育管理工具。包体共2000个文件、约66.07MB,包含1027个PNG图片、304个JavaScript文件、199个Java源文件、71个Vue文件、61个CSS文件、40个GIF以及SVG、JSON等资源;前端使用Vue+JavaScript+CSS构建交互界面,后端由Java承担核心业务逻辑,并引入微信小程序支持多终端访问。目前已有90人学习浏览。整个工程采用模块化拆分,目录清晰,可直接导入IDE运行;前后端分离的架构与丰富素材,既方便开发者从界面、交互到服务端逐层理解,也为后续功能扩展、二次开发提供了良好基础。
1. 多种语言出现在一套试卷管理源码里,不是炫技
一套试卷管理系统如果从出题、组卷、答题、批改到成绩发布全用同一门语言写,代码通常不难维护,难的是团队里已经有多个技术栈,或者某个环节天然依赖别的语言的生态。比如智能组卷要调机器学习库,移动监考端要跨 iOS/Android,管理后台又要快速迭代,这时“基于多种编程语言的试卷管理系统”就成了一种现实选择。它要解决的核心问题不是每种语言写一遍,而是让 Java、Python、Go、Dart 各管一段,共享同一份数据库和接口契约。下面是一套可直接改用的设计源码思路,重点给出工程目录、核心算法和联调验证方法。
2. 试卷管理系统先定边界:领域模型与编程语言选型
多语言项目最容易失控的地方是每门语言都私建一套领域模型。我一般会把业务收敛到四张核心表,所有语言都围绕这四张表协作,而不是各自为政。领域模型不统一,后面所有跨语言调用都会变成字段翻译,维护成本会指数增长。
2.1 抽出四张核心表,跨语言共享同一套领域语义
这四张表是题目表、试卷表、答题记录表、成绩结果表。题库由 Java 写入,批改由 Python 写入,答题记录由 Go/Dart 写入,成绩表由 Python 回填。只要字段含义一致,语言怎么换都不影响业务闭环。
| 表名 | 关键字段 | 写入方 | 读取方 |
|---|---|---|---|
| tb_question | id、type、stem、options_json、answer_hash、score、difficulty | Java | Python |
| tb_paper | id、title、question_ids_json、total_score、status | Java | Python、Go |
| tb_answer_record | id、paper_id、question_id、user_answer、duration | Dart/Go | Python |
| tb_score_result | id、record_id、score、detail_json、graded_at | Python | Java、Go |
注意 answer_hash 存的是 SHA-256 后的标准答案,而不是明文。原因有两个:一是多语言模块之间传递明文答案会扩散敏感数据,二是批改模块用哈希也能完成客观题判分,Java 侧不需要反向解出答案。difficulty 用 1 到 5 的整数,避免“简单/中等/困难”这类字符串在跨语言比较时出现编码不一致。options_json 采用 JSON 字符串存储选项,这样在 Java 里是 String,在 Python 里是 json.loads 后的 dict,不需要为每个语言做一次建表。
2.2 四类语言各管一段:Java、Python、Go、Dart 的分工
“编程语言推荐”这个问题在试卷管理系统里没有唯一答案,关键看每一层的替换成本。我的选型思路是:题库管理用 Java/Spring Boot,智能组卷和批改用 Python/FastAPI,边缘路由用 Go/Gin,移动监考端用 Dart/Flutter。不要一上来就六种语言,先把业务切到两个语言,等接口契约稳定后再补第三个。
| 模块 | 语言/框架 | 选型理由 | 边界 |
|---|---|---|---|
| 题库管理 | Java/Spring Boot | 事务能力强,JPA/Hibernate 生态稳 | 只出题、组卷、落库 |
| 智能组卷与批改 | Python/FastAPI | 方便用 numpy、分词库、scikit-learn | 不写业务状态,只算结果 |
| 边缘路由 | Go/Gin | 高并发下内存占用低,转发延迟小 | 只做鉴权、限流、路由 |
| 移动监考端 | Dart/Flutter | 一套代码同时覆盖 Android/iOS | 只采集答案和防切屏事件 |
Go 网关不是必选。如果你的部署环境里没有独立的接入层需求,去掉 Go 也完全成立。保留它的意义在于展示“多种编程语言”不是平均用力,而是每一层都有明确的独立生命周期。真正不能随便换的,只有数据库表和接口协议。Dart 模块在代码里主要负责上传 answer_record,它不直接访问题库表,只通过 Go 网关调用读取试卷的接口。
2.3 用 OpenAPI 把接口契约钉死,只要契约不崩,语言随便换
跨语言协作的另一个前提是接口契约先行。我习惯在仓库根目录维护一份 exam-contract.yaml,上面定义所有跨语言调用。比如组卷和批改两个接口:
openapi: 3.0.3 info: title: exam-contract version: 1.0.0 paths: /papers/generate: post: requestBody: required: true content: application/json: schema: type: object required: [paper_type, question_count] properties: paper_type: type: string enum: [mock, final] difficulty_ratio: type: object additionalProperties: type: integer question_count: type: integer target_score: type: integer responses: '200': description: generated paper id content: application/json: schema: type: object properties: paper_id: type: string /grading/submit: post: requestBody: required: true content: application/json: schema: type: object required: [record_id] properties: record_id: type: string answers: type: object additionalProperties: type: string responses: '200': description: accepted content: application/json: schema: type: object properties: status: type: string record_id: type: string这份契约文件可以直接生成多语言源码。Java 侧用 openapi-generator 的 spring 模板,Python 侧用 python-fastapi 模板,生成后各自补业务实现:
openapi-generator-cli generate -i contract/exam-contract.yaml -g spring -o services/exam-java openapi-generator-cli generate -i contract/exam-contract.yaml -g python-fastapi -o services/exam-python生成结果里 Java 会得到对应的 RestController 接口,Python 会得到 Pydantic 模型和路由签名。后续任何人改字段,都要先改 yaml,再重新生成。这样即使 Java 和 Python 团队并行开发,也不会出现两边字段名不一致的问题。每次重新生成后,必须跑一次契约里的校验测试,否则生成代码会覆盖掉手写的业务逻辑,这个是常见的踩坑点。
3. Java 模块:题库管理、试卷生成与事务处理源码设计
Java 模块承担的是命令侧职责:写题库、组装试卷、把待批改的题目交给 Python。它不需要懂批改算法,也不需要直接暴露给移动端。很多人在这个模块里过度设计,把试卷读取、答题提交都塞进 Java,结果 Java 服务成了单点瓶颈。正确的做法是 Java 只做写入和查询的强一致操作,高频读取交给 Go 网关做缓存。
3.1 工程结构与 Spring Boot 最小依赖
新建一个 Spring Boot 工程,只需要 web、data-jpa、mysql 三个依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>不引入 Dubbo、gRPC 这类 RPC 框架。多语言环境里 HTTP+JSON 的成本最低,而且 OpenAPI 契约正好覆盖 HTTP 接口。如果吞吐量后续不够,再用 Apache Thrift 或 gRPC 替换,但那是后话。应用配置里需要把spring.jpa.open-in-view设为 false,否则每次请求都会把数据库连接持有到视图渲染结束,Python 服务同时访问数据库时会更容易触发连接池打满。
3.2 按题型比例抽题并控制总分的生成算法
试卷生成的核心不是随机,而是“带约束的随机”。约束有三个:题型比例、难度分布、总分。下面这段代码演示了按题型比例抽题后的总分校正。
@Service public class PaperService { private final QuestionRepository questionRepository; private final PaperRepository paperRepository; public PaperService(QuestionRepository questionRepository, PaperRepository paperRepository) { this.questionRepository = questionRepository; this.paperRepository = paperRepository; } @Transactional public String generatePaper(GenerateRequest req) { List<Question> selected = new ArrayList<>(); req.getDistributions().forEach((type, cnt) -> { List<Question> questions = questionRepository.findByTypeAndStatus(type, 1); Collections.shuffle(questions); selected.addAll(questions.stream().limit(cnt).toList()); }); int total = selected.stream().mapToInt(Question::getScore).sum(); int diff = total - req.getTargetScore(); if (diff > 0) { for (Question q : selected) { if (diff <= 0) break; if (q.getScore() > 1 && questionRepository.findNextLowerScore(q.getType(), q.getScore()) != null) { Question lower = questionRepository.findNextLowerScore(q.getType(), q.getScore()); selected.remove(q); selected.add(lower); diff -= (q.getScore() - lower.getScore()); } } } if (diff != 0) { throw new IllegalStateException("总分无法匹配,需要调整题目池"); } Paper paper = new Paper(); paper.setTitle(req.getPaperTitle()); paper.setTotalScore(total); paper.setStatus(0); paper.setQuestionIdsJson( selected.stream().map(Question::getId).toList().toString()); return paperRepository.save(paper).getId(); } }这段代码的逻辑是:先从当前类型且启用状态的题目中随机取指定数量,再计算总分差。如果超出目标总分,就用同类型里分值更低的题替换,直到总分恰好等于目标。findNextLowerScore 的 Repository 方法可以这样定义:
@Query("SELECT q FROM Question q WHERE q.type = :type AND q.score < :score AND q.status = 1 ORDER BY q.score DESC LIMIT 1") Question findNextLowerScore(@Param("type") int type, @Param("score") int score);注意替换操作必须在同一个事务里完成,否则可能出现试卷题目列表里包含已经被其他线程删除的题目。主要参数在 GenerateRequest 里定义:
| 参数名 | 类型 | 含义 | 示例 |
|---|---|---|---|
| paper_title | String | 试卷名称 | 2026年期中模拟 |
| target_score | Integer | 试卷总分 | 100 |
| distributions | Map<Integer,Integer> | 题型数量分布,key 为题型编号 | {1:10, 2:5, 4:5} |
| difficulty_level | Integer | 难度上限,只抽取 ≤ 该值的题 | 3 |
这里没有用纯 SQL 直接完成“按比例随机抽题”,因为 MySQL 的ORDER BY RAND()在百万级题库上性能很差。生产环境我一般先在题库表里冗余一个rand_key随机数列,抽题时用WHERE rand_key > ? ORDER BY rand_key LIMIT ?代替随机排序。这样可以避免每次抽题都触发全表排序,多语言联调时也不会因为慢查询拖垮整个链路。
3.3 表结构 DDL 与 JPA 实体边界
题库表需要支撑上面的随机抽题和难度替换,DDL 如下:
CREATE TABLE tb_question ( id bigint PRIMARY KEY AUTO_INCREMENT, type tinyint NOT NULL COMMENT '1单选 2多选 3判断 4填空 5主观', stem text NOT NULL, options_json text NULL, answer_hash char(64) NOT NULL, score int NOT NULL, difficulty tinyint NOT NULL DEFAULT 3, status tinyint NOT NULL DEFAULT 1, rand_key double NOT NULL DEFAULT (RAND()), created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, key idx_type_diff (type, difficulty), key idx_rand (rand_key) ) ENGINE=InnoDB;JPA 侧的关键注释是@Column(name = "answer_hash")对应 answer_hash,而 options_json 直接用 String 映射。不要为选项创建独立的 JSON 字段类,否则跨语言解析时会出现序列化冲突。Java 模块只操作题目表和试卷表,答题记录和成绩结果表交给 Python 和 Go 去写,这个边界要在代码评审里明确卡住。还有一个容易被忽略的问题:Java 侧使用@Transactional管理事务,生成试卷只会在事务提交后返回,但 Python 侧读取题目的时间点可能在事务提交前。如果 Python 直接读 Java 事务内的未提交数据,会读到旧版本。解决方法是 Java 侧写题时使用@Version乐观锁,Python 侧读取时通过tb_question.status过滤禁用状态。
4. Python 模块:智能组卷与自动批改服务的源码实现
Python 模块负责计算侧。FastAPI 提供 REST 接口,内部跑批改逻辑,然后把结果写回 MySQL。这个模块最好不要持有会话状态,所有请求都应该是无状态的,方便横向扩容。如果你需要保存批改进度,优先写 Redis 而不是本地内存,否则在多实例部署时会出现两个实例各自计算同一道题的情况。
4.1 FastAPI 服务骨架与 Pydantic 响应模型
最小可用的服务骨架如下:
from fastapi import FastAPI, BackgroundTasks from pydantic import BaseModel, Field from typing import Optional app = FastAPI(title="grading-service") class SubmitRecord(BaseModel): record_id: str answers: dict[str, str] duration: Optional[int] = Field(0, ge=0, le=14400) @app.post("/grading/submit") async def submit_record(rec: SubmitRecord, bg: BackgroundTasks): # 先落一条待批改记录,再异步批改 bg.add_task(run_grading, rec.record_id, rec.answers, rec.duration) return {"status": "accepted", "record_id": rec.record_id} def run_grading(record_id: str, answers: dict[str, str], duration: int): # 实际批改逻辑 pass这里选了 BackgroundTasks 而不是 Celery,原因是 MVP 阶段不需要一个独立的消息中间件。BackgroundTasks 在单进程中执行,适合批改耗时不超过几分钟的场景。如果业务量上来,把 run_grading 替换成queue.send("grading", ...)即可,外部接口不用改。注意 BackgroundTasks 不保证在多个 worker 下只执行一次,所以幂等靠数据库的唯一索引来保证。Pydantic 里duration字段加了ge=0, le=14400,也就是 0 到 4 小时的答题时长,这个参数可以防止异常作答记录进入批改队列。请求体里answers 的 key 是 question_id,value 是用户的原始输入,Python 侧不需要关心它的展示格式。
4.2 客观题精确比对、主观题模糊匹配的批改核心
批改核心在 grade_question 函数中。客观题用哈希直接比对,主观题用 difflib 的 SequenceMatcher 做模糊匹配:
import hashlib from difflib import SequenceMatcher def grade_question(q: dict, user_answer: str) -> float: if q["type"] in (1, 2, 3): h = hashlib.sha256(user_answer.strip().upper().encode()).hexdigest() return q["score"] if h == q["answer_hash"] else 0.0 if q["type"] == 4: return score_fill(q, user_answer) return score_essay(q, user_answer) def score_fill(q: dict, answer: str) -> float: parts = [p.strip() for p in answer.split(";;") if p.strip()] correct = 0 for i, expected in enumerate(q["reference_fill"]): if i < len(parts) and SequenceMatcher(None, expected, parts[i]).ratio() >= 0.7: correct += 1 blank_count = len(q["reference_fill"]) return q["score"] * correct / blank_count if blank_count else 0.0 def score_essay(q: dict, answer: str) -> float: if not answer.strip(): return 0.0 ratio = SequenceMatcher(None, q.get("reference_text", ""), answer).ratio() if ratio >= 0.85: return q["score"] if ratio >= 0.60: return float(q["score"]) * 0.5 return 0.0SequenceMatcher 的默认参数在处理中文主观题时需要留意。autojunk 默认为 True,当某个字符在高频出现时会被跳过匹配,这会干扰中文答案的相似度计算。处理中文主观题时建议显式传autojunk=False:
ratio = SequenceMatcher(None, q["reference_text"], answer, autojunk=False).ratio()主观题的阈值参数也需要根据题库样本调整:
| 参数名 | 值 | 说明 |
|---|---|---|
| score_essay.high_ratio | 0.85 | 高于该相似度给满分 |
| score_essay.low_ratio | 0.60 | 高于低阈值给一半分 |
| score_fill.similarity | 0.70 | 填空按空匹配的相似度下限 |
| autojunk | False | 关闭自动忽略高频字符,避免中文匹配失真 |
上面的批改代码没有从数据库查题目,而是直接基于传入的 q dict 计算。这样做的目的是让批改函数变成纯函数,方便单测。生产环境里 q 是从 Redis 缓存读出来的,而不是每次批改都查 MySQL,否则批改高峰会把数据库连接池打满。缓存 key 可以用question:{id},Java 模块更新题库时主动失效这个 key。
4.3 与 Java 服务共用 MySQL 时的数据一致与锁冲突处理
最大的坑是多个语言进程写同一张表。Java 和 Python 同时读写 tb_score_result 时,要避免一个语言的事务包裹另一个语言的 HTTP 调用。我见过最典型的故障是:Java 在事务里调用 Python 批改接口,Python 又去更新同一张表,导致行锁被 Java 的长事务卡住,最后 MySQL 锁等待超时。
正确做法是 Java 只负责创建成绩单初始记录,Python 只负责回填分数。Python 侧写回时使用带FOR UPDATE的读改写,确保两个批改 worker 不会重复计算同一条记录:
from sqlalchemy import text from sqlalchemy.orm import Session def lock_score_row(session: Session, record_id: str): return session.execute( text("SELECT id, status FROM tb_score_result WHERE record_id = :rid FOR UPDATE"), {"rid": record_id} ).mappings().first()拿到锁后再执行UPDATE tb_score_result SET score = :score, status = 1 WHERE record_id = :rid。如果 status 已经是 1,说明已批改,直接跳过。这个“先查状态再更新”的流程,只有在 FOR UPDATE 的约束下才安全,否则两个 worker 同时读到 status=0 会重复写成绩。另外,Python 侧更新成绩时不要使用 ORM 的自动 flush,改为显式session.commit(),否则行锁可能在事务提交前被释放,Java 侧查询到的是中间状态。
5. 多语言联调的3个验证技巧:从 Issue 定位到一键部署
5.1 统一 JWT 的 RS256 验签
多语言服务最常见的联调故障是 token 签发和验证使用的算法不一致。Java 侧用 nimbus-jose-jwt 签发,Python 侧用 python-jose 验签,密钥对用 openssl 生成:
openssl genrsa -out private-key.pem 2048 openssl rsa -in private-key.pem -pubout -out public-key.pemJava 持有私钥,Python 和 Go 只持有公钥。Python 验签代码:
from jose import jwt claims = jwt.decode(token, public_key, algorithms=["RS256"], audience="exam-api")如果 Java 签发时 claims 里带aud: exam-api,而 Python 验签时漏了 audience,会直接抛JWTClaimsError。这个错误只会在跨语言环境下暴露出来,单语言单测不会发现。Go 网关在转发请求前先验签,验签失败就返回 401,不把无效 token 送到 Java 和 Python。
5.2 docker-compose 一键拉起多语言服务
多语言源码不需要本地装齐所有运行时。我一般用一个 docker-compose.yml 把所有服务编排起来:
version: "3.8" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] exam-java: build: ./services/exam-java depends_on: mysql: condition: service_healthy exam-python: build: ./services/exam-python depends_on: - mysql启动后先验证健康检查,不要急着打业务接口:
docker-compose ps curl -s http://localhost:8080/actuator/health curl -s http://localhost:8000/health5.3 用 OpenAPI 生成客户端直连全链路
最后一个技巧是直接用生成的 Python 客户端做端到端冒烟测试:
openapi-generator-cli generate -i contract/exam-contract.yaml -g python -o build/client pip install build/client然后写三行脚本调用 Java 生成试卷、Python 提交批改、Go 网关查成绩。如果这条链路在冒烟测试里通过,就可以把 Go、Dart 逐步接进来。因为整条链路的所有字段都来自同一份契约,即使某次改动破坏了兼容性,也能在生成代码时立刻被发现。跨语言调试时,在 Python 服务的响应头里加一个X-Lang: python,Java 服务的响应头加X-Lang: java,curl 的响应头能直接告诉你是哪个环节丢了字段,不用再抓包。
本文还有配套的精品资源,点击获取