Compare commits

..

1 Commits

Author SHA1 Message Date
lbw
fcf381b8f1 fix(exam): 优化考试单词识别与文件上传功能
- 修改application.yml默认激活环境为pro,调整上传文件大小限制为30MB
- 更新pro配置中的模板文件版本和路径
- 修复考试单词ID乱序问题,添加生成试卷成功日志
- 优化考试识别逻辑,确保ansSheetPath变量正确处理,完善异常捕获与文件删除机制
- 增加上传文件格式白名单校验,强化文件存储路径和命名安全性
- 用NIO替换File操作,确保上传目录存在并合理创建
- 优化PngUtil图像二值化处理,增加自适应阈值和形态学操作减少噪声
- 修改未背熟单词识别阈值,调整检测区域坐标和日志输出
- 注释部分冗余图像预处理代码,完善日志和异常信息提示
- 统一文件上传与识别过程的错误处理和日志记录,提升系统稳定性和可维护性
2026-01-06 14:48:01 +08:00
7 changed files with 141 additions and 85 deletions

View File

@@ -62,7 +62,7 @@ public class ExamWordsController {
// bug: 获取单词后单词的id会乱序、 需要重新更新考试记录中的 id // bug: 获取单词后单词的id会乱序、 需要重新更新考试记录中的 id
examWordsDO.setWordIds(assessmentWords.stream().map(Word::getId).toList()); examWordsDO.setWordIds(assessmentWords.stream().map(Word::getId).toList());
examWordsService.updateExamWordsWordIdsOrder(examWordsDO); examWordsService.updateExamWordsWordIdsOrder(examWordsDO);
log.info("生成试卷成功 {}", examWordsDO);
List<StudentDetail> studentDetailList = studentService.getStudentDetailList(Collections.singletonList(studentId)); List<StudentDetail> studentDetailList = studentService.getStudentDetailList(Collections.singletonList(studentId));
List<Map<String, Object>> maps = studentDetailList.stream().map(studentDetail -> { List<Map<String, Object>> maps = studentDetailList.stream().map(studentDetail -> {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();

View File

@@ -10,16 +10,17 @@ import org.checkerframework.checker.nullness.qual.NonNull;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.IOException; 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.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.*;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@Service @Service
@Slf4j @Slf4j
@@ -118,7 +119,7 @@ public class ExamWordsServiceImpl implements ExamWordsService {
StudentDO studentDO = studentDOMapper.selectStudentById(studentId); StudentDO studentDO = studentDOMapper.selectStudentById(studentId);
Integer gradeId = studentDO.getGradeId(); Integer gradeId = studentDO.getGradeId();
List<UnitDO> unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId) + ""); List<UnitDO> 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()); examWordsDO.setTitle("期中测试" + studentDO.getName());
return getExamWordsDO(studentId, examWordsDO); return getExamWordsDO(studentId, examWordsDO);
@@ -128,13 +129,13 @@ public class ExamWordsServiceImpl implements ExamWordsService {
StudentDO studentDO = studentDOMapper.selectStudentById(studentId); StudentDO studentDO = studentDOMapper.selectStudentById(studentId);
Integer gradeId = studentDO.getGradeId(); Integer gradeId = studentDO.getGradeId();
List<UnitDO> unitDOS = unitDOMapper.selectByUnitName(ExamWordsConstant.getGradeName(gradeId)); List<UnitDO> 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()); examWordsDO.setTitle("期末测试" + studentDO.getName());
return getExamWordsDO(studentId, examWordsDO); return getExamWordsDO(studentId, examWordsDO);
} }
@NonNull @NonNull
private ExamWordsDO getExamWordsDO(Integer studentId, StudentDO studentDO, Integer gradeId, List<UnitDO> unitDOS) { private ExamWordsDO getExamWordsDO(Integer studentId, StudentDO studentDO, Integer gradeId, List<UnitDO> unitDOS, Integer type) {
if (unitDOS.isEmpty()) { if (unitDOS.isEmpty()) {
throw new RuntimeException("没有找到对应的单元"); throw new RuntimeException("没有找到对应的单元");
} }
@@ -143,7 +144,7 @@ public class ExamWordsServiceImpl implements ExamWordsService {
ExamWordsDO examWordsDO = ExamWordsDO.builder() ExamWordsDO examWordsDO = ExamWordsDO.builder()
.gradeId(gradeId) .gradeId(gradeId)
.level(1) .level(1)
.type(ExamWordsConstant.EXAM_TYPE_BASELINE) .type(type)
.title(studentDO.getName()) .title(studentDO.getName())
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.wordIds(vocabularyBankDOS.stream().map(VocabularyBankDO::getId).toList()) .wordIds(vocabularyBankDOS.stream().map(VocabularyBankDO::getId).toList())
@@ -170,30 +171,55 @@ public class ExamWordsServiceImpl implements ExamWordsService {
@Override @Override
@Transactional(rollbackFor = RuntimeException.class) @Transactional(rollbackFor = RuntimeException.class)
public int saveExamWordsPngToDbAndLocal(MultipartFile file) { public int saveExamWordsPngToDbAndLocal(MultipartFile file) {
// 1. 基础校验:判空
File dir = new File(tmpPng); if (file == null || file.isEmpty()) {
if (!dir.exists()) { throw new RuntimeException("上传文件不能为空");
dir.mkdirs();
} }
try { // 2. 安全校验:检查后缀名白名单
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String suffix = ""; String extension = StringUtils.getFilenameExtension(originalFilename); // Spring工具类
if (originalFilename != null && originalFilename.contains(".")) { List<String> allowedExtensions = Arrays.asList("png", "jpg", "jpeg");
suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String newFileName = UUID.randomUUID() + suffix;
String path = tmpPng + newFileName;
File dest = new File(path); if (extension == null || !allowedExtensions.contains(extension.toLowerCase())) {
file.transferTo(dest); throw new RuntimeException("不支持的文件格式,仅支持: " + allowedExtensions);
int insert = examWordsJudgeResultDOMapper.insert(path); }
log.info("上传文件成功");
// 3. 准备目录 (使用 NIO)
// 假设 tmpPng 是配置好的基础路径字符串
Path directoryPath = Paths.get(tmpPng);
try {
if (!Files.exists(directoryPath)) {
Files.createDirectories(directoryPath);
}
// 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; return insert;
} catch (IOException e) {
throw new RuntimeException("上传失败", e);
}
} catch (IOException e) {
log.error("文件上传失败: {}", originalFilename, e);
throw new RuntimeException("上传失败,请稍后重试", e);
}
} }
@Override @Override

View File

@@ -53,9 +53,11 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
public void judgeExamWords(int count) { public void judgeExamWords(int count) {
List<ExamWordsJudgeResultDO> examWordsJudgeResultDOS = examWordsJudgeResultDOMapper.selectUnfinishedExamWordsJudgeResultDOList(count); List<ExamWordsJudgeResultDO> examWordsJudgeResultDOS = examWordsJudgeResultDOMapper.selectUnfinishedExamWordsJudgeResultDOList(count);
for (ExamWordsJudgeResultDO examWordsJudgeResultDO : examWordsJudgeResultDOS) { for (ExamWordsJudgeResultDO examWordsJudgeResultDO : examWordsJudgeResultDOS) {
String ansSheetPath = null;
try { try {
String ansSheetPath = examWordsJudgeResultDO.getAnsSheetPath(); ansSheetPath = examWordsJudgeResultDO.getAnsSheetPath();
List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(ansSheetPath); List<CoordinatesXY> coordinatesXIES = PngUtil.analysisXY(ansSheetPath);
// 从图片中获取学生 id 和考试 id // 从图片中获取学生 id 和考试 id
StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(ansSheetPath, tessdataPath, coordinatesXIES); StudentExamId studentExamId = PngUtil.analyzeExamWordsIdAndStudentId(ansSheetPath, tessdataPath, coordinatesXIES);
Integer examWordsJudgeResultDOId = examWordsJudgeResultDO.getId(); Integer examWordsJudgeResultDOId = examWordsJudgeResultDO.getId();
@@ -79,7 +81,7 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
} }
ExamWordsDO examWordsDO = examWordsDOMapper.selectById(examWordsId); ExamWordsDO examWordsDO = examWordsDOMapper.selectById(examWordsId);
if(examWordsDO == null) { if (examWordsDO == null) {
examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "未找到考试"); examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDOId, "未找到考试");
continue; continue;
} }
@@ -146,9 +148,14 @@ public class ExamWordsJudgeServiceImpl implements ExamWordsJudgeService {
boolean delete = new File(ansSheetPath).delete(); boolean delete = new File(ansSheetPath).delete();
if (delete) { if (delete) {
log.info("删除文件成功:{}", ansSheetPath); log.info("删除文件成功:{}", ansSheetPath);
} else {
log.error("删除文件失败:{}", ansSheetPath);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("识别考试失败 {}", e.getMessage()); log.error("识别考试失败 {}", e.getMessage());
if (ansSheetPath != null) {
new File(ansSheetPath).delete();
}
examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDO.getId(), e.getMessage()); examWordsJudgeResultDOMapper.updateMsg(examWordsJudgeResultDO.getId(), e.getMessage());
} }
} }

View File

@@ -15,6 +15,7 @@ import org.opencv.imgproc.Imgproc;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte; import java.awt.image.DataBufferByte;
import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@@ -31,31 +32,13 @@ public class PngUtil {
// 获取起始坐标 // 获取起始坐标
public static List<CoordinatesXY> analysisXY(String imagePath) { public static List<CoordinatesXY> analysisXY(String imagePath) {
Mat binary = image2BinaryMath(imagePath);
Mat src = Imgcodecs.imread(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. 查找轮廓 // 4. 查找轮廓
List<MatOfPoint> contours = new ArrayList<>(); List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat(); Mat hierarchy = new Mat();
// RETR_EXTERNAL 只检测最外层轮廓,忽略块内部可能存在的噪点
Imgproc.findContours(binary, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE); 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("检测到的轮廓总数: " + contours.size());
System.out.println("------------------------------------------------"); System.out.println("------------------------------------------------");
@@ -91,36 +74,31 @@ public class PngUtil {
// 可选:在原图上画出框,用于调试验证 // 可选:在原图上画出框,用于调试验证
Imgproc.rectangle(src, rect, new Scalar(0, 0, 255), 2); // 红色框 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); 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 + " 个黑色块。"); System.out.println("找到 " + blockCount + " 个黑色块。");
// 计算起始坐标 // 计算起始坐标
list.sort(Comparator.comparingInt(CoordinatesXY::getX)); 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; return list;
} }
// 获取(未背熟)单词的 id // 获取(未背熟)单词的 id
public static List<Integer> analyzePngForUnmemorizedWordIds(String filePath, List<Integer> wordIds, List<CoordinatesXY> coordinatesXYList) { public static List<Integer> analyzePngForUnmemorizedWordIds(String filePath, List<Integer> wordIds, List<CoordinatesXY> coordinatesXYList) {
Mat src = Imgcodecs.imread(filePath);
if (src.empty()) {
log.error("无法读取图片,请检查路径: {}", filePath);
throw new RuntimeException("无法读取图片");
}
Mat gray = new Mat(); Mat binary = image2BinaryMath(filePath);
Mat binary = new Mat();
try { 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<Integer> answer = new ArrayList<>(); List<Integer> answer = new ArrayList<>();
int words_index = 0; int words_index = 0;
@@ -150,8 +128,8 @@ public class PngUtil {
Rect rect = new Rect(currentX + 1, currentY + 1, width - 2, height - 2); Rect rect = new Rect(currentX + 1, currentY + 1, width - 2, height - 2);
Mat region = binary.submat(rect); Mat region = binary.submat(rect);
int countNonZero = Core.countNonZero(region); int countNonZero = Core.countNonZero(region);
log.info("当前位置为 words_index={},坐标为 x={} y={} 当前区域非零像素数: {}", words_index, currentX, currentY, countNonZero);
if (countNonZero > 370) { if (countNonZero > 1000) {
Integer id = wordIds.get(words_index); Integer id = wordIds.get(words_index);
answer.add(id); answer.add(id);
log.info("检测到标记未背熟ID={}, 当前坐标 x = {} y = {} ", id, currentX + 1, currentY + 1); log.info("检测到标记未背熟ID={}, 当前坐标 x = {} y = {} ", id, currentX + 1, currentY + 1);
@@ -170,8 +148,6 @@ public class PngUtil {
} finally { } finally {
src.release();
gray.release();
binary.release(); binary.release();
} }
} }
@@ -191,21 +167,21 @@ public class PngUtil {
Rect roiRect = new Rect(0, 0, left.getX(), left.getY()); Rect roiRect = new Rect(0, 0, left.getX(), left.getY());
Mat roi = new Mat(src, roiRect); Mat roi = new Mat(src, roiRect);
// 3. 图像预处理 (提高 OCR 准确率) // // 3. 图像预处理 (提高 OCR 准确率)
// 3.1 转为灰度图 // // 3.1 转为灰度图
Mat gray = new Mat(); // Mat gray = new Mat();
Imgproc.cvtColor(roi, gray, Imgproc.COLOR_BGR2GRAY); // Imgproc.cvtColor(roi, gray, Imgproc.COLOR_BGR2GRAY);
//
// 3.2 二值化 (Thresholding) // // 3.2 二值化 (Thresholding)
// 使用 OTSU 算法自动寻找最佳阈值,或者手动指定阈值 // // 使用 OTSU 算法自动寻找最佳阈值,或者手动指定阈值
Mat binary = new Mat(); // Mat binary = new Mat();
Imgproc.threshold(gray, binary, 0, 255, Imgproc.THRESH_BINARY | Imgproc.THRESH_OTSU); // 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 使用) // 4. 将 OpenCV Mat 转换为 BufferedImage (供 Tess4J 使用)
BufferedImage processedImage = matToBufferedImage(binary); BufferedImage processedImage = matToBufferedImage(src);
// 5. 使用 Tesseract 进行 OCR 识别 // 5. 使用 Tesseract 进行 OCR 识别
ITesseract instance = new Tesseract(); ITesseract instance = new Tesseract();
@@ -230,6 +206,50 @@ public class PngUtil {
return null; 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) { private static @NonNull StudentExamId getStudentExamId(Pattern pattern, String result) {
Matcher matcher = pattern.matcher(result); Matcher matcher = pattern.matcher(result);
StudentExamId studentExamId = new StudentExamId(0, 0); StudentExamId studentExamId = new StudentExamId(0, 0);

View File

@@ -39,7 +39,7 @@ templates:
weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v3.docx weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v3.docx
plan_day: 7 plan_day: 7
tmp: tmp:
png: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\tmp\png\ png:
ai: ai:
key: app-loC6IrJpj4cS54MAYp73QtGl key: app-loC6IrJpj4cS54MAYp73QtGl

View File

@@ -31,15 +31,15 @@ spring:
templates: templates:
word: assessment_v7.docx word: assessment_v9.docx
count: 100 count: 100
data: eng.traineddata data:
plan: plan:
weekday: tem_study_plan_v6.docx weekday: tem_study_plan_v7.docx
weekend: study_plan_review_v2.docx weekend: study_plan_review_v3.docx
plan_day: 7 plan_day: 7
tmp: tmp:
png: tmp\png\ png:
ai: ai:
key: app-loC6IrJpj4cS54MAYp73QtGl key: app-loC6IrJpj4cS54MAYp73QtGl

View File

@@ -3,8 +3,11 @@ server:
spring: spring:
profiles: profiles:
active: dev # 默认激活 dev 本地开发环境 active: pro # 默认激活 dev 本地开发环境
servlet:
multipart:
max-file-size: 30MB
max-request-size: 30MB
mybatis: mybatis:
# MyBatis xml 配置文件路径 # MyBatis xml 配置文件路径
mapper-locations: classpath:/mapper/**/*.xml mapper-locations: classpath:/mapper/**/*.xml