From fcf381b8f1ae7082675fafaef5e689a95aeadb2f Mon Sep 17 00:00:00 2001 From: lbw <1192299468@qq.com> Date: Tue, 6 Jan 2026 14:48:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(exam):=20=E4=BC=98=E5=8C=96=E8=80=83?= =?UTF-8?q?=E8=AF=95=E5=8D=95=E8=AF=8D=E8=AF=86=E5=88=AB=E4=B8=8E=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改application.yml默认激活环境为pro,调整上传文件大小限制为30MB - 更新pro配置中的模板文件版本和路径 - 修复考试单词ID乱序问题,添加生成试卷成功日志 - 优化考试识别逻辑,确保ansSheetPath变量正确处理,完善异常捕获与文件删除机制 - 增加上传文件格式白名单校验,强化文件存储路径和命名安全性 - 用NIO替换File操作,确保上传目录存在并合理创建 - 优化PngUtil图像二值化处理,增加自适应阈值和形态学操作减少噪声 - 修改未背熟单词识别阈值,调整检测区域坐标和日志输出 - 注释部分冗余图像预处理代码,完善日志和异常信息提示 - 统一文件上传与识别过程的错误处理和日志记录,提升系统稳定性和可维护性 --- .../controller/ExamWordsController.java | 2 +- .../service/exam/ExamWordsServiceImpl.java | 76 +++++++---- .../judge/ExamWordsJudgeServiceImpl.java | 11 +- .../enlish/service/utils/PngUtil.java | 118 ++++++++++-------- .../main/resources/config/application-dev.yml | 2 +- .../main/resources/config/application-pro.yml | 10 +- .../src/main/resources/config/application.yml | 7 +- 7 files changed, 141 insertions(+), 85 deletions(-) diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/ExamWordsController.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/ExamWordsController.java index 17a6950..302d138 100644 --- a/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/ExamWordsController.java +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/ExamWordsController.java @@ -62,7 +62,7 @@ public class ExamWordsController { // bug: 获取单词后,单词的id会乱序、 需要重新更新考试记录中的 id examWordsDO.setWordIds(assessmentWords.stream().map(Word::getId).toList()); examWordsService.updateExamWordsWordIdsOrder(examWordsDO); - + log.info("生成试卷成功 {}", examWordsDO); List studentDetailList = studentService.getStudentDetailList(Collections.singletonList(studentId)); List> maps = studentDetailList.stream().map(studentDetail -> { Map data = new HashMap<>(); diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/exam/ExamWordsServiceImpl.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/exam/ExamWordsServiceImpl.java index 09b1b47..11f139f 100644 --- a/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/exam/ExamWordsServiceImpl.java +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/exam/ExamWordsServiceImpl.java @@ -10,16 +10,17 @@ import org.checkerframework.checker.nullness.qual.NonNull; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.UUID; +import java.util.*; @Service @Slf4j @@ -118,7 +119,7 @@ public class ExamWordsServiceImpl implements ExamWordsService { StudentDO studentDO = studentDOMapper.selectStudentById(studentId); Integer gradeId = studentDO.getGradeId(); List unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId) + "上"); - ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS); + ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS, ExamWordsConstant.EXAM_TYPE_MIDTERM); examWordsDO.setTitle("期中测试" + studentDO.getName()); return getExamWordsDO(studentId, examWordsDO); @@ -128,13 +129,13 @@ public class ExamWordsServiceImpl implements ExamWordsService { StudentDO studentDO = studentDOMapper.selectStudentById(studentId); Integer gradeId = studentDO.getGradeId(); List unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId)); - ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS); + ExamWordsDO examWordsDO = getExamWordsDO(studentId, studentDO, gradeId, unitDOS, ExamWordsConstant.EXAM_TYPE_FINAL); examWordsDO.setTitle("期末测试" + studentDO.getName()); return getExamWordsDO(studentId, examWordsDO); } @NonNull - private ExamWordsDO getExamWordsDO(Integer studentId, StudentDO studentDO, Integer gradeId, List unitDOS) { + private ExamWordsDO getExamWordsDO(Integer studentId, StudentDO studentDO, Integer gradeId, List unitDOS, Integer type) { if (unitDOS.isEmpty()) { throw new RuntimeException("没有找到对应的单元"); } @@ -143,7 +144,7 @@ public class ExamWordsServiceImpl implements ExamWordsService { ExamWordsDO examWordsDO = ExamWordsDO.builder() .gradeId(gradeId) .level(1) - .type(ExamWordsConstant.EXAM_TYPE_BASELINE) + .type(type) .title(studentDO.getName()) .createdAt(LocalDateTime.now()) .wordIds(vocabularyBankDOS.stream().map(VocabularyBankDO::getId).toList()) @@ -170,30 +171,55 @@ public class ExamWordsServiceImpl implements ExamWordsService { @Override @Transactional(rollbackFor = RuntimeException.class) public int saveExamWordsPngToDbAndLocal(MultipartFile file) { - - File dir = new File(tmpPng); - if (!dir.exists()) { - dir.mkdirs(); + // 1. 基础校验:判空 + if (file == null || file.isEmpty()) { + throw new RuntimeException("上传文件不能为空"); } + // 2. 安全校验:检查后缀名白名单 + String originalFilename = file.getOriginalFilename(); + String extension = StringUtils.getFilenameExtension(originalFilename); // Spring工具类 + List allowedExtensions = Arrays.asList("png", "jpg", "jpeg"); + + if (extension == null || !allowedExtensions.contains(extension.toLowerCase())) { + throw new RuntimeException("不支持的文件格式,仅支持: " + allowedExtensions); + } + + // 3. 准备目录 (使用 NIO) + // 假设 tmpPng 是配置好的基础路径字符串 + Path directoryPath = Paths.get(tmpPng); try { - String originalFilename = file.getOriginalFilename(); - String suffix = ""; - if (originalFilename != null && originalFilename.contains(".")) { - suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); + if (!Files.exists(directoryPath)) { + Files.createDirectories(directoryPath); } - String newFileName = UUID.randomUUID() + suffix; - String path = tmpPng + newFileName; - File dest = new File(path); - file.transferTo(dest); - int insert = examWordsJudgeResultDOMapper.insert(path); - log.info("上传文件成功"); + // 4. 生成文件名 (防止文件名冲突) + String newFileName = UUID.randomUUID().toString() + "." + extension; + + // 5. 组合最终路径 (自动处理分隔符) + Path targetPath = directoryPath.resolve(newFileName); + + // 6. 保存文件 + file.transferTo(targetPath.toAbsolutePath().toFile()); + String string = targetPath.toAbsolutePath().toFile().toString(); + log.info("文件上传成功路径为 {}", string); + if (!targetPath.toFile().exists()) { + log.error("文件上传失败: {}", newFileName); + throw new RuntimeException("文件上传失败"); + } + + // 7. 入库 + // 建议:存相对路径或文件名,不要存 targetPath.toString() 的绝对路径 + // 这里为了演示,假设 insert 依然接受字符串,建议存 newFileName + int insert = examWordsJudgeResultDOMapper.insert(targetPath.toString()); + + log.info("上传文件成功: {}", newFileName); return insert; - } catch (IOException e) { - throw new RuntimeException("上传失败", e); - } + } catch (IOException e) { + log.error("文件上传失败: {}", originalFilename, e); + throw new RuntimeException("上传失败,请稍后重试", e); + } } @Override diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/judge/ExamWordsJudgeServiceImpl.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/judge/ExamWordsJudgeServiceImpl.java index f32c712..0b0f032 100644 --- a/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/judge/ExamWordsJudgeServiceImpl.java +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/service/judge/ExamWordsJudgeServiceImpl.java @@ -53,9 +53,11 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService { public void judgeExamWords(int count) { List examWordsJudgeResultDOS = examWordsJudgeResultDOMapper.selectUnfinishedExamWordsJudgeResultDOList(count); for (ExamWordsJudgeResultDO examWordsJudgeResultDO : examWordsJudgeResultDOS) { + String ansSheetPath = null; try { - String ansSheetPath = examWordsJudgeResultDO.getAnsSheetPath(); + ansSheetPath = examWordsJudgeResultDO.getAnsSheetPath(); List coordinatesXIES = PngUtil.analysisXY(ansSheetPath); + // 从图片中获取学生 id 和考试 id StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(ansSheetPath, tessdataPath, coordinatesXIES); Integer examWordsJudgeResultDOId = examWordsJudgeResultDO.getId(); @@ -79,7 +81,7 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService { } ExamWordsDO examWordsDO = examWordsDOMapper.selectById(examWordsId); - if(examWordsDO == null) { + if (examWordsDO == null) { examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "未找到考试"); continue; } @@ -146,9 +148,14 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService { boolean delete = new File(ansSheetPath).delete(); if (delete) { log.info("删除文件成功:{}", ansSheetPath); + } else { + log.error("删除文件失败:{}", ansSheetPath); } } catch (Exception e) { log.error("识别考试失败 {}", e.getMessage()); + if (ansSheetPath != null) { + new File(ansSheetPath).delete(); + } examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDO.getId(), e.getMessage()); } } diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/PngUtil.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/PngUtil.java index 3c94a22..015bc66 100644 --- a/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/PngUtil.java +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/PngUtil.java @@ -15,6 +15,7 @@ import org.opencv.imgproc.Imgproc; import java.awt.image.BufferedImage; import java.awt.image.DataBufferByte; +import java.io.File; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -31,31 +32,13 @@ public class PngUtil { // 获取起始坐标 public static List analysisXY(String imagePath) { + Mat binary = image2BinaryMath(imagePath); Mat src = Imgcodecs.imread(imagePath); - - if (src.empty()) { - System.out.println("无法读取图片,请检查路径。"); - return null; - } - - // 3. 预处理 - // 3.1 转换为灰度图 - Mat gray = new Mat(); - Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY); - - // 3.2 二值化处理 (Thresholding) - // 使用 THRESH_BINARY_INV (反转二值化),因为我们需要找的是白色背景上的黑色块。 - // 反转后,黑色块变成白色(255),背景变成黑色(0),方便 findContours 查找。 - Mat binary = new Mat(); - // 阈值设为 50 左右即可,因为块是纯黑的 - Imgproc.threshold(gray, binary, 80, 255, Imgproc.THRESH_BINARY_INV); - // 4. 查找轮廓 List contours = new ArrayList<>(); Mat hierarchy = new Mat(); - // RETR_EXTERNAL 只检测最外层轮廓,忽略块内部可能存在的噪点 Imgproc.findContours(binary, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE); - //Imgcodecs.imwrite("output_red___v1.png", binary); + System.out.println("检测到的轮廓总数: " + contours.size()); System.out.println("------------------------------------------------"); @@ -91,36 +74,31 @@ public class PngUtil { // 可选:在原图上画出框,用于调试验证 Imgproc.rectangle(src, rect, new Scalar(0, 0, 255), 2); // 红色框 Imgproc.putText(src, "#" + blockCount, new Point(rect.x, rect.y - 5), Imgproc.FONT_HERSHEY_SIMPLEX, 0.5, new Scalar(0, 0, 255), 1); - //Imgcodecs.imwrite("output_red.png", src); } } + Imgcodecs.imwrite("output_red.png", src); System.out.println("找到 " + blockCount + " 个黑色块。"); - // 计算起始坐标 list.sort(Comparator.comparingInt(CoordinatesXY::getX)); + list.forEach(coordinatesXY -> coordinatesXY.setHeight(coordinatesXY.getHeight() / 51)); + list.forEach(coordinatesXY -> coordinatesXY.setWidth(coordinatesXY.getWidth() / 3)); + list.forEach(coordinatesXY -> coordinatesXY.setX(coordinatesXY.getX() + coordinatesXY.getWidth() * 2)); + + log.info("起始坐标: {}", list); + return list; } // 获取(未背熟)单词的 id public static List analyzePngForUnmemorizedWordIds(String filePath, List wordIds, List coordinatesXYList) { - Mat src = Imgcodecs.imread(filePath); - if (src.empty()) { - log.error("无法读取图片,请检查路径: {}", filePath); - throw new RuntimeException("无法读取图片"); - } - Mat gray = new Mat(); - Mat binary = new Mat(); + Mat binary = image2BinaryMath(filePath); try { - Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY); - // 建议:如果光照不均匀,考虑使用 THRESH_OTSU 自动阈值,或者自适应阈值 - Imgproc.threshold(gray, binary, 150, 255, Imgproc.THRESH_BINARY_INV); -// 调试时打印 - //Imgcodecs.imwrite("output_binary.png", binary); + List answer = new ArrayList<>(); int words_index = 0; @@ -150,8 +128,8 @@ public class PngUtil { Rect rect = new Rect(currentX + 1, currentY + 1, width - 2, height - 2); Mat region = binary.submat(rect); int countNonZero = Core.countNonZero(region); - - if (countNonZero > 370) { + log.info("当前位置为 words_index={},坐标为 x={} y={} 当前区域非零像素数: {}", words_index, currentX, currentY, countNonZero); + if (countNonZero > 1000) { Integer id = wordIds.get(words_index); answer.add(id); log.info("检测到标记(未背熟):ID={}, 当前坐标 x = {} y = {} ", id, currentX + 1, currentY + 1); @@ -170,8 +148,6 @@ public class PngUtil { } finally { - src.release(); - gray.release(); binary.release(); } } @@ -191,21 +167,21 @@ public class PngUtil { Rect roiRect = new Rect(0, 0, left.getX(), left.getY()); Mat roi = new Mat(src, roiRect); - // 3. 图像预处理 (提高 OCR 准确率) - // 3.1 转为灰度图 - Mat gray = new Mat(); - Imgproc.cvtColor(roi, gray, Imgproc.COLOR_BGR2GRAY); - - // 3.2 二值化 (Thresholding) - // 使用 OTSU 算法自动寻找最佳阈值,或者手动指定阈值 - Mat binary = new Mat(); - Imgproc.threshold(gray, binary, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU); +// // 3. 图像预处理 (提高 OCR 准确率) +// // 3.1 转为灰度图 +// Mat gray = new Mat(); +// Imgproc.cvtColor(roi, gray, Imgproc.COLOR_BGR2GRAY); +// +// // 3.2 二值化 (Thresholding) +// // 使用 OTSU 算法自动寻找最佳阈值,或者手动指定阈值 +// Mat binary = new Mat(); +// Imgproc.threshold(gray, binary, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU); // 可选:保存预处理后的图片查看效果 - //Imgcodecs.imwrite("debug_roi.jpg", binary); + Imgcodecs.imwrite("debug_roi.jpg", src); // 4. 将 OpenCV Mat 转换为 BufferedImage (供 Tess4J 使用) - BufferedImage processedImage = matToBufferedImage(binary); + BufferedImage processedImage = matToBufferedImage(src); // 5. 使用 Tesseract 进行 OCR 识别 ITesseract instance = new Tesseract(); @@ -230,6 +206,50 @@ public class PngUtil { return null; } + private static Mat image2BinaryMath(String imagePath) { + + if (!new File(imagePath).exists()) { + log.error("图片不存在,请检查路径: {}", imagePath); + throw new RuntimeException("图片不存在"); + } + + Mat src = Imgcodecs.imread(imagePath); + + if (src.empty()) { + log.info("无法读取图片,请检查路径: {}", imagePath); + throw new RuntimeException("无法读取图片"); + } + + Mat gray = new Mat(); + //转换为灰度图 + Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY); + + Imgproc.GaussianBlur(gray, gray, new Size(5, 5), 0); + + Mat binary = new Mat(); + Imgproc.adaptiveThreshold(gray, binary, 255, + Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, + Imgproc.THRESH_BINARY_INV, 25, 10); + + Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(3, 3)); + + //开运算 (Open):先腐蚀后膨胀,用于去除背景中的微小噪点 + Imgproc.morphologyEx(binary, binary, Imgproc.MORPH_OPEN, kernel); + + // 闭运算 (Close):先膨胀后腐蚀,用于连接断裂的区域并填充块内部的空洞 + // 如果块比较大且内部反光严重,可以将 Size(3,3) 改为 Size(5,5) 或更大 + Mat closeKernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(7, 7)); + Imgproc.morphologyEx(binary, binary, Imgproc.MORPH_CLOSE, closeKernel); + + // 保存二值化过程图用于调试 (生产环境可注释) + Imgcodecs.imwrite("debug_binary_natural.png", binary); + + src.release(); + gray.release(); + + return binary; + } + private static @NonNull StudentExamId getStudentExamId(Pattern pattern, String result) { Matcher matcher = pattern.matcher(result); StudentExamId studentExamId = new StudentExamId(0, 0); diff --git a/enlish-service/src/main/resources/config/application-dev.yml b/enlish-service/src/main/resources/config/application-dev.yml index 9d1a221..013cd07 100644 --- a/enlish-service/src/main/resources/config/application-dev.yml +++ b/enlish-service/src/main/resources/config/application-dev.yml @@ -39,7 +39,7 @@ templates: weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v3.docx plan_day: 7 tmp: - png: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\tmp\png\ + png: ai: key: app-loC6IrJpj4cS54MAYp73QtGl diff --git a/enlish-service/src/main/resources/config/application-pro.yml b/enlish-service/src/main/resources/config/application-pro.yml index 8933e83..0205753 100644 --- a/enlish-service/src/main/resources/config/application-pro.yml +++ b/enlish-service/src/main/resources/config/application-pro.yml @@ -31,15 +31,15 @@ spring: templates: - word: assessment_v7.docx + word: assessment_v9.docx count: 100 - data: eng.traineddata + data: plan: - weekday: tem_study_plan_v6.docx - weekend: study_plan_review_v2.docx + weekday: tem_study_plan_v7.docx + weekend: study_plan_review_v3.docx plan_day: 7 tmp: - png: tmp\png\ + png: ai: key: app-loC6IrJpj4cS54MAYp73QtGl diff --git a/enlish-service/src/main/resources/config/application.yml b/enlish-service/src/main/resources/config/application.yml index 713ebb2..b8df363 100644 --- a/enlish-service/src/main/resources/config/application.yml +++ b/enlish-service/src/main/resources/config/application.yml @@ -3,8 +3,11 @@ server: spring: profiles: - active: dev # 默认激活 dev 本地开发环境 - + active: pro # 默认激活 dev 本地开发环境 + servlet: + multipart: + max-file-size: 30MB + max-request-size: 30MB mybatis: # MyBatis xml 配置文件路径 mapper-locations: classpath:/mapper/**/*.xml