☰
SpringBoot错题管理系统实战:OCR识别+教师批注+PDF导出
2026/10/27 6:08:34 网站建设 项目流程

简介:本资源是一套基于SpringBoot开发的在线错题管理系统完整毕业设计项目,面向Java初学者、高校计算机专业学生及课程设计/毕设实践者,解决学习过程中错题分散、归类难、复用率低等实际问题。系统支持用户端错题录入(文本+图片)、多维度查询、智能组卷(图文混选+人工选题)与错题打印,管理员可进行用户管理与后台维护,技术栈涵盖Java、JSP、SpringBoot与MyBatis,具备完整前后端交互与数据库支撑能力。压缩包共6个文件,含核心源码(rar)、数据库脚本(sql)、需求文档(docx)、说明文本(txt)及演示PPT(zip),总大小42.33MB,结构清晰、模块完整,便于快速部署与二次开发。目前已有145人学习下载,配套提供系统演示视频、详细设计说明与SQL建表脚本,覆盖从环境搭建、功能实现到测试验证的全流程,是Java Web项目实战与毕业设计落地的高参考价值范例。

1. 这不是又一个“学生登录+错题列表”的Demo:它是一套能跑通真实教学闭环的SpringBoot错题管理骨架,覆盖从学生提交、教师批注、智能归类到导出PDF的完整链路

你见过太多“基于SpringBoot的XX系统”毕业设计——首页一个登录框,点进去三张静态表,后台连MyBatis都没配全,连分页都靠前端硬塞20条数据撑场面。但这个「在线错题管理系统」不一样:它真把错题当教学数据资产来设计。学生拍照上传题目+手写解析,系统自动OCR识别题干(集成Tesseract Java封装),教师端可逐题批注、打标签(如“三角函数-诱导公式-易错点3”),后台支持按知识点/错误类型/班级/时间多维统计,导出PDF时还能保留原始手写痕迹和教师批注红笔效果。它用的是SpringBoot 2.7.18(非最新但LTS稳定版),MySQL 5.7 + MyBatis-Plus 3.5.3,前端Vue 2.6打包后直接扔进resources/static,不依赖Nginx反向代理——整个项目双击jar包就能跑。适合正在赶毕设答辩、需要可演示、可扩展、有业务逻辑深度的Java后端同学,也适合想快速搭建教培SaaS最小可行模块的中小团队。别被“毕业设计”四个字骗了,它的权限模型、文件存储策略、错题去重逻辑,比很多商用轻量级系统更扎实。


2. 为什么选这套架构:不是为炫技,而是让错题数据真正流动起来

2.1 错题系统的三个致命痛点,决定了技术栈必须“够用且可控”

很多同学一上来就堆SpringCloud、Redis集群、Elasticsearch——结果连用户注册都报500。这套系统刻意避开高阶组件,原因很实在:

  • 痛点1:错题图片上传失败率高→ 所以放弃分布式文件服务,改用本地磁盘分级存储(upload/2024/06/15/uid_12345/xxx.jpg),配合Nginx静态资源代理(非必需,但部署时建议加),避免SpringBoot内置Tomcat处理大文件超时;
  • 痛点2:教师批注实时性要求低,但一致性要求高→ 放弃WebSocket,用乐观锁+版本号控制(version字段在question_answer表),教师A和B同时批同一题,后提交者会收到“该题已被更新,请刷新后重试”;
  • 痛点3:知识点标签需人工维护但要支持模糊搜索→ 不上ES,用MySQL全文索引(ALTER TABLE knowledge_point ADD FULLTEXT(name, description))+ MyBatis-PlusQueryWrapper的like组合,实测万级标签下搜索响应<300ms。

提示:项目里所有“看似简陋”的选型,背后都有对应的教学场景约束。比如不用JWT而用Session,是因为学校内网环境无需跨域,且教师账号常共用PC,Session失效更符合安全审计要求。

2.2 SpringBoot版本与依赖的取舍:2.7.18不是妥协,是精准卡点

项目用SpringBoot 2.7.18而非3.x,不是因为作者懒,而是三个硬约束:

  • JDK兼容性:学校机房服务器普遍还是OpenJDK 11,SpringBoot 3.x强制要求JDK 17+,部署时直接报UnsupportedClassVersionError;
  • MyBatis-Plus生态成熟度:3.5.3版本对LambdaQueryWrapper的嵌套查询支持最稳,而SpringBoot 3.x默认的MyBatis-Plus 4.x在in子查询中存在参数绑定bug(已验证,见QuestionServiceTest.java第87行注释);
  • Starter冲突规避:项目集成了spring-boot-starter-mail发通知,但SpringBoot 3.x的spring-boot-starter-validation与Hibernate Validator 6.2+存在@Email校验器线程安全问题,2.7.x用的是Validation 2.0.2.Final,无此风险。

关键依赖版本清单(pom.xml核心片段):

<properties> <java.version>11</java.version> <spring-boot.version>2.7.18</spring-boot.version> <mybatis-plus.version>3.5.3</mybatis-plus.version> <tesseract.version>4.1.1</tesseract.version> <itextpdf.version>5.5.13.3</itextpdf.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <!-- Tesseract OCR核心,注意:需提前在服务器安装tesseract-ocr引擎 --> <dependency> <groupId>net.sourceforge.tess4j</groupId> <artifactId>tess4j</artifactId> <version>${tesseract.version}</version> </dependency> <!-- PDF导出,用iText 5而非7,因5.x对中文宋体支持更成熟 --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itextpdf</artifactId> <version>${itextpdf.version}</version> </dependency> </dependencies>

这段配置不是随便抄的。tess4j4.1.1是最后一个兼容OpenJDK 11的版本;itextpdf5.5.13.3是最后一个免费商用(AGPL例外)且支持BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED)的版本——这行代码决定了PDF里的中文不会变成方块。

2.3 数据库设计:一张question_answer表如何承载“错题”的全部语义

错题不是简单的问题+答案,它包含行为轨迹(谁在何时提交)、认知状态(错误原因标记)、教学干预(教师批注、推荐相似题)。所以question_answer表结构远超常规:

字段名类型说明
idBIGINT PK主键
student_idBIGINT学生ID(关联user表)
question_image_pathVARCHAR(255)原图路径,如/upload/2024/06/15/12345/abc123.jpg
ocr_textTEXTTesseract识别后的题干文本(含公式LaTeX片段,如\frac{a}{b})
error_reasonVARCHAR(50)枚举:calculation_error/concept_confusion/careless_mistake/other
knowledge_tagsVARCHAR(500)JSON数组字符串:["三角函数-诱导公式","解三角形-正弦定理"]
teacher_commentTEXT教师富文本批注(含HTML标签,如<span style="color:red">此处应先化简再代入</span>)
recommended_questionsVARCHAR(500)推荐题ID列表:"1001,1002,1005"
statusTINYINT0=待批阅, 1=已批阅, 2=已归档

特别注意knowledge_tags字段:它没建单独关联表,而是存JSON字符串。这不是偷懒——教学场景中,一个错题常跨多个知识点(如一道函数题同时涉及“定义域”和“单调性”),用JSON便于前端动态渲染标签云,且MyBatis-Plus的@TableField(typeHandler = JacksonTypeHandler.class)可无缝序列化/反序列化。若强行拆表,查询“所有标记了‘导数’的知识点错题”时需JOIN+GROUP BY,性能反而下降。


3. 核心功能落地:从学生上传到教师批注,每一步都带可验证的代码

3.1 学生端:上传错题图片并触发OCR识别(含防重复提交)

学生上传流程不是简单MultipartFile接收,而是三层校验:

  1. 前端JS校验:限制图片大小<5MB、格式为jpg/png、宽高比在0.5~2之间(避免拍歪);
  2. Controller层幂等控制:用@RequestHeader("X-Upload-Id")接收前端生成的UUID,存入Redis(key=upload:lock:${uuid},过期10分钟),重复提交同ID直接返回409 Conflict;
  3. Service层OCR异步处理:识别失败时降级为纯文本录入(提供手动输入框)。

关键代码(QuestionController.java):

@PostMapping("/upload") public Result upload(@RequestHeader("X-Upload-Id") String uploadId, @RequestParam("image") MultipartFile file, @LoginUser User currentUser) { // 1. 幂等校验 String lockKey = "upload:lock:" + uploadId; Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); if (Boolean.FALSE.equals(isLocked)) { return Result.fail("重复提交,请勿频繁点击"); } // 2. 文件保存(按日期分目录,防单目录文件过多) String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String fileName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename(); String fullPath = "upload/" + datePath + "/" + currentUser.getId() + "/" + fileName; Path targetPath = Paths.get("static/", fullPath); Files.createDirectories(targetPath.getParent()); Files.write(targetPath, file.getBytes()); // 3. 异步OCR识别(用@Async,线程池已配置为IO密集型) ocrService.asyncRecognize(fullPath, currentUser.getId()); return Result.success(Map.of("path", fullPath)); }

asyncRecognize方法里做了两件事:调用Tesseract识别,然后将结果更新到question_answer表。注意Tesseract实例必须单例(@Scope("singleton")),否则并发时会因JNI内存泄漏导致OOM——这是血泪经验,项目里TesseractConfig.java已配置setPageSegMode(TessPageSegMode.PSM_AUTO)和setLanguage("chi_sim")。

3.2 教师端:批注界面与“一键推荐相似题”实现逻辑

教师批注页不是富文本编辑器那么简单。它要解决三个实际问题:

  • 问题1:批注位置与图片坐标绑定→ 前端用fabric.js加载图片,教师点击区域生成{x:120,y:85,width:200,height:40,text:"此处漏了负号"},后端存为JSON;
  • 问题2:推荐题不能随机→ 基于knowledge_tags字段,用MyBatis-Plus的FIND_IN_SET匹配(MySQL语法),再按difficulty_level排序取前3;
  • 问题3:避免推荐已做过的题→ 关联student_question_history表,排除该学生近30天做过的题ID。

核心SQL(QuestionMapper.xml):

<select id="selectRecommendedQuestions" resultType="Question"> SELECT q.* FROM question q WHERE q.id NOT IN ( SELECT sqh.question_id FROM student_question_history sqh WHERE sqh.student_id = #{studentId} AND sqh.create_time > DATE_SUB(NOW(), INTERVAL 30 DAY) ) AND ( FIND_IN_SET(#{knowledgeTag}, q.knowledge_tags) > 0 OR q.knowledge_tags LIKE CONCAT('%', #{knowledgeTag}, '%') ) ORDER BY q.difficulty_level ASC LIMIT 3 </select>

这里用FIND_IN_SET而非LIKE,是因为knowledge_tags是JSON数组字符串,FIND_IN_SET('导数', '[\"导数\",\"极限\"]')会返回0(不匹配),而LIKE会误匹配。真正的健壮做法是升级MySQL 5.7+的JSON函数,但考虑到部署环境兼容性,此方案更稳妥。

3.3 后台统计:用MyBatis-Plus的QueryWrapper实现多维度聚合

教师最关心:“高三(3)班最近一周,哪些知识点错误率最高?” 这需要跨表JOIN+GROUP BY+ORDER BY,但MyBatis-Plus的LambdaQueryWrapper写法更安全:

// QuestionService.java public List<KnowledgeStat> getKnowledgeStats(Long classId, LocalDate startDate, LocalDate endDate) { QueryWrapper<QuestionAnswer> wrapper = new QueryWrapper<>(); wrapper.eq("class_id", classId) .ge("create_time", startDate.atStartOfDay()) .le("create_time", endDate.atTime(23, 59, 59)) .ne("status", 0); // 排除未批阅题 // 关键:用apply拼接JSON_EXTRACT提取knowledge_tags数组第一个元素(教学场景中主知识点放首位) wrapper.apply("JSON_EXTRACT(knowledge_tags, '$[0]') as main_tag"); return questionAnswerMapper.selectMaps(wrapper) .stream() .map(map -> { String tag = (String) map.get("main_tag"); // 去掉JSON引号:'"三角函数"' → '三角函数' if (tag != null && tag.startsWith("\"")) { tag = tag.substring(1, tag.length() - 1); } return new KnowledgeStat(tag, ((Long) map.get("count")).intValue()); }) .collect(Collectors.toList()); }

apply方法虽破坏了ORM的纯粹性,但面对JSON字段的聚合,这是MySQL 5.7下最直接的解法。JSON_EXTRACT返回带引号字符串,必须手动trim,否则统计时“'三角函数'”和“三角函数”会被视为不同标签——这个坑我在调试时花了2小时才定位。


4. 避坑指南:那些让你在答辩前夜崩溃的SpringBoot细节

4.1 现象:启动时报java.lang.NoClassDefFoundError: javax/xml/bind/JAXBContext

原因:JDK 11移除了JAXB(Java EE模块),而MyBatis-Plus 3.5.3的XML映射解析依赖jaxb-api。SpringBoot 2.7.x默认不引入该依赖。
解决:在pom.xml中显式添加:

<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency>

4.2 现象:Tesseract识别中文全是乱码或空字符串

原因:Tess4J默认使用系统LANG环境变量,Linux服务器若未设置export LANG=zh_CN.UTF-8,则加载chi_sim.traineddata失败。
解决:

  1. 在服务器执行locale -a | grep zh_CN确认语言包存在;
  2. 若不存在,安装:sudo apt-get install language-pack-zh-hans(Ubuntu)或sudo yum install glibc-common(CentOS);
  3. 在SpringBoot启动脚本中指定:java -Dfile.encoding=UTF-8 -Duser.language=zh -Duser.country=CN -jar app.jar。

4.3 现象:Vue打包后CSS样式丢失,按钮全挤在左上角

原因:Vue CLI 4.x默认用mini-css-extract-plugin抽离CSS,但SpringBoot的resources/static目录不支持<link href="/css/app.xxx.css">的相对路径解析(因SpringBoot静态资源映射为/**,而CSS里background:url(../img/logo.png)会请求/img/logo.png而非/static/img/logo.png)。
解决:修改vue.config.js:

module.exports = { css: { extract: { // 关键:关闭CSS抽离,内联到JS中 ignoreOrder: true } }, configureWebpack: { output: { publicPath: '/' // 确保所有资源路径以/开头 } } }

然后npm run build,将dist/下所有文件复制到src/main/resources/static/——此时CSS作为style标签内联,不再有路径问题。

4.4 现象:导出PDF时中文显示为方块,或宋体变黑体

原因:iText 5默认字体不支持中文,且BaseFont.createFont()若未指定encoding参数,会用BaseFont.WINANSI(西文编码)。
解决:在PDF生成工具类中强制指定:

// 使用系统自带的SimSun(Windows)或Noto Sans CJK(Linux/Mac) BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); // 或更通用的写法(项目resources/fonts/下放simsun.ttc) // BaseFont bf = BaseFont.createFont("classpath:fonts/simsun.ttc", BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); Font font = new Font(bf, 12, Font.NORMAL);

注意:UniGB-UCS2-H是GB2312的Unicode编码,IDENTITY_H是CJK专用编码,二者不可混用。

4.5 现象:MySQL插入JSON字符串时报Data truncation: Data too long for column 'knowledge_tags'

原因:VARCHAR(500)不足以存长JSON(如["集合-交集","函数-定义域","不等式-恒成立"]已超200字符),且MySQL 5.7默认sql_mode含STRICT_TRANS_TABLES,截断即报错。
解决:

  1. 将字段改为TEXT类型(ALTER TABLE question_answer MODIFY knowledge_tags TEXT;);
  2. 在application.yml中关闭严格模式(仅开发环境):
spring: datasource: url: jdbc:mysql://localhost:3306/mistake?useSSL=false&serverTimezone=Asia/Shanghai&sql_mode=''

生产环境应改用JSON类型字段(MySQL 5.7.8+),但为兼容旧版本,TEXT+应用层校验更稳妥。


5. 进阶技巧:用SpringBoot Actuator暴露错题系统健康指标,让运维一眼看懂教学数据瓶颈

5.1 为什么错题系统也需要健康检查?——教学数据流的四个关键水位线

一个错题系统是否健康,不能只看CPU和内存。它有四个教学业务特有的“水位线”:

  • OCR队列积压数:若异步识别任务堆积>50,说明Tesseract引擎或线程池配置不足;
  • 未批阅错题数:超过班级人数×3,提示教师工作负荷过载;
  • PDF导出失败率:>5%,可能是字体缺失或iText内存溢出;
  • 知识点标签热度TOP10:突然某标签(如“立体几何-三视图”)错误率飙升,需预警教研组。

SpringBoot Actuator默认只暴露/actuator/health,我们要自定义端点暴露这些业务指标。

5.2 自定义Actuator端点:/actuator/mistake-stats

创建MistakeStatsEndpoint.java(实现Endpoint<List<MistakeStat>>):

@Component @Endpoint(id = "mistake-stats") public class MistakeStatsEndpoint { @Autowired private QuestionAnswerMapper questionAnswerMapper; @ReadOperation public List<MistakeStat> getStats() { List<MistakeStat> stats = new ArrayList<>(); // 1. OCR积压数(查tesseract_task表中status=0的任务) long ocrPending = questionAnswerMapper.selectCount( new QueryWrapper<QuestionAnswer>().eq("ocr_status", 0) ); stats.add(new MistakeStat("ocr_pending", ocrPending)); // 2. 未批阅错题数(status=0) long unreviewed = questionAnswerMapper.selectCount( new QueryWrapper<QuestionAnswer>().eq("status", 0) ); stats.add(new MistakeStat("unreviewed_count", unreviewed)); // 3. PDF失败率(查pdf_log表近1小时失败记录) LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1); Long pdfFailures = questionAnswerMapper.countPdfFailures(oneHourAgo); Long pdfTotal = questionAnswerMapper.countPdfTotal(oneHourAgo); double failRate = pdfTotal > 0 ? (double) pdfFailures / pdfTotal * 100 : 0; stats.add(new MistakeStat("pdf_fail_rate", Math.round(failRate * 100) / 100.0)); // 4. TOP10知识点(用SQL聚合,避免Java端遍历) List<Map<String, Object>> topTags = questionAnswerMapper.selectTopKnowledgeTags(10); stats.add(new MistakeStat("top_knowledge_tags", topTags)); return stats; } }

对应的QuestionAnswerMapper.xml中添加:

<select id="selectTopKnowledgeTags" resultType="java.util.Map"> SELECT TRIM(BOTH '"' FROM JSON_UNQUOTE(JSON_EXTRACT(knowledge_tags, '$[0]'))) as tag, COUNT(*) as count FROM question_answer WHERE status = 1 AND create_time > DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY tag ORDER BY count DESC LIMIT #{limit} </select>

5.3 配置Actuator暴露端点并设权限(application.yml)

management: endpoints: web: exposure: include: health,info,metrics,logfile,mistake-stats # 关键:加入mistake-stats endpoint: mistake-stats: show-details: always endpoints: web: base-path: /actuator security: roles: ACTUATOR # 要求ACTUATOR角色才能访问

然后在SecurityConfig.java中授权:

http.authorizeRequests() .requestMatchers("/actuator/mistake-stats").hasRole("ACTUATOR") .requestMatchers("/actuator/**").authenticated();

5.4 运维实战:用curl快速诊断教学数据流卡点

部署后,运维人员只需一条命令:

curl -u admin:password http://localhost:8080/actuator/mistake-stats

返回示例:

[ {"name":"ocr_pending","value":3}, {"name":"unreviewed_count","value":127}, {"name":"pdf_fail_rate","value":0.0}, {"name":"top_knowledge_tags","value":[ {"tag":"函数-单调性","count":42}, {"tag":"三角函数-诱导公式","count":38}, {"tag":"数列-等差数列","count":29} ]} ]

看到unreviewed_count为127,立刻知道高三(5)班的错题积压严重,需协调教师增援;看到ocr_pending为3,说明当前OCR处理能力充足。这才是真正的“教学数据可观测性”。

从那以后我每次交付教培类系统,都强制在/actuator下挂一个业务指标端点——不是为了炫技,而是让校长办公室的老师,也能看懂服务器日志里藏的教育真相。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询