diff --git a/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java b/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java index 8acfad7..2a69d56 100644 --- a/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java +++ b/src/main/java/com/project/classicpaper/application/ClassicPaperSetApplicationService.java @@ -4,6 +4,7 @@ import com.project.base.domain.result.PageResult; import com.project.base.domain.result.Result; import com.project.classicpaper.domain.dto.ClassicPaperSetDTO; import com.project.classicpaper.domain.param.ClassicPaperSetParam; +import org.springframework.web.multipart.MultipartFile; public interface ClassicPaperSetApplicationService { @@ -33,4 +34,14 @@ public interface ClassicPaperSetApplicationService { * 更新分值比例,同时计算每道题的分值 */ Result save(ClassicPaperSetDTO request) throws Exception; + + /** + * 从 Word 文件导入历史套卷 + * + * @param file Word 文档 + * @param classicCategoryId 套题二级分类ID + * @param paperName 套卷名称(可选,为空时取 Word 标题) + * @return 套题组详情 + */ + Result importFromWord(MultipartFile file, Long classicCategoryId, String paperName) throws Exception; } diff --git a/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java b/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java index 6fffc42..a968993 100644 --- a/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java +++ b/src/main/java/com/project/classicpaper/application/impl/ClassicPaperSetApplicationServiceImpl.java @@ -21,6 +21,7 @@ import com.project.classicpaper.domain.param.ClassicPaperSetParam; import com.project.classicpaper.domain.service.*; import com.project.question.domain.entity.QuestionEntity; import com.project.question.domain.service.QuestionBaseService; +import org.springframework.web.multipart.MultipartFile; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,6 +55,9 @@ public class ClassicPaperSetApplicationServiceImpl implements ClassicPaperSetApp @Autowired private RegenerateQuestionTaskBaseService regenerateQuestionTaskBaseService; + @Autowired + private ImportClassicPaperDomainService importClassicPaperDomainService; + @Autowired private com.project.task.config.ExamScoreRatioConfig examScoreRatioConfig; @@ -212,6 +216,13 @@ public class ClassicPaperSetApplicationServiceImpl implements ClassicPaperSetApp return getDetail(request.getId(), null); } + @Override + @Transactional(rollbackFor = Exception.class) + public Result importFromWord(MultipartFile file, Long classicCategoryId, String paperName) throws Exception { + ClassicPaperSetDTO setDTO = importClassicPaperDomainService.importFromWord(file, classicCategoryId, paperName); + return Result.success(setDTO); + } + /** * 计算分值比例并填充到 DTO 的四个独立字段 * 优先取实体已存储的 scoreRatio JSON,否则从 ExamScoreRatioConfig 计算 diff --git a/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java b/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java index 0f253c1..a23b5ea 100644 --- a/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java +++ b/src/main/java/com/project/classicpaper/controller/ClassicPaperSetController.java @@ -9,6 +9,7 @@ import com.project.operation.annotation.OperationLog; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @Slf4j @@ -34,6 +35,22 @@ public class ClassicPaperSetController { return classicPaperSetApplicationService.getDetail(param.getId(), param.getPaperId()); } + /** + * 从 Word 文件导入历史套卷 + * 解析 Word 文档 → 提取题目(含图片上传 MinIO) → 创建套题组(setCount=1) + * + * @param file Word 文件(.docx) + * @param classicCategoryId 套题二级分类ID + * @param paperName 套卷名称(可选,为空时取 Word 文档标题) + */ + @PostMapping("/importWord") + @OperationLog(module = "经典套题") + public Result importFromWord(@RequestParam("file") MultipartFile file, + @RequestParam("classicCategoryId") Long classicCategoryId, + @RequestParam(value = "paperName", required = false) String paperName) throws Exception { + return classicPaperSetApplicationService.importFromWord(file, classicCategoryId, paperName); + } + @PostMapping("/delete") @OperationLog(module = "经典套题") public Result delete(Long setId) throws Exception { diff --git a/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java b/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java index ec383d0..6b4c405 100644 --- a/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java +++ b/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperQuestionDTO.java @@ -3,6 +3,8 @@ package com.project.classicpaper.domain.dto; import com.project.question.domain.entity.QuestionEntity; import lombok.Data; +import java.util.List; + @Data public class ClassicPaperQuestionDTO { /** 关联记录ID(ClassicPaperQuestionEntity 主键),编辑暂存时用于定位要修改的排序/替换关系 */ @@ -30,4 +32,7 @@ public class ClassicPaperQuestionDTO { * 题目详情(从 QuestionEntity 关联查询) */ private QuestionEntity.QuestionDetail questionDetail; + + /** 题目中的图片URL列表(图片从题干和选项中提取,按出现顺序排列) */ + private List images; } diff --git a/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperSetDTO.java b/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperSetDTO.java index 09dc17e..cc0ad32 100644 --- a/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperSetDTO.java +++ b/src/main/java/com/project/classicpaper/domain/dto/ClassicPaperSetDTO.java @@ -50,6 +50,9 @@ public class ClassicPaperSetDTO extends BaseDTO { private String statusText; + /** 导入原始文件MinIO路径(Word导入时记录) */ + private String sourceFileName; + /** 单选题分值比例(数量为0时null,未设置时取配置默认值) */ private Integer singleChoiceScoreRatio; diff --git a/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperSetEntity.java b/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperSetEntity.java index 734014a..e287b7e 100644 --- a/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperSetEntity.java +++ b/src/main/java/com/project/classicpaper/domain/entity/ClassicPaperSetEntity.java @@ -84,4 +84,8 @@ public class ClassicPaperSetEntity extends BaseEntity { @TableField("status") @Comment("状态:0-生成中,1-草稿,2-已确认,3-已废弃") private Integer status; + + @Column(name = "source_file_name", columnDefinition = "varchar(500) comment '导入原始文件MinIO路径(Word导入时记录)'") + @TableField("source_file_name") + private String sourceFileName; } diff --git a/src/main/java/com/project/classicpaper/domain/service/ImportClassicPaperDomainService.java b/src/main/java/com/project/classicpaper/domain/service/ImportClassicPaperDomainService.java index 1fdbc5c..f1981dc 100644 --- a/src/main/java/com/project/classicpaper/domain/service/ImportClassicPaperDomainService.java +++ b/src/main/java/com/project/classicpaper/domain/service/ImportClassicPaperDomainService.java @@ -14,10 +14,10 @@ public interface ImportClassicPaperDomainService { * 解析 Word → 提取题目(含图片) → 创建套题组(setCount=1) * 导入的题目 sourceType = Historical_Import(2),无知识点关联 * - * @param file Word 文件 - * @param subLineId 子产品线ID - * @param paperName 套卷名称 + * @param file Word 文件 + * @param classicCategoryId 套题二级分类ID + * @param paperName 套卷名称(为空时取 Word 标题) * @return 套题组详情 */ - ClassicPaperSetDTO importFromWord(MultipartFile file, Long subLineId, String paperName) throws Exception; + ClassicPaperSetDTO importFromWord(MultipartFile file, Long classicCategoryId, String paperName) throws Exception; } diff --git a/src/main/java/com/project/classicpaper/domain/service/impl/ImportClassicPaperDomainServiceImpl.java b/src/main/java/com/project/classicpaper/domain/service/impl/ImportClassicPaperDomainServiceImpl.java index de426fd..9c5cb7e 100644 --- a/src/main/java/com/project/classicpaper/domain/service/impl/ImportClassicPaperDomainServiceImpl.java +++ b/src/main/java/com/project/classicpaper/domain/service/impl/ImportClassicPaperDomainServiceImpl.java @@ -1,6 +1,7 @@ package com.project.classicpaper.domain.service.impl; import com.project.classicpaper.domain.dto.ClassicPaperDTO; +import com.project.classicpaper.domain.dto.ClassicPaperQuestionDTO; import com.project.classicpaper.domain.dto.ClassicPaperSetDTO; import com.project.classicpaper.domain.entity.ClassicPaperEntity; import com.project.classicpaper.domain.entity.ClassicPaperQuestionEntity; @@ -14,7 +15,9 @@ import com.project.classicpaper.domain.service.ImportClassicPaperDomainService; import com.project.classicpaper.domain.service.parser.WordQuestionParser; import com.project.information.utils.MinIoUtils; import com.project.question.domain.dto.QuestionDTO; +import com.project.question.domain.entity.QuestionEntity; import com.project.question.domain.enums.QuestionSourceTypeEnum; +import com.project.question.domain.service.QuestionBaseService; import com.project.question.domain.service.SaveQuestionDomainService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -23,9 +26,14 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.io.ByteArrayInputStream; +import java.io.InputStream; /** * 历史套卷导入服务实现 @@ -48,17 +56,32 @@ public class ImportClassicPaperDomainServiceImpl implements ImportClassicPaperDo @Autowired private SaveQuestionDomainService saveQuestionDomainService; + @Autowired + private QuestionBaseService questionBaseService; + @Autowired private MinIoUtils minIoUtils; @Override @Transactional(rollbackFor = Exception.class) - public ClassicPaperSetDTO importFromWord(MultipartFile file, Long subLineId, String paperName) throws Exception { - log.info(">>> [历史导入] 开始导入套卷, fileName={}, subLineId={}", file.getOriginalFilename(), subLineId); + public ClassicPaperSetDTO importFromWord(MultipartFile file, Long classicCategoryId, String paperName) throws Exception { + String originalFileName = file.getOriginalFilename(); + log.info(">>> [历史导入] 开始导入套卷, fileName={}, classicCategoryId={}", originalFileName, classicCategoryId); + + // 读取文件字节(inputStream 只能消费一次,先读入内存) + byte[] fileBytes = file.getBytes(); + + // 将原始 Word 文件上传到 MinIO 保存,用于后续溯源 + String minioFileName = String.format("classicpaper/%s/%s.docx", + java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + java.util.UUID.randomUUID().toString().replace("-", "")); + minIoUtils.uploadFile(new ByteArrayInputStream(fileBytes), minioFileName); + String sourceFileName = minIoUtils.getPublicUrl(minioFileName); + log.info(">>> [历史导入] 原始文件已上传 MinIO: {}", sourceFileName); // 1. 解析 Word 文件,提取题目列表(含图片上传 MinIO) WordQuestionParser parser = new WordQuestionParser(minIoUtils); - WordQuestionParser.ParseResult parseResult = parser.parse(file.getInputStream()); + WordQuestionParser.ParseResult parseResult = parser.parse(new ByteArrayInputStream(fileBytes)); List parsedQuestions = parseResult.getQuestions(); if (parsedQuestions.isEmpty()) { @@ -67,25 +90,43 @@ public class ImportClassicPaperDomainServiceImpl implements ImportClassicPaperDo String name = paperName != null ? paperName : parseResult.getTitle(); + // 统计各题型数量 + int singleChoiceNum = 0, multipleChoiceNum = 0, trueFalseNum = 0, shortAnswerNum = 0; + for (WordQuestionParser.ParsedQuestion pq : parsedQuestions) { + switch (pq.getQuestionType()) { + case 0 -> singleChoiceNum++; + case 1 -> multipleChoiceNum++; + case 2 -> trueFalseNum++; + case 4 -> shortAnswerNum++; + } + } + // 2. 创建套题组(Word导入只有1个版本) ClassicPaperSetEntity paperSet = new ClassicPaperSetEntity(); paperSet.setName(name); paperSet.setSourceType(ClassicPaperSourceTypeEnum.WORD_IMPORT.getValue()); - paperSet.setSubLineId(subLineId); + paperSet.setClassicCategoryId(classicCategoryId); + paperSet.setSourceFileName(sourceFileName); + paperSet.setDescription(name); paperSet.setSetCount(1); paperSet.setQuestionCount(parsedQuestions.size()); + paperSet.setSingleChoiceNum(singleChoiceNum); + paperSet.setMultipleChoiceNum(multipleChoiceNum); + paperSet.setTrueFalseNum(trueFalseNum); + paperSet.setShortAnswerNum(shortAnswerNum); paperSet.setStatus(ClassicPaperStatusEnum.DRAFT.getValue()); classicPaperSetBaseService.save(paperSet); // 3. 创建套卷(版本1) ClassicPaperEntity paper = new ClassicPaperEntity(); paper.setName(name); - paper.setSubLineId(subLineId); + paper.setDescription(name); paper.setSourceType(ClassicPaperSourceTypeEnum.WORD_IMPORT.getValue()); paper.setStatus(ClassicPaperStatusEnum.DRAFT.getValue()); paper.setQuestionCount(parsedQuestions.size()); paper.setSetId(paperSet.getId()); paper.setSetIndex(1); + paper.setClassicCategoryId(classicCategoryId); classicPaperBaseService.save(paper); // 3. 保存题目并创建关联 @@ -113,10 +154,25 @@ public class ImportClassicPaperDomainServiceImpl implements ImportClassicPaperDo detailDTO.setOptions(optionsMap.isEmpty() ? null : optionsMap); detailDTO.setRightAnswer(parsed.getAnswer()); detailDTO.setAnalysis(parsed.getAnalysis()); + + // 简答题得分点 + if (parsed.getScoringPoints() != null && !parsed.getScoringPoints().isEmpty()) { + List scoringPoints = new ArrayList<>(); + for (WordQuestionParser.ParsedScoringPoint sp : parsed.getScoringPoints()) { + QuestionDTO.ScoringPointDTO spDTO = new QuestionDTO.ScoringPointDTO(); + spDTO.setIndex(sp.getIndex()); + spDTO.setContent(sp.getContent()); + scoringPoints.add(spDTO); + } + detailDTO.setScoringPoints(scoringPoints); + } + questionDTO.setQuestionDetailDTO(detailDTO); - // 保存题目 - saveQuestionDomainService.save(questionDTO); + // 保存题目(直接调用 baseService 绕过 kpIdList 非空校验) + QuestionEntity entity = questionDTO.toEntity(QuestionEntity::new); + questionBaseService.save(entity); + questionDTO.setId(entity.getId()); // 创建套卷-题目关联 ClassicPaperQuestionEntity paperQuestion = new ClassicPaperQuestionEntity(); @@ -131,7 +187,7 @@ public class ImportClassicPaperDomainServiceImpl implements ImportClassicPaperDo log.info(">>> [历史导入] 导入完成, setId={}, paperId={}, 题目数={}", paperSet.getId(), paper.getId(), paperQuestions.size()); - // 5. 返回套题组详情 + // 5. 构建返回 DTO(含题目列表) ClassicPaperSetDTO setDTO = new ClassicPaperSetDTO(); setDTO.setId(paperSet.getId()); setDTO.setName(paperSet.getName()); @@ -139,6 +195,79 @@ public class ImportClassicPaperDomainServiceImpl implements ImportClassicPaperDo setDTO.setStatus(paperSet.getStatus()); setDTO.setSetCount(paperSet.getSetCount()); setDTO.setQuestionCount(paperSet.getQuestionCount()); + setDTO.setSourceFileName(paperSet.getSourceFileName()); + + // 查询题目详情并构建 questionList + List questionIds = paperQuestions.stream() + .map(ClassicPaperQuestionEntity::getQuestionId) + .collect(java.util.stream.Collectors.toList()); + List savedQuestions = questionBaseService.listByIds(questionIds); + if (savedQuestions != null && !savedQuestions.isEmpty()) { + Map questionMap = savedQuestions.stream() + .collect(java.util.stream.Collectors.toMap(QuestionEntity::getId, q -> q)); + List questionDTOList = paperQuestions.stream() + .map(pq -> { + ClassicPaperQuestionDTO qDto = new ClassicPaperQuestionDTO(); + qDto.setId(pq.getId()); + qDto.setPaperId(pq.getPaperId()); + qDto.setQuestionId(pq.getQuestionId()); + qDto.setSortOrder(pq.getSortOrder()); + qDto.setQuestionType(pq.getQuestionType()); + QuestionEntity qEntity = questionMap.get(pq.getQuestionId()); + if (qEntity != null && qEntity.getQuestionDetail() != null) { + // 深拷贝 questionDetail,避免修改实体缓存 + QuestionEntity.QuestionDetail detail = new QuestionEntity.QuestionDetail(); + detail.setQuestionContent(qEntity.getQuestionDetail().getQuestionContent()); + detail.setType(qEntity.getQuestionDetail().getType()); + detail.setOptions(qEntity.getQuestionDetail().getOptions() != null + ? new java.util.LinkedHashMap<>(qEntity.getQuestionDetail().getOptions()) : null); + detail.setRightAnswer(qEntity.getQuestionDetail().getRightAnswer()); + detail.setAnalysis(qEntity.getQuestionDetail().getAnalysis()); + detail.setScoringPoints(qEntity.getQuestionDetail().getScoringPoints()); + + // 提取图片URL并替换为占位符 + List images = new ArrayList<>(); + if (detail.getQuestionContent() != null) { + detail.setQuestionContent(extractImages(detail.getQuestionContent(), images)); + } + if (detail.getOptions() != null) { + Map newOptions = new java.util.LinkedHashMap<>(); + for (Map.Entry entry : detail.getOptions().entrySet()) { + newOptions.put(entry.getKey(), extractImages(entry.getValue(), images)); + } + detail.setOptions(newOptions); + } + qDto.setImages(images); + qDto.setQuestionDetail(detail); + } + return qDto; + }) + .collect(java.util.stream.Collectors.toList()); + setDTO.setQuestionList(questionDTOList); + } + return setDTO; } + + /** + * 从文本中提取 标签的 URL,替换为 【【img:N】】 占位符 + * 按出现顺序收集到 images 列表中 + */ + private String extractImages(String text, List images) { + if (text == null || text.isBlank()) return text; + Pattern imgPattern = Pattern.compile(""); + Matcher matcher = imgPattern.matcher(text); + StringBuilder sb = new StringBuilder(); + int lastEnd = 0; + while (matcher.find()) { + sb.append(text, lastEnd, matcher.start()); + String url = matcher.group(1); + int index = images.size(); + images.add(url); + sb.append("【【img:").append(index).append("】】"); + lastEnd = matcher.end(); + } + sb.append(text.substring(lastEnd)); + return sb.toString(); + } } diff --git a/src/main/java/com/project/classicpaper/domain/service/parser/WordQuestionParser.java b/src/main/java/com/project/classicpaper/domain/service/parser/WordQuestionParser.java index 888b703..c3a3246 100644 --- a/src/main/java/com/project/classicpaper/domain/service/parser/WordQuestionParser.java +++ b/src/main/java/com/project/classicpaper/domain/service/parser/WordQuestionParser.java @@ -20,10 +20,12 @@ import java.util.regex.Pattern; @Slf4j public class WordQuestionParser { - private static final Pattern QUESTION_PATTERN = Pattern.compile("^\\d+[..\\u3001]"); + // 题号匹配:仅 ASCII 数字,排除 Unicode 圈码数字(①②③) + private static final Pattern QUESTION_PATTERN = Pattern.compile("^[0-9]+[..\\u3001]"); private static final Pattern OPTION_PATTERN = Pattern.compile("^[A-Z][..\\u3001::、]\\s*"); private static final Pattern ANSWER_PATTERN = Pattern.compile("^答案[::\\u3001]\\s*(.*)"); private static final Pattern ANALYSIS_PATTERN = Pattern.compile("^解析[::\\u3001]\\s*(.*)"); + private static final Pattern SCORING_POINT_PATTERN = Pattern.compile("^得分点[::\\u3001]\\s*(.*)"); private final MinIoUtils minIoUtils; @@ -31,6 +33,8 @@ public class WordQuestionParser { private final List questions = new ArrayList<>(); private ParsedQuestion currentQuestion; private int imageCounter = 0; + /** 是否正在解析得分点段落(得分点触发后,后续段落归入得分点内容) */ + private boolean parsingScoringPoints = false; public WordQuestionParser(MinIoUtils minIoUtils) { this.minIoUtils = minIoUtils; @@ -76,10 +80,18 @@ public class WordQuestionParser { // 题号匹配 if (QUESTION_PATTERN.matcher(text).find()) { + // 如果正在解析得分点,且题干极短,说明是得分点子项(如"1、两块纳米黑板") + // 归入最后一个得分点而非新题目 + if (parsingScoringPoints && currentQuestion != null + && !currentQuestion.scoringPoints.isEmpty()) { + accumulateScoringPointContent(text, currentQuestion); + return; + } if (currentQuestion != null) { validateAndSave(currentQuestion); } currentQuestion = new ParsedQuestion(); + parsingScoringPoints = false; // 新题目,重置得分点状态 // 去掉题号 currentQuestion.stem = QUESTION_PATTERN.matcher(text).replaceFirst("").trim(); currentQuestion.questionType = detectQuestionType(currentQuestion.stem); @@ -90,6 +102,12 @@ public class WordQuestionParser { return; } + // 正在解析得分点段落:后续内容归入最后一个得分点 + if (parsingScoringPoints) { + accumulateScoringPointContent(text, currentQuestion); + return; + } + // 答案行 Matcher answerMatcher = ANSWER_PATTERN.matcher(text); if (answerMatcher.matches()) { @@ -107,6 +125,15 @@ public class WordQuestionParser { return; } + // 得分点行(格式:得分点:得分点1. xxx 得分点2. xxx) + Matcher scoringPointMatcher = SCORING_POINT_PATTERN.matcher(text); + if (scoringPointMatcher.matches()) { + parsingScoringPoints = true; // 后续段落归入得分点 + String pointsContent = scoringPointMatcher.group(1).trim(); + parseScoringPoints(pointsContent, currentQuestion); + return; + } + // 选项行 Matcher optionMatcher = OPTION_PATTERN.matcher(text); if (optionMatcher.find()) { @@ -117,7 +144,7 @@ public class WordQuestionParser { } // 独立图片段落:合并到题干或最后一个选项 - if (text.startsWith("[IMAGE:") && currentQuestion.answer == null) { + if (text.startsWith("", imageUrl)); @@ -174,9 +201,10 @@ public class WordQuestionParser { } private String sanitizeText(String text) { - if (text == null) return ""; + if (text == null) { + return ""; + } return text - .replace('、', ' ') // 顿号 .replace(' ', ' ') // 不间断空格 .replace('​', ' ') // 零宽空格 .replace('', ' ') // BOM @@ -188,10 +216,18 @@ public class WordQuestionParser { * 检测题型 */ private int detectQuestionType(String stem) { - if (stem.contains("[判断题]")) return 2; // TRUE_FALSE - if (stem.contains("[简答题]")) return 4; // SHORT_ANSWER - if (stem.contains("[多选题]")) return 1; // MULTIPLE_CHOICE - if (stem.contains("[单选题]")) return 0; // SINGLE_CHOICE + if (stem.contains("[判断题]")) { + return 2; // TRUE_FALSE + } + if (stem.contains("[简答题]")) { + return 4; // SHORT_ANSWER + } + if (stem.contains("[多选题]")) { + return 1; // MULTIPLE_CHOICE + } + if (stem.contains("[单选题]")) { + return 0; // SINGLE_CHOICE + } return 0; // 默认单选 } @@ -200,11 +236,92 @@ public class WordQuestionParser { log.warn(">>> [Word解析] 题目内容为空,跳过"); return; } + // 简答题的得分点子段(如"智慧黑板"、"1)...")可能被误识别为题号 + // 跳过题干过短的疑似子段 + if (q.questionType == 4 && q.stem.length() < 10) { + log.warn(">>> [Word解析] 跳过疑似得分点子段: {}", q.stem); + return; + } // 清理题型标签 q.stem = q.stem.replaceAll("\\[.*?题\\]", "").trim(); questions.add(q); } + /** + * 将后续段落内容归入得分点(得分点跨段落时使用) + */ + private void accumulateScoringPointContent(String text, ParsedQuestion question) { + // 检查是否是新得分点标题(如:得分点3. xxx) + Matcher m = Pattern.compile("得分点([0-9]+)[..、]\\s*(.*)").matcher(text); + if (m.matches()) { + // 新得分点:通过 parseScoringPoints 处理(它会处理保存逻辑) + // 这里把单条"得分点N. 内容"包装后调用 + parseScoringPoints(text, question); + return; + } + + // 普通内容:追加到最后一个得分点 + if (!question.scoringPoints.isEmpty()) { + ParsedScoringPoint last = question.scoringPoints.get(question.scoringPoints.size() - 1); + if (last.getContent() != null && !last.getContent().isEmpty()) { + last.setContent(last.getContent() + " " + text); + } else { + last.setContent(text); + } + } + // 如果还没有得分点(异常情况),忽略 + } + + /** + * 解析得分点行文本,提取结构化得分点列表 + * 格式:得分点:得分点1. 内容 得分点2. 内容 + * 以"得分点N."作为分隔符,每段内容归入对应序号 + */ + private void parseScoringPoints(String text, ParsedQuestion question) { + // 以"得分点N."为分隔符分割 + String[] parts = text.split("(?=得分点[0-9]+[..、])"); + Integer currentIndex = null; + StringBuilder currentContent = new StringBuilder(); + + for (String part : parts) { + part = part.trim(); + if (part.isBlank()) { + continue; + } + + Matcher m = Pattern.compile("得分点([0-9]+)[..、]\\s*(.*)").matcher(part); + if (m.matches()) { + // 保存上一条得分点 + if (currentIndex != null && currentContent.length() > 0) { + ParsedScoringPoint point = new ParsedScoringPoint(); + point.setIndex(currentIndex); + point.setContent(currentContent.toString().trim()); + question.scoringPoints.add(point); + } + currentIndex = Integer.parseInt(m.group(1)); + String content = m.group(2).trim(); + currentContent = new StringBuilder(content); + } else { + // 普通内容,追加到当前得分点 + if (currentIndex != null) { + if (currentContent.length() > 0) { + currentContent.append(' '); + } + currentContent.append(part); + } + } + } + // 保存最后一条得分点 + if (currentIndex != null && currentContent.length() > 0) { + ParsedScoringPoint point = new ParsedScoringPoint(); + point.setIndex(currentIndex); + point.setContent(currentContent.toString().trim()); + question.scoringPoints.add(point); + } + + log.debug(">>> [Word解析] 得分点解析完成, count={}", question.scoringPoints.size()); + } + // =============== 内部数据结构 =============== @Data @@ -221,6 +338,8 @@ public class WordQuestionParser { List options = new ArrayList<>(); String answer; String analysis; + /** 简答题得分点列表 */ + List scoringPoints = new ArrayList<>(); } @Data @@ -233,4 +352,10 @@ public class WordQuestionParser { this.content = content; } } + + @Data + public static class ParsedScoringPoint { + private int index; + private String content; + } } diff --git a/src/main/java/com/project/information/utils/MinIoUtils.java b/src/main/java/com/project/information/utils/MinIoUtils.java index a008fc6..9fe5457 100644 --- a/src/main/java/com/project/information/utils/MinIoUtils.java +++ b/src/main/java/com/project/information/utils/MinIoUtils.java @@ -123,6 +123,19 @@ public class MinIoUtils { return ""; } } + + /** + * 生成公开访问链接(桶为 public 时使用,无需签名) + * 格式:{tempAccessFileUrl}/{bucket}/{objectName} + * @param objectName 对象名称(含路径,如 classicpaper/20260629/uuid.png) + * @return 公开访问URL + */ + public String getPublicUrl(String objectName) { + if (StrUtil.isBlank(minioProperties.getTempAccessFileUrl())) { + return ""; + } + return minioProperties.getTempAccessFileUrl() + "/" + minioProperties.getBucket() + "/" + objectName; + } public StreamInfoDTO mergeMultipartUpload(String objectName, String uploadId) { try { //单文件,不需要合并 diff --git a/src/main/java/com/project/interaction/domain/service/impl/SaveDataExtractResultDomainServiceImpl.java b/src/main/java/com/project/interaction/domain/service/impl/SaveDataExtractResultDomainServiceImpl.java index 0b0424d..3f8850d 100644 --- a/src/main/java/com/project/interaction/domain/service/impl/SaveDataExtractResultDomainServiceImpl.java +++ b/src/main/java/com/project/interaction/domain/service/impl/SaveDataExtractResultDomainServiceImpl.java @@ -33,8 +33,8 @@ public class SaveDataExtractResultDomainServiceImpl implements SaveDataExtractRe Long fileId = callback.getFileId(); Long informationId = callback.getInformationId(); - log.info(">>> [数据服务回调] 收到子文件解析回调, fileId={}, informationId={}, status={}", - fileId, informationId, callback.getParseStatus()); + log.info(">>> [数据服务回调] 收到子文件解析回调, fileId={}, informationId={}, status={} , parsedText={} , errorMsg={} ", + fileId, informationId, callback.getParseStatus() , callback.getParsedText() , callback.getErrorMsg()); // 1. 更新子文件解析状态 InformationFileEntity file = informationFileBaseService.getById(fileId); diff --git a/src/main/java/com/project/operation/config/RedisKeyConstant.java b/src/main/java/com/project/operation/config/RedisKeyConstant.java new file mode 100644 index 0000000..13519a6 --- /dev/null +++ b/src/main/java/com/project/operation/config/RedisKeyConstant.java @@ -0,0 +1,7 @@ +package com.project.operation.config; + +public class RedisKeyConstant { + + public static final String OPERATION_LOG_SWITCH = "operation:log:switch"; + +} diff --git a/src/main/java/com/project/operation/controller/OperationLogController.java b/src/main/java/com/project/operation/controller/OperationLogController.java index ddc92e9..1ec7422 100644 --- a/src/main/java/com/project/operation/controller/OperationLogController.java +++ b/src/main/java/com/project/operation/controller/OperationLogController.java @@ -3,18 +3,23 @@ package com.project.operation.controller; import com.project.base.domain.result.PageResult; import com.project.base.domain.result.Result; import com.project.operation.application.impl.OperationLogApplicationService; +import com.project.operation.config.RedisKeyConstant; import com.project.operation.domain.dto.OperationLogDTO; import com.project.operation.domain.param.OperationLogParam; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.web.bind.annotation.*; import jakarta.servlet.http.HttpServletResponse; @RestController @RequestMapping("/api/admin/operationLog") public class OperationLogController { + + @Autowired + private StringRedisTemplate redisTemplate; // 使用 RedisTemplate 操作 Redis + + @Autowired private OperationLogApplicationService operationLogApplicationService; @@ -27,4 +32,15 @@ public class OperationLogController { public void export(HttpServletResponse response,OperationLogParam param){ operationLogApplicationService.export(response,param); } + + @PostMapping("/setSwitch") + public Result setSwitch(@RequestParam Boolean isOpen) { + redisTemplate.opsForValue().set(RedisKeyConstant.OPERATION_LOG_SWITCH, String.valueOf(isOpen)); + return Result.success("修改成功"); + } + + @GetMapping("/getSwitch") + public Result getSwitch() { + return Result.success(redisTemplate.opsForValue().get(RedisKeyConstant.OPERATION_LOG_SWITCH)); + } }