diff --git a/src/main/java/com/project/classicpaper/domain/service/ImportClassicPaperDomainService.java b/src/main/java/com/project/classicpaper/domain/service/ImportClassicPaperDomainService.java new file mode 100644 index 0000000..5bc2f72 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/service/ImportClassicPaperDomainService.java @@ -0,0 +1,23 @@ +package com.project.classicpaper.domain.service; + +import com.project.classicpaper.domain.dto.ClassicPaperDTO; +import org.springframework.web.multipart.MultipartFile; + +/** + * 历史套卷导入服务 + * 支持导入旧系统的 Word 历史套卷直接入库 + */ +public interface ImportClassicPaperDomainService { + + /** + * 从 Word 文件导入经典套卷 + * 解析 Word → 提取题目(含图片) → 创建套卷关联 + * 导入的题目 sourceType = Historical_Import(2),无知识点关联 + * + * @param file Word 文件 + * @param subLineId 子产品线ID + * @param paperName 套卷名称 + * @return 套卷详情 + */ + ClassicPaperDTO importFromWord(MultipartFile file, Long subLineId, 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 new file mode 100644 index 0000000..d341ce7 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/service/impl/ImportClassicPaperDomainServiceImpl.java @@ -0,0 +1,123 @@ +package com.project.classicpaper.domain.service.impl; + +import com.project.classicpaper.domain.dto.ClassicPaperDTO; +import com.project.classicpaper.domain.entity.ClassicPaperEntity; +import com.project.classicpaper.domain.entity.ClassicPaperQuestionEntity; +import com.project.classicpaper.domain.enums.ClassicPaperSourceTypeEnum; +import com.project.classicpaper.domain.enums.ClassicPaperStatusEnum; +import com.project.classicpaper.domain.service.ClassicPaperBaseService; +import com.project.classicpaper.domain.service.ClassicPaperQuestionBaseService; +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.enums.QuestionSourceTypeEnum; +import com.project.question.domain.service.SaveQuestionDomainService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * 历史套卷导入服务实现 + * 解析 Word → 提取题目(含图片上传 MinIO) → 创建套卷关联 + * 导入的题目 sourceType = Historical_Import(2),无知识点关联 + */ +@Service +@Slf4j +public class ImportClassicPaperDomainServiceImpl implements ImportClassicPaperDomainService { + + @Autowired + private ClassicPaperBaseService classicPaperBaseService; + + @Autowired + private ClassicPaperQuestionBaseService classicPaperQuestionBaseService; + + @Autowired + private SaveQuestionDomainService saveQuestionDomainService; + + @Autowired + private MinIoUtils minIoUtils; + + @Override + @Transactional(rollbackFor = Exception.class) + public ClassicPaperDTO importFromWord(MultipartFile file, Long subLineId, String paperName) throws Exception { + log.info(">>> [历史导入] 开始导入套卷, fileName={}, subLineId={}", file.getOriginalFilename(), subLineId); + + // 1. 解析 Word 文件,提取题目列表(含图片上传 MinIO) + WordQuestionParser parser = new WordQuestionParser(minIoUtils); + WordQuestionParser.ParseResult parseResult = parser.parse(file.getInputStream()); + List parsedQuestions = parseResult.getQuestions(); + + if (parsedQuestions.isEmpty()) { + log.warn(">>> [历史导入] Word 文件中未提取到题目"); + } + + // 2. 创建套卷 + ClassicPaperEntity paper = new ClassicPaperEntity(); + paper.setName(paperName != null ? paperName : parseResult.getTitle()); + paper.setSubLineId(subLineId); + paper.setSourceType(ClassicPaperSourceTypeEnum.WORD_IMPORT.getValue()); + paper.setStatus(ClassicPaperStatusEnum.DRAFT.getValue()); + paper.setQuestionCount(parsedQuestions.size()); + classicPaperBaseService.save(paper); + + // 3. 保存题目并创建关联 + List paperQuestions = new ArrayList<>(); + for (int i = 0; i < parsedQuestions.size(); i++) { + WordQuestionParser.ParsedQuestion parsed = parsedQuestions.get(i); + + // 构建选项 Map + Map optionsMap = new TreeMap<>(); + if (parsed.getOptions() != null) { + for (WordQuestionParser.ParsedOption opt : parsed.getOptions()) { + optionsMap.put(opt.getLabel(), opt.getContent()); + } + } + + // 创建题目(sourceType = Historical_Import,无知识点关联) + QuestionDTO questionDTO = new QuestionDTO(); + questionDTO.setSourceType(QuestionSourceTypeEnum.Historical_Import.getValue()); + questionDTO.setQuestionType(parsed.getQuestionType()); + questionDTO.setKpIdList(new ArrayList<>()); // 无知识点关联 + + QuestionDTO.QuestionDetailDTO detailDTO = new QuestionDTO.QuestionDetailDTO(); + detailDTO.setQuestionContent(parsed.getStem()); + detailDTO.setType(parsed.getQuestionType()); + detailDTO.setOptions(optionsMap.isEmpty() ? null : optionsMap); + detailDTO.setRightAnswer(parsed.getAnswer()); + detailDTO.setAnalysis(parsed.getAnalysis()); + questionDTO.setQuestionDetailDTO(detailDTO); + + // 保存题目 + saveQuestionDomainService.save(questionDTO); + + // 创建套卷-题目关联 + ClassicPaperQuestionEntity paperQuestion = new ClassicPaperQuestionEntity(); + paperQuestion.setPaperId(paper.getId()); + paperQuestion.setQuestionId(questionDTO.getId()); + paperQuestion.setSortOrder(i + 1); + paperQuestion.setQuestionType(parsed.getQuestionType()); + paperQuestions.add(paperQuestion); + } + + classicPaperQuestionBaseService.saveBatch(paperQuestions); + + log.info(">>> [历史导入] 导入完成, paperId={}, 题目数={}", paper.getId(), paperQuestions.size()); + + // 4. 返回套卷详情 + ClassicPaperDTO dto = new ClassicPaperDTO(); + dto.setId(paper.getId()); + dto.setName(paper.getName()); + dto.setSourceType(paper.getSourceType()); + dto.setStatus(paper.getStatus()); + dto.setQuestionCount(paper.getQuestionCount()); + return dto; + } +} 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 new file mode 100644 index 0000000..888b703 --- /dev/null +++ b/src/main/java/com/project/classicpaper/domain/service/parser/WordQuestionParser.java @@ -0,0 +1,236 @@ +package com.project.classicpaper.domain.service.parser; + +import com.project.information.utils.MinIoUtils; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.IdUtil; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.xwpf.usermodel.*; + +import java.io.InputStream; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Word 文档题目解析器 + * 逐段落状态机解析,提取题目、选项、答案、解析、图片 + * 图片上传 MinIO,占位符替换为 HTML img 标签 + */ +@Slf4j +public class WordQuestionParser { + + private static final Pattern QUESTION_PATTERN = Pattern.compile("^\\d+[..\\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 final MinIoUtils minIoUtils; + + private String title; + private final List questions = new ArrayList<>(); + private ParsedQuestion currentQuestion; + private int imageCounter = 0; + + public WordQuestionParser(MinIoUtils minIoUtils) { + this.minIoUtils = minIoUtils; + } + + /** + * 解析 Word 文档 + * @param inputStream Word 文件流 + * @return 解析结果 + */ + public ParseResult parse(InputStream inputStream) throws Exception { + try (XWPFDocument document = new XWPFDocument(inputStream)) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + processParagraph(paragraph); + } + // 保存最后一道题 + if (currentQuestion != null) { + validateAndSave(currentQuestion); + } + } + + ParseResult result = new ParseResult(); + result.setTitle(title); + result.setQuestions(questions); + return result; + } + + private void processParagraph(XWPFParagraph paragraph) { + // 提取图片并上传 MinIO,返回替换后的文本 + String text = extractImagesAndUpload(paragraph); + + if (text == null || text.isBlank()) { + return; + } + + text = text.trim(); + + // 第一个非空段落作为标题 + if (title == null) { + title = text; + return; + } + + // 题号匹配 + if (QUESTION_PATTERN.matcher(text).find()) { + if (currentQuestion != null) { + validateAndSave(currentQuestion); + } + currentQuestion = new ParsedQuestion(); + // 去掉题号 + currentQuestion.stem = QUESTION_PATTERN.matcher(text).replaceFirst("").trim(); + currentQuestion.questionType = detectQuestionType(currentQuestion.stem); + return; + } + + if (currentQuestion == null) { + return; + } + + // 答案行 + Matcher answerMatcher = ANSWER_PATTERN.matcher(text); + if (answerMatcher.matches()) { + currentQuestion.answer = answerMatcher.group(1).trim(); + if (currentQuestion.answer.length() > 1 && currentQuestion.questionType == 0) { + currentQuestion.questionType = 1; // 升级为多选 + } + return; + } + + // 解析行 + Matcher analysisMatcher = ANALYSIS_PATTERN.matcher(text); + if (analysisMatcher.matches()) { + currentQuestion.analysis = analysisMatcher.group(1).trim(); + return; + } + + // 选项行 + Matcher optionMatcher = OPTION_PATTERN.matcher(text); + if (optionMatcher.find()) { + String label = text.substring(0, 1); + String content = optionMatcher.replaceFirst("").trim(); + currentQuestion.options.add(new ParsedOption(label, content)); + return; + } + + // 独立图片段落:合并到题干或最后一个选项 + if (text.startsWith("[IMAGE:") && currentQuestion.answer == null) { + if (currentQuestion.options.isEmpty()) { + currentQuestion.stem += text; + } else { + ParsedOption lastOpt = currentQuestion.options.get(currentQuestion.options.size() - 1); + lastOpt.content += text; + } + return; + } + + // 其他文本:追加到题干 + if (currentQuestion.answer == null) { + currentQuestion.stem += "\n" + text; + } + } + + /** + * 提取段落中的图片,上传 MinIO,替换为 HTML img 标签 + */ + private String extractImagesAndUpload(XWPFParagraph paragraph) { + StringBuilder sb = new StringBuilder(); + + for (XWPFRun run : paragraph.getRuns()) { + String runText = run.text(); + if (runText != null) { + sb.append(sanitizeText(runText)); + } + + // 提取嵌入图片 + List pictures = run.getEmbeddedPictures(); + for (XWPFPicture picture : pictures) { + try { + imageCounter++; + // MinIO 路径规范:classicpaper/{yyyyMMdd}/{uuid}.png(不依赖 subLineId) + String fileName = String.format("classicpaper/%s/%s.png", + DateUtil.format(new java.util.Date(), "yyyyMMdd"), + IdUtil.fastSimpleUUID()); + byte[] imageData = picture.getPictureData().getData(); + + // 上传到 MinIO + minIoUtils.uploadFile(new java.io.ByteArrayInputStream(imageData), fileName); + String imageUrl = minIoUtils.getPreviewUrl(fileName); + + // 替换为 HTML img 标签 + sb.append(String.format("", imageUrl)); + log.debug(">>> [Word解析] 图片已上传 MinIO: {}", fileName); + } catch (Exception e) { + log.error(">>> [Word解析] 图片上传失败", e); + sb.append("[IMAGE:上传失败]"); + } + } + } + + return sb.toString().trim(); + } + + private String sanitizeText(String text) { + if (text == null) return ""; + return text + .replace('、', ' ') // 顿号 + .replace(' ', ' ') // 不间断空格 + .replace('​', ' ') // 零宽空格 + .replace('', ' ') // BOM + .replaceAll("\\s+", " ") + .trim(); + } + + /** + * 检测题型 + */ + 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 + return 0; // 默认单选 + } + + private void validateAndSave(ParsedQuestion q) { + if (q.stem == null || q.stem.isBlank()) { + log.warn(">>> [Word解析] 题目内容为空,跳过"); + return; + } + // 清理题型标签 + q.stem = q.stem.replaceAll("\\[.*?题\\]", "").trim(); + questions.add(q); + } + + // =============== 内部数据结构 =============== + + @Data + public static class ParseResult { + private String title; + private List questions = new ArrayList<>(); + } + + @Data + public static class ParsedQuestion { + /** 题型:0-单选,1-多选,2-判断,4-简答 */ + int questionType = 0; + String stem = ""; + List options = new ArrayList<>(); + String answer; + String analysis; + } + + @Data + public static class ParsedOption { + String label; + String content; + + public ParsedOption(String label, String content) { + this.label = label; + this.content = content; + } + } +} diff --git a/src/main/java/com/project/information/application/impl/GenerateFromFilesApplicationServiceImpl.java b/src/main/java/com/project/information/application/impl/GenerateFromFilesApplicationServiceImpl.java index 7e59668..03cc036 100644 --- a/src/main/java/com/project/information/application/impl/GenerateFromFilesApplicationServiceImpl.java +++ b/src/main/java/com/project/information/application/impl/GenerateFromFilesApplicationServiceImpl.java @@ -7,6 +7,7 @@ import com.project.information.application.GenerateFromFilesApplicationService; import com.project.information.domain.dto.UploadedFileDTO; import com.project.information.domain.entity.InformationEntity; import com.project.information.domain.entity.InformationFileEntity; +import com.project.information.domain.enums.ContentCategoryEnum; import com.project.information.domain.enums.DraftStatusEnum; import com.project.information.domain.enums.FileParseStatusEnum; import com.project.information.domain.enums.MaterialTypeEnum; @@ -18,6 +19,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.List; @@ -49,10 +51,32 @@ public class GenerateFromFilesApplicationServiceImpl implements GenerateFromFile if (param.getSubLineId() == null) { throw new BusinessErrorException("子产品线不能为空"); } - if (param.getFiles() == null || param.getFiles().isEmpty()) { + if (!StringUtils.hasText(param.getFileName())) { + throw new BusinessErrorException("文件名不能为空"); + } + + // 解析 files JSON 字符串 + List files = param.parseFiles(); + if (files.isEmpty()) { throw new BusinessErrorException("请先上传文件"); } + // 校验内容分类是否合法 + for (int i = 0; i < files.size(); i++) { + if (!ContentCategoryEnum.isValid(files.get(i).getContentCategory())) { + throw new BusinessErrorException("第" + (i + 1) + "个文件的内容分类不合法"); + } + } + + // 检查同子产品线下是否有重复文件名 + boolean exists = informationBaseService.lambdaQuery() + .eq(InformationEntity::getSubLineId, param.getSubLineId()) + .eq(InformationEntity::getName, param.getFileName()) + .exists(); + if (exists) { + throw new BusinessErrorException("该子产品线下已存在同名资料「" + param.getFileName() + "」,请修改文件名"); + } + // 1. 创建虚拟 InformationEntity(materialType=原始资料, draftStatus=PENDING) InformationEntity virtualInfo = new InformationEntity(); virtualInfo.setSubLineId(param.getSubLineId()); @@ -68,7 +92,7 @@ public class GenerateFromFilesApplicationServiceImpl implements GenerateFromFile // 2. 创建子文件记录并关联虚拟资料 List fileEntities = new ArrayList<>(); - for (UploadedFileDTO file : param.getFiles()) { + for (UploadedFileDTO file : files) { InformationFileEntity fileEntity = new InformationFileEntity(); fileEntity.setInformationId(informationId); fileEntity.setFileName(file.getFileName()); diff --git a/src/main/java/com/project/information/application/impl/UploadFilesApplicationServiceImpl.java b/src/main/java/com/project/information/application/impl/UploadFilesApplicationServiceImpl.java index b7245f7..340fe2e 100644 --- a/src/main/java/com/project/information/application/impl/UploadFilesApplicationServiceImpl.java +++ b/src/main/java/com/project/information/application/impl/UploadFilesApplicationServiceImpl.java @@ -1,6 +1,7 @@ package com.project.information.application.impl; import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; import com.project.base.config.CustomIdGenerator; import com.project.base.domain.exception.BusinessErrorException; import com.project.base.domain.result.Result; @@ -73,7 +74,7 @@ public class UploadFilesApplicationServiceImpl implements UploadFilesApplication // MinIO 路径规范:classicpaper/{yyyyMMdd}/{uuid}.{ext} String filePath = String.format("classicpaper/%s/%s.%s", new java.text.SimpleDateFormat("yyyyMMdd").format(new Date()), - customIdGenerator.nextId(null), + IdUtil.fastSimpleUUID(), suffix); // 上传到 MinIO diff --git a/src/main/java/com/project/information/controller/InformationController.java b/src/main/java/com/project/information/controller/InformationController.java index 15e4465..f437886 100644 --- a/src/main/java/com/project/information/controller/InformationController.java +++ b/src/main/java/com/project/information/controller/InformationController.java @@ -77,6 +77,7 @@ public class InformationController { /** * Step2:提交表单(创建虚拟资料 + 关联子文件 + 触发解析) + * form-data 参数:subLineId, fileName, fileNames[], filePaths[], fileSuffixes[], contentCategories[] */ @PostMapping("/generateFromFiles") public Result generateFromFiles(GenerateFromFilesParam param) throws Exception { diff --git a/src/main/java/com/project/information/domain/param/GenerateFromFilesParam.java b/src/main/java/com/project/information/domain/param/GenerateFromFilesParam.java index da684b2..9a0201c 100644 --- a/src/main/java/com/project/information/domain/param/GenerateFromFilesParam.java +++ b/src/main/java/com/project/information/domain/param/GenerateFromFilesParam.java @@ -1,23 +1,47 @@ package com.project.information.domain.param; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.project.base.domain.exception.BusinessErrorException; import com.project.information.domain.dto.UploadedFileDTO; import lombok.Data; +import org.springframework.util.StringUtils; +import java.util.Collections; import java.util.List; /** * 原始资料表单提交请求参数 - * Step2:创建虚拟资料 + 子文件记录 + 触发解析 + * Step2:创建虚拟资料 + 子文件记录 + 触发数据服务解析 + * subLineId、fileName 用普通 form-data 传参 + * files 用 JSON 字符串传参(仿照 ClusterCallbackDTO 模式) */ @Data public class GenerateFromFilesParam { + private static final ObjectMapper MAPPER = new ObjectMapper(); + /** 子产品线ID */ private Long subLineId; /** 虚拟资料文件名(用户填写的占位名称) */ private String fileName; - /** Step1 上传的文件信息列表 */ - private List files; + /** Step1 上传的文件信息(JSON 字符串) */ + private String files; + + /** + * 解析 files JSON 字符串为列表(Lombok 不会覆盖此方法) + */ + public List parseFiles() { + if (!StringUtils.hasText(files)) { + return Collections.emptyList(); + } + try { + return MAPPER.readValue(files, new TypeReference>() {}); + } catch (JsonProcessingException e) { + throw new BusinessErrorException("文件信息解析失败"); + } + } } diff --git a/src/main/java/com/project/information/domain/service/ConfirmKnowledgePointDraftDomainService.java b/src/main/java/com/project/information/domain/service/ConfirmKnowledgePointDraftDomainService.java new file mode 100644 index 0000000..05d7dad --- /dev/null +++ b/src/main/java/com/project/information/domain/service/ConfirmKnowledgePointDraftDomainService.java @@ -0,0 +1,21 @@ +package com.project.information.domain.service; + +import com.project.base.domain.result.Result; + +/** + * 确认固化知识点草稿域服务 + * 将草稿复制到正式表,触发聚类,不可逆 + */ +public interface ConfirmKnowledgePointDraftDomainService { + + /** + * 确认提交(固化,不可逆) + * 1. 幂等校验 + * 2. 草稿 content → evaluator_knowledge_point + * 3. 更新 draftStatus = APPROVED + * 4. 触发聚类 + * + * @param informationId 虚拟资料ID + */ + Result confirm(Long informationId); +} diff --git a/src/main/java/com/project/information/domain/service/impl/ConfirmKnowledgePointDraftDomainServiceImpl.java b/src/main/java/com/project/information/domain/service/impl/ConfirmKnowledgePointDraftDomainServiceImpl.java new file mode 100644 index 0000000..220c1d5 --- /dev/null +++ b/src/main/java/com/project/information/domain/service/impl/ConfirmKnowledgePointDraftDomainServiceImpl.java @@ -0,0 +1,103 @@ +package com.project.information.domain.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.project.base.domain.exception.BusinessErrorException; +import com.project.base.domain.result.Result; +import com.project.information.domain.dto.KnowledgePointDTO; +import com.project.information.domain.entity.InformationEntity; +import com.project.information.domain.entity.KnowledgePointDraftEntity; +import com.project.information.domain.entity.KnowledgePointEntity; +import com.project.information.domain.enums.AuditStatusEnum; +import com.project.information.domain.enums.DraftStatusEnum; +import com.project.information.domain.service.*; +import com.project.interaction.application.AlgorithmApplicationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * 确认固化知识点草稿域服务实现 + */ +@Service +@Slf4j +public class ConfirmKnowledgePointDraftDomainServiceImpl implements ConfirmKnowledgePointDraftDomainService { + + @Autowired + private KnowledgePointDraftBaseService knowledgePointDraftBaseService; + + @Autowired + private InformationBaseService informationBaseService; + + @Autowired + private KnowledgePointBaseService knowledgePointBaseService; + + @Autowired + private AlgorithmApplicationService algorithmApplicationService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Result confirm(Long informationId) { + if (informationId == null) { + throw new BusinessErrorException("资料ID不能为空"); + } + + // 1. 查询资料,幂等校验 + InformationEntity information = informationBaseService.getById(informationId); + if (information == null) { + throw new BusinessErrorException("资料不存在"); + } + if (DraftStatusEnum.APPROVED.getValue().equals(information.getDraftStatus())) { + return Result.success("已固化,不可重复操作"); + } + if (!DraftStatusEnum.PENDING.getValue().equals(information.getDraftStatus())) { + throw new BusinessErrorException("该资料不在待审核状态"); + } + + // 2. 查询所有待审核草稿 + List drafts = knowledgePointDraftBaseService.list( + new LambdaQueryWrapper() + .eq(KnowledgePointDraftEntity::getInformationId, informationId) + .eq(KnowledgePointDraftEntity::getAuditStatus, AuditStatusEnum.PENDING.getValue())); + if (drafts.isEmpty()) { + throw new BusinessErrorException("没有待审核的草稿"); + } + + // 3. 将草稿 content 复制到正式知识点表 + List knowledgePoints = new ArrayList<>(); + for (KnowledgePointDraftEntity draft : drafts) { + KnowledgePointEntity kp = new KnowledgePointEntity(); + kp.setContent(draft.getContent()); + kp.setKnowledgeType(draft.getKnowledgeType()); + kp.setInformationId(informationId); + kp.setParseName(draft.getParseName()); + knowledgePoints.add(kp); + } + knowledgePointBaseService.saveBatch(knowledgePoints); + + // 4. 更新草稿 auditStatus 为已通过 + List draftIds = drafts.stream().map(KnowledgePointDraftEntity::getId).toList(); + knowledgePointDraftBaseService.lambdaUpdate() + .in(KnowledgePointDraftEntity::getId, draftIds) + .set(KnowledgePointDraftEntity::getAuditStatus, AuditStatusEnum.APPROVED.getValue()) + .update(); + + // 5. 更新虚拟资料 draftStatus = APPROVED(2) + informationBaseService.lambdaUpdate() + .eq(InformationEntity::getId, informationId) + .set(InformationEntity::getDraftStatus, DraftStatusEnum.APPROVED.getValue()) + .update(); + + // 6. 触发聚类 + List kpList = knowledgePoints.stream() + .map(entity -> entity.toDTO(KnowledgePointDTO::new)) + .toList(); + algorithmApplicationService.postToClusteringByInformationId(informationId, kpList); + + log.info(">>> [草稿审核] 确认固化完成, informationId={}, 草稿数={}", informationId, drafts.size()); + return Result.success("确认成功"); + } +} diff --git a/src/main/java/com/project/interaction/application/AiExtractCallbackApplicationService.java b/src/main/java/com/project/interaction/application/AiExtractCallbackApplicationService.java new file mode 100644 index 0000000..33d04dc --- /dev/null +++ b/src/main/java/com/project/interaction/application/AiExtractCallbackApplicationService.java @@ -0,0 +1,10 @@ +package com.project.interaction.application; + +import com.project.interaction.domain.dto.AiExtractCallbackDTO; + +/** + * 算法服务知识点提取回调应用服务 + */ +public interface AiExtractCallbackApplicationService { + void handleCallback(AiExtractCallbackDTO callback); +} diff --git a/src/main/java/com/project/interaction/application/DataExtractCallbackApplicationService.java b/src/main/java/com/project/interaction/application/DataExtractCallbackApplicationService.java new file mode 100644 index 0000000..d5ad669 --- /dev/null +++ b/src/main/java/com/project/interaction/application/DataExtractCallbackApplicationService.java @@ -0,0 +1,10 @@ +package com.project.interaction.application; + +import com.project.interaction.domain.dto.DataExtractCallbackDTO; + +/** + * 数据服务解析回调应用服务 + */ +public interface DataExtractCallbackApplicationService { + void handleCallback(DataExtractCallbackDTO callback); +} diff --git a/src/main/java/com/project/interaction/application/impl/AiExtractCallbackApplicationServiceImpl.java b/src/main/java/com/project/interaction/application/impl/AiExtractCallbackApplicationServiceImpl.java new file mode 100644 index 0000000..a8b1672 --- /dev/null +++ b/src/main/java/com/project/interaction/application/impl/AiExtractCallbackApplicationServiceImpl.java @@ -0,0 +1,22 @@ +package com.project.interaction.application.impl; + +import com.project.interaction.application.AiExtractCallbackApplicationService; +import com.project.interaction.domain.dto.AiExtractCallbackDTO; +import com.project.interaction.domain.service.SaveAiExtractDraftDomainService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 算法服务知识点提取回调应用服务实现 + */ +@Service +public class AiExtractCallbackApplicationServiceImpl implements AiExtractCallbackApplicationService { + + @Autowired + private SaveAiExtractDraftDomainService saveAiExtractDraftDomainService; + + @Override + public void handleCallback(AiExtractCallbackDTO callback) { + saveAiExtractDraftDomainService.handleCallback(callback); + } +} diff --git a/src/main/java/com/project/interaction/application/impl/DataExtractCallbackApplicationServiceImpl.java b/src/main/java/com/project/interaction/application/impl/DataExtractCallbackApplicationServiceImpl.java new file mode 100644 index 0000000..732ee27 --- /dev/null +++ b/src/main/java/com/project/interaction/application/impl/DataExtractCallbackApplicationServiceImpl.java @@ -0,0 +1,22 @@ +package com.project.interaction.application.impl; + +import com.project.interaction.application.DataExtractCallbackApplicationService; +import com.project.interaction.domain.dto.DataExtractCallbackDTO; +import com.project.interaction.domain.service.SaveDataExtractResultDomainService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 数据服务解析回调应用服务实现 + */ +@Service +public class DataExtractCallbackApplicationServiceImpl implements DataExtractCallbackApplicationService { + + @Autowired + private SaveDataExtractResultDomainService saveDataExtractResultDomainService; + + @Override + public void handleCallback(DataExtractCallbackDTO callback) { + saveDataExtractResultDomainService.handleCallback(callback); + } +} diff --git a/src/main/java/com/project/interaction/domain/service/SaveAiExtractDraftDomainService.java b/src/main/java/com/project/interaction/domain/service/SaveAiExtractDraftDomainService.java new file mode 100644 index 0000000..d1d70dc --- /dev/null +++ b/src/main/java/com/project/interaction/domain/service/SaveAiExtractDraftDomainService.java @@ -0,0 +1,11 @@ +package com.project.interaction.domain.service; + +import com.project.interaction.domain.dto.AiExtractCallbackDTO; + +/** + * 保存AI提取知识点草稿域服务 + * 将算法返回的草稿知识点写入草稿表 + */ +public interface SaveAiExtractDraftDomainService { + void handleCallback(AiExtractCallbackDTO callback); +} diff --git a/src/main/java/com/project/interaction/domain/service/SaveDataExtractResultDomainService.java b/src/main/java/com/project/interaction/domain/service/SaveDataExtractResultDomainService.java new file mode 100644 index 0000000..8d25bd3 --- /dev/null +++ b/src/main/java/com/project/interaction/domain/service/SaveDataExtractResultDomainService.java @@ -0,0 +1,11 @@ +package com.project.interaction.domain.service; + +import com.project.interaction.domain.dto.DataExtractCallbackDTO; + +/** + * 保存数据服务解析结果域服务 + * 更新子文件解析状态和文本,检查批次完成,触发算法服务 + */ +public interface SaveDataExtractResultDomainService { + void handleCallback(DataExtractCallbackDTO callback); +} diff --git a/src/main/java/com/project/interaction/domain/service/impl/SaveAiExtractDraftDomainServiceImpl.java b/src/main/java/com/project/interaction/domain/service/impl/SaveAiExtractDraftDomainServiceImpl.java new file mode 100644 index 0000000..6589942 --- /dev/null +++ b/src/main/java/com/project/interaction/domain/service/impl/SaveAiExtractDraftDomainServiceImpl.java @@ -0,0 +1,68 @@ +package com.project.interaction.domain.service.impl; + +import com.project.information.domain.entity.InformationEntity; +import com.project.information.domain.entity.KnowledgePointDraftEntity; +import com.project.information.domain.enums.AuditStatusEnum; +import com.project.information.domain.enums.DraftStatusEnum; +import com.project.information.domain.service.InformationBaseService; +import com.project.information.domain.service.KnowledgePointDraftBaseService; +import com.project.interaction.domain.dto.AiExtractCallbackDTO; +import com.project.interaction.domain.service.SaveAiExtractDraftDomainService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * 保存AI提取知识点草稿域服务实现 + */ +@Service +@Slf4j +public class SaveAiExtractDraftDomainServiceImpl implements SaveAiExtractDraftDomainService { + + @Autowired + private KnowledgePointDraftBaseService knowledgePointDraftBaseService; + + @Autowired + private InformationBaseService informationBaseService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void handleCallback(AiExtractCallbackDTO callback) { + Long informationId = callback.getInformationId(); + List drafts = callback.getDrafts(); + + log.info(">>> [算法服务回调] 收到知识点提取回调, informationId={}, 草稿数={}", + informationId, drafts != null ? drafts.size() : 0); + + if (drafts == null || drafts.isEmpty()) { + log.warn(">>> [算法服务回调] 算法返回的草稿为空, informationId={}", informationId); + return; + } + + // 1. 保存草稿(aiContent = content 的副本) + List draftEntities = new ArrayList<>(); + for (AiExtractCallbackDTO.DraftItem item : drafts) { + KnowledgePointDraftEntity draft = new KnowledgePointDraftEntity(); + draft.setInformationId(informationId); + draft.setContent(item.getContent()); + draft.setAiContent(item.getContent()); + draft.setKnowledgeType(item.getKnowledgeType()); + draft.setParseName(item.getParseName()); + draft.setAuditStatus(AuditStatusEnum.PENDING.getValue()); + draftEntities.add(draft); + } + knowledgePointDraftBaseService.saveBatch(draftEntities); + + // 2. 更新虚拟资料 draftStatus = PENDING(1) + informationBaseService.lambdaUpdate() + .eq(InformationEntity::getId, informationId) + .set(InformationEntity::getDraftStatus, DraftStatusEnum.PENDING.getValue()) + .update(); + + log.info(">>> [算法服务回调] 草稿保存完成, informationId={}, 草稿数={}", informationId, draftEntities.size()); + } +} 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 new file mode 100644 index 0000000..9e10514 --- /dev/null +++ b/src/main/java/com/project/interaction/domain/service/impl/SaveDataExtractResultDomainServiceImpl.java @@ -0,0 +1,96 @@ +package com.project.interaction.domain.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.project.information.domain.entity.InformationFileEntity; +import com.project.information.domain.enums.FileParseStatusEnum; +import com.project.information.domain.service.InformationFileBaseService; +import com.project.interaction.domain.dto.AiExtractRequestDTO; +import com.project.interaction.domain.dto.DataExtractCallbackDTO; +import com.project.interaction.domain.service.PostToAiExtractDomainService; +import com.project.interaction.domain.service.SaveDataExtractResultDomainService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * 保存数据服务解析结果域服务实现 + */ +@Service +@Slf4j +public class SaveDataExtractResultDomainServiceImpl implements SaveDataExtractResultDomainService { + + @Autowired + private InformationFileBaseService informationFileBaseService; + + @Autowired + private PostToAiExtractDomainService postToAiExtractDomainService; + + @Override + public void handleCallback(DataExtractCallbackDTO callback) { + Long fileId = callback.getFileId(); + Long informationId = callback.getInformationId(); + + log.info(">>> [数据服务回调] 收到子文件解析回调, fileId={}, informationId={}, status={}", + fileId, informationId, callback.getParseStatus()); + + // 1. 更新子文件解析状态 + InformationFileEntity file = informationFileBaseService.getById(fileId); + if (file == null) { + log.error(">>> [数据服务回调] 子文件不存在, fileId={}", fileId); + return; + } + file.setParseStatus(callback.getParseStatus()); + if (FileParseStatusEnum.Success.getValue().equals(callback.getParseStatus())) { + file.setParsedText(callback.getParsedText()); + } + informationFileBaseService.updateById(file); + + // 2. 解析失败则记录日志 + if (!FileParseStatusEnum.Success.getValue().equals(callback.getParseStatus())) { + log.warn(">>> [数据服务回调] 子文件解析失败, fileId={}, error={}", fileId, callback.getErrorMsg()); + return; + } + + // 3. 检查该虚拟资料下所有子文件是否全部完成 + List allFiles = informationFileBaseService.list( + new LambdaQueryWrapper() + .eq(InformationFileEntity::getInformationId, informationId)); + + boolean allDone = allFiles.stream() + .allMatch(f -> FileParseStatusEnum.Success.getValue().equals(f.getParseStatus()) + || FileParseStatusEnum.Failed.getValue().equals(f.getParseStatus())); + + if (!allDone) { + log.info(">>> [数据服务回调] 虚拟资料 {} 下还有子文件未完成解析,等待中", informationId); + return; + } + + // 4. 全部完成 → 调用算法服务知识点提取 + log.info(">>> [数据服务回调] 虚拟资料 {} 下所有子文件解析完成,开始调用算法服务", informationId); + + List fileContents = new ArrayList<>(); + for (InformationFileEntity f : allFiles) { + if (FileParseStatusEnum.Success.getValue().equals(f.getParseStatus()) && f.getParsedText() != null) { + AiExtractRequestDTO.FileContent fc = new AiExtractRequestDTO.FileContent(); + fc.setFileName(f.getFileName()); + fc.setContentCategory(String.valueOf(f.getContentCategory())); + fc.setParsedText(f.getParsedText()); + fileContents.add(fc); + } + } + + if (fileContents.isEmpty()) { + log.warn(">>> [数据服务回调] 虚拟资料 {} 下没有成功解析的子文件", informationId); + return; + } + + AiExtractRequestDTO request = new AiExtractRequestDTO(); + request.setInformationId(informationId); + request.setFiles(fileContents); + + postToAiExtractDomainService.postToAiExtract(request); + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..aa87666 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,97 @@ +server: + port: 7088 + +spring: + datasource: + dynamic: + primary: master + datasource: + master: + driverClassName: com.mysql.cj.jdbc.Driver + password: Itc@123456 + url: jdbc:mysql://8.129.84.155:3306/ai_evaluator?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true + username: root + # Redis + data: + redis: + host: 8.129.84.155 + port: 6379 + password: Itc@123456 + database: 3 + timeout: 5000ms + lettuce: + pool: + max-active: 8 + max-idle: 30 + max-wait: 10000 + min-idle: 10 + jpa: + hibernate: + ddl-auto: update + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + show-sql: true + # 上传下载限制 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + serialization: + write-dates-as-timestamps: false + generator: + write-numbers-as-strings: true +minio: + endpoint: ${MINIO_ENDPOINT:http://localhost:9000} + accessKey: ${MINIO_ASSESSKEY:DTKYZDZM1i31XOvd24SP} + secretKey: ${MINIO_SECRETKEY:PnfLPcJbvaUboZIwYZAADPB0pDtPZgbi0QiLSs3C} + bucket: ${MINIO_BUCKET:ai-evaluator} + tempAccessFileUrl: http://8.129.84.155/minio-api +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + mapper-locations: classpath*:mapper/**/*.xml + type-aliases-package: com.proposal.**.domain.entity + +milvus: + host: 127.0.0.1 + port: 19530 +analysis: + host: http://127.0.0.1 + port: 8888 + url: /word/parse +ding: + appKey: dinghgexcbos9dorl3kb + appSecret: bSNX1ZOUTk2A4FGwQ1U6fyHlFssh9IZtFO6FuKx8A97PE1VZL9lBTJapwzDGCS7v +# appKey: ding4eootvq4jzas96lr #保伦架构 +# appSecret: UI6XcD8GFPE_W_yo2eQkMuoSsTEf1whpZYEXrsDA7CV7bkJp40B3VNcETWS1aGgg + agentId: 4418045751 + corpId: ding13a2ff8718e6bcdd +# corpId: 3318151640 + noticeUrlPrefix: https://107pm707566hq.vicp.fun/ai-evaluator-test +algo: + clusterUrl: /semantic-cluster + baseUrl: http://127.0.0.1:8002 + generateQuestionUrl: /v1/generate/questions_from_cluster + apiUrl: http://127.0.0.1:8000 +jwt: + secret: "my-very-fixed-and-secure-secret-key-1234567890" + +# 题目生成相关配置 +question: + generation: + # 限流速率:每秒允许的API请求数 + rate-limit: 4 + downgrade: false + # 队列配置 + queue: + # 重试间隔(秒) + retry-interval: 60 + +scheduled-task: + owner: test \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index aa87666..306add4 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -9,7 +9,7 @@ spring: master: driverClassName: com.mysql.cj.jdbc.Driver password: Itc@123456 - url: jdbc:mysql://8.129.84.155:3306/ai_evaluator?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true + url: jdbc:mysql://8.129.84.155:3306/ai_evaluator_test?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true username: root # Redis data: @@ -47,10 +47,10 @@ spring: generator: write-numbers-as-strings: true minio: - endpoint: ${MINIO_ENDPOINT:http://localhost:9000} + endpoint: ${MINIO_ENDPOINT:http://8.129.84.155:9000} accessKey: ${MINIO_ASSESSKEY:DTKYZDZM1i31XOvd24SP} secretKey: ${MINIO_SECRETKEY:PnfLPcJbvaUboZIwYZAADPB0pDtPZgbi0QiLSs3C} - bucket: ${MINIO_BUCKET:ai-evaluator} + bucket: ${MINIO_BUCKET:ai-evaluator-test} tempAccessFileUrl: http://8.129.84.155/minio-api mybatis-plus: configuration: @@ -68,11 +68,11 @@ analysis: ding: appKey: dinghgexcbos9dorl3kb appSecret: bSNX1ZOUTk2A4FGwQ1U6fyHlFssh9IZtFO6FuKx8A97PE1VZL9lBTJapwzDGCS7v -# appKey: ding4eootvq4jzas96lr #保伦架构 -# appSecret: UI6XcD8GFPE_W_yo2eQkMuoSsTEf1whpZYEXrsDA7CV7bkJp40B3VNcETWS1aGgg + # appKey: ding4eootvq4jzas96lr #保伦架构 + # appSecret: UI6XcD8GFPE_W_yo2eQkMuoSsTEf1whpZYEXrsDA7CV7bkJp40B3VNcETWS1aGgg agentId: 4418045751 corpId: ding13a2ff8718e6bcdd -# corpId: 3318151640 + # corpId: 3318151640 noticeUrlPrefix: https://107pm707566hq.vicp.fun/ai-evaluator-test algo: clusterUrl: /semantic-cluster