|
@@ -0,0 +1,413 @@
|
|
|
+package com.qmth.pdf.tools.service.impl;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.google.common.cache.Cache;
|
|
|
+import com.google.common.cache.CacheBuilder;
|
|
|
+import com.qmth.pdf.tools.bean.*;
|
|
|
+import com.qmth.pdf.tools.config.PdfConfig;
|
|
|
+import com.qmth.pdf.tools.constant.SystemConstant;
|
|
|
+import com.qmth.pdf.tools.service.StudentPaperService;
|
|
|
+import com.qmth.pdf.tools.util.HttpClientUtil;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.scheduling.annotation.Async;
|
|
|
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.util.CollectionUtils;
|
|
|
+import org.springframework.util.LinkedMultiValueMap;
|
|
|
+import org.springframework.util.MultiValueMap;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+
|
|
|
+import java.io.File;
|
|
|
+import java.io.FileWriter;
|
|
|
+import java.io.IOException;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Paths;
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.CompletableFuture;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+import java.util.concurrent.atomic.AtomicInteger;
|
|
|
+import java.util.concurrent.locks.ReentrantLock;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * @Description 学生的试卷json处理Service
|
|
|
+ * @Author haoguanghui
|
|
|
+ * @date 2025/03/13
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class StudentPaperServiceImpl implements StudentPaperService {
|
|
|
+
|
|
|
+ private final static Logger log = LoggerFactory.getLogger(StudentPaperServiceImpl.class);
|
|
|
+
|
|
|
+ private final PdfConfig pdfConfig;
|
|
|
+
|
|
|
+ private final HttpClientUtil httpClient;
|
|
|
+
|
|
|
+ private final ThreadPoolTaskExecutor taskExecutor;
|
|
|
+
|
|
|
+ // 内存锁
|
|
|
+ private final ConcurrentHashMap<Long, ReentrantLock> lockMap = new ConcurrentHashMap<>();
|
|
|
+
|
|
|
+ // 缓存
|
|
|
+ private final Cache<String, PaperBean> paperCache;
|
|
|
+
|
|
|
+ // 使用单例模式的 ObjectMapper
|
|
|
+ private static final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ // 考生答案类型引用
|
|
|
+ private static final TypeReference<List<StudentAnswerBean>> STUDENT_ANSWER_LIST_TYPE_REFERENCE =
|
|
|
+ new TypeReference<List<StudentAnswerBean>>() {};
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ public StudentPaperServiceImpl(RestTemplate restTemplate, PdfConfig pdfConfig, ThreadPoolTaskExecutor taskExecutor) {
|
|
|
+ this.httpClient = new HttpClientUtil(restTemplate);
|
|
|
+ this.pdfConfig = pdfConfig;
|
|
|
+ this.taskExecutor = taskExecutor;
|
|
|
+ this.paperCache = CacheBuilder.newBuilder()
|
|
|
+ .maximumSize(100) // 最大缓存数量
|
|
|
+ .expireAfterWrite(10, TimeUnit.MINUTES) // 缓存10分钟后过期
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ @Override
|
|
|
+ @Async
|
|
|
+ public void outputStudentPaperJson(Long examId, String subjectCode) {
|
|
|
+ if (examId == null) {
|
|
|
+ log.error("examId不能为空");
|
|
|
+ throw new IllegalArgumentException("参数错误");
|
|
|
+ }
|
|
|
+
|
|
|
+ ReentrantLock lock = lockMap.computeIfAbsent(examId, k -> new ReentrantLock());
|
|
|
+
|
|
|
+ // 尝试获取锁
|
|
|
+ if (!lock.tryLock()) {
|
|
|
+ log.warn("正在生成考生的试卷,请不要重复执行,examId: {}, subjectCode: {}", examId, subjectCode);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ long startTime = System.currentTimeMillis();
|
|
|
+ log.warn("开始生成考生的试卷json文件,examId: {}, subjectCode: {}", examId, subjectCode);
|
|
|
+
|
|
|
+ //分页查询所有考生
|
|
|
+ List<StudentBean> allStudentBeans = fetchAllStudentWithPagination(examId, subjectCode);
|
|
|
+ if (CollectionUtils.isEmpty(allStudentBeans)) {
|
|
|
+ log.warn("没有找到符合条件的考生,examId: {}, subjectCode: {}", examId, subjectCode);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ log.warn("待生成json文件的考生数量:{},examId:{}, subjectCode:{}", allStudentBeans.size(), examId, subjectCode);
|
|
|
+
|
|
|
+ AtomicInteger successCounter = new AtomicInteger(0);
|
|
|
+ List<CompletableFuture<Void>> futures = new ArrayList<>();
|
|
|
+
|
|
|
+ for (StudentBean studentBean : allStudentBeans) {
|
|
|
+ CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
|
|
|
+ try {
|
|
|
+ processStudent(studentBean, examId);
|
|
|
+ successCounter.incrementAndGet(); // 成功加1
|
|
|
+ } catch (Exception ex) {
|
|
|
+ log.error("处理考生信息失败,studentCode: {}, studentName: {}, examId: {}, subjectCode: {}",
|
|
|
+ studentBean.getStudentCode(), studentBean.getName(), examId, subjectCode, ex);
|
|
|
+ }
|
|
|
+ }, taskExecutor)
|
|
|
+ .exceptionally(ex -> {
|
|
|
+ log.error("考生试卷生成失败,studentCode: {}, studentName: {}, examId: {}, subjectCode: {}",
|
|
|
+ studentBean.getStudentCode(), studentBean.getName(), examId, subjectCode, ex);
|
|
|
+ return null;
|
|
|
+ });
|
|
|
+
|
|
|
+ futures.add(future);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 等待所有任务完成
|
|
|
+ CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
|
|
+
|
|
|
+ long endTime = System.currentTimeMillis();
|
|
|
+ log.warn("完成考生试卷的生成,耗时: {}s, 处理成功的考生数量: {}, examId: {}, subjectCode: {}",
|
|
|
+ (endTime - startTime) / 1000, successCounter.get(), examId, subjectCode);
|
|
|
+
|
|
|
+ } finally {
|
|
|
+ lock.unlock();
|
|
|
+ // 移除不再需要的锁
|
|
|
+ lockMap.remove(examId, lock);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void processStudent(StudentBean studentBean, Long examId) {
|
|
|
+ String subjectCode = studentBean.getSubjectCode();
|
|
|
+ PaperBean paperBean;
|
|
|
+
|
|
|
+ // 从缓存中获取试卷结构
|
|
|
+ if ((paperBean = paperCache.getIfPresent(subjectCode)) == null) {
|
|
|
+ synchronized (this) {
|
|
|
+ if ((paperBean = paperCache.getIfPresent(subjectCode)) == null) {
|
|
|
+ try {
|
|
|
+ paperBean = fetchPaperStructure(examId, subjectCode);
|
|
|
+ if (paperBean != null) {
|
|
|
+ paperCache.put(subjectCode, paperBean);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("从远程获取试卷结构失败, examId:{}, subjectCode:{}", examId, subjectCode, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (paperBean == null) {
|
|
|
+ log.warn("找不到该科目对应的试卷, examId:{}, subjectCode:{}", examId, subjectCode);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ //试卷的大题集合
|
|
|
+ List<DetailBean> details = paperBean.getDetails();
|
|
|
+ if (CollectionUtils.isEmpty(details)) {
|
|
|
+ log.warn("试卷不存在题目, examId:{}, subjectCode:{}", examId, subjectCode);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ //填充考生姓名和学号
|
|
|
+ paperBean.setStudentName(studentBean.getName());
|
|
|
+ paperBean.setStudentCode(studentBean.getStudentCode());
|
|
|
+
|
|
|
+ // 获取考生的答案
|
|
|
+ List<StudentAnswerBean> studentAnswerBeans;
|
|
|
+ try {
|
|
|
+ studentAnswerBeans = fetchStudentScore(examId, studentBean.getSecretNumber());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取考生答案失败, examId:{}, secretNumber:{}", examId, studentBean.getSecretNumber(), e);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 填充考生的答案和得分
|
|
|
+ fillStudentScoreAndAnswer(studentBean, studentAnswerBeans, details);
|
|
|
+
|
|
|
+ // 将填充后的试卷写到文件中
|
|
|
+ outputToFile(studentBean, paperBean, examId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void outputToFile(StudentBean studentBean, PaperBean paperBean, Long examId) {
|
|
|
+ try {
|
|
|
+ String json = objectMapper.writeValueAsString(paperBean);
|
|
|
+ //文件名称:姓名_学号.json
|
|
|
+ String fileName = studentBean.getName() + "_" + studentBean.getStudentCode() + ".json";
|
|
|
+ String filePath = pdfConfig.getOutputPath() + examId + "/" + fileName;
|
|
|
+
|
|
|
+ // 判断目录是否存在,不存在,创建
|
|
|
+ File file = new File(filePath);
|
|
|
+ if (!file.getParentFile().exists()) {
|
|
|
+ file.getParentFile().mkdirs();
|
|
|
+ }
|
|
|
+ Files.write(Paths.get(filePath), json.getBytes(StandardCharsets.UTF_8));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("文件写入失败,studentCode: {}, 文件名: {}", studentBean.getStudentCode(), studentBean.getName(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void fillStudentScoreAndAnswer(StudentBean studentBean, List<StudentAnswerBean> studentAnswerBeans, List<DetailBean> details) {
|
|
|
+ //客观题得分
|
|
|
+ List<StudentScoreBean> objectiveScoreDetail = studentBean.getObjectiveScoreDetail();
|
|
|
+ //主观题得分
|
|
|
+ List<StudentScoreBean> subjectiveScoreDetail = studentBean.getSubjectiveScoreDetail();
|
|
|
+
|
|
|
+ //考生答案map
|
|
|
+ Map<String, StudentAnswerBean> answerMap = buildAnswerMap(studentAnswerBeans);
|
|
|
+ //客观题得分map
|
|
|
+ Map<String, Double> objectiveScoreMap = buildScoreMap(objectiveScoreDetail);
|
|
|
+ //主观题得分map
|
|
|
+ Map<String, Double> subjectiveScoreMap = buildScoreMap(subjectiveScoreDetail);
|
|
|
+
|
|
|
+ for (DetailBean detail : details) {
|
|
|
+ //大题号
|
|
|
+ int mainNumber = detail.getNumber();
|
|
|
+ List<QuestionBean> questions = detail.getQuestions();
|
|
|
+ for (QuestionBean question : questions) {
|
|
|
+ //小题号
|
|
|
+ int subNumber = question.getNumber();
|
|
|
+ boolean objective = question.isObjective();
|
|
|
+ //考生得分
|
|
|
+ question.setStudentScore(getStudentScore(mainNumber, subNumber, objective, objectiveScoreMap, subjectiveScoreMap));
|
|
|
+ //考生答案
|
|
|
+ question.setStudentAnswer(getStudentAnswer(answerMap, mainNumber, subNumber));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ private Object getStudentAnswer(Map<String, StudentAnswerBean> answerMap, int mainNumber, int subNumber) {
|
|
|
+ String key = getKey(mainNumber, subNumber);
|
|
|
+ StudentAnswerBean answerBean = answerMap.get(key);
|
|
|
+ return answerBean != null ? answerBean.getAnswer() : "";
|
|
|
+ }
|
|
|
+
|
|
|
+ private Double getStudentScore(int mainNumber, int subNumber, boolean objective, Map<String, Double> objectiveScoreMap,
|
|
|
+ Map<String, Double> subjectiveScoreMap) {
|
|
|
+ // 根据 objective 选择对应的 Map
|
|
|
+ Map<String, Double> scoreMap = objective ? objectiveScoreMap : subjectiveScoreMap;
|
|
|
+ String key = getKey(mainNumber, subNumber);
|
|
|
+
|
|
|
+ return scoreMap.getOrDefault(key, 0.0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String getKey(int mainNumber, int subNumber) {
|
|
|
+ return mainNumber + "-" + subNumber;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Double> buildScoreMap(List<StudentScoreBean> scoreDetail) {
|
|
|
+ if (scoreDetail == null) {
|
|
|
+ return new HashMap<>();
|
|
|
+ }
|
|
|
+ Map<String, Double> scoreMap = new HashMap<>();
|
|
|
+ for (StudentScoreBean studentScoreBean : scoreDetail) {
|
|
|
+ if (studentScoreBean != null) {
|
|
|
+ String key = getKey(studentScoreBean.getMainNumber(), studentScoreBean.getSubNumber());
|
|
|
+ scoreMap.put(key, studentScoreBean.getScore());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return scoreMap;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, StudentAnswerBean> buildAnswerMap(List<StudentAnswerBean> studentAnswerBeans) {
|
|
|
+ Map<String, StudentAnswerBean> answerMap = new HashMap<>();
|
|
|
+ for (StudentAnswerBean answerBean : studentAnswerBeans) {
|
|
|
+ String key = getKey(answerBean.getMainNumber(), answerBean.getSubNumber());
|
|
|
+ answerMap.put(key, answerBean);
|
|
|
+ }
|
|
|
+ return answerMap;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取考生的答案
|
|
|
+ */
|
|
|
+ private List<StudentAnswerBean> fetchStudentScore(Long examId, String secretNumber) {
|
|
|
+ // secretNumber不能为空且长度要大于3
|
|
|
+ if (secretNumber == null || secretNumber.length() < 3) {
|
|
|
+ log.error("secretNumber不能为空且长度要大于3");
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+
|
|
|
+ //提取 secretNumber最后3位数字
|
|
|
+ String path = secretNumber.substring(secretNumber.length() - 3);
|
|
|
+ // 获取学生成绩url
|
|
|
+ String url = String.format("%s%s/%s/%s/%s.json", pdfConfig.getResourceUrl(), SystemConstant.STUDENT_ANSWER_URL_PREFIX, examId, path, secretNumber);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 获取考生的答案
|
|
|
+ String response = httpClient.get(url, null, null);
|
|
|
+ if (response == null || response.isEmpty()) {
|
|
|
+ log.warn("考生答案返回为空。examId:{}, secretNumber:{}", examId, secretNumber);
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ // 解析 JSON 响应
|
|
|
+ return objectMapper.readValue(response, STUDENT_ANSWER_LIST_TYPE_REFERENCE);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取考生列表失败,examId:{}, secretNumber:{}, url:{}", examId, secretNumber, url, e);
|
|
|
+ }
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取试卷结构
|
|
|
+ private PaperBean fetchPaperStructure(Long examId, String subjectCode) {
|
|
|
+ if (subjectCode == null) {
|
|
|
+ log.error("科目编码不能为空");
|
|
|
+ throw new IllegalArgumentException("参数错误");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ // 请求获取试卷结构
|
|
|
+ String url = String.format("%s%s%s/%s.json", pdfConfig.getResourceUrl(), SystemConstant.PAPER_URL_PREFIX, examId, subjectCode);
|
|
|
+ String response = httpClient.get(url, null, null);
|
|
|
+ if (response == null || response.isEmpty()) {
|
|
|
+ log.warn("获取试卷结构返回为空。examId:{}, subjectCode:{}", examId, subjectCode);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ //将response返回的结果,写入到文件中
|
|
|
+ /* try {
|
|
|
+ File file = new File("D:/test/test.json");
|
|
|
+ if(!file.exists()) {
|
|
|
+ file.createNewFile();
|
|
|
+ }
|
|
|
+ if(file.length() <= 0) {
|
|
|
+ log.warn(response);
|
|
|
+ writeStringToFile(file, response, "UTF-8");
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.error("写入文件失败", e);
|
|
|
+ }*/
|
|
|
+ // 解决返回的json有特殊字符的问题
|
|
|
+ int start = response.indexOf("{");
|
|
|
+ if (start > 0) {
|
|
|
+ response = response.substring(start);
|
|
|
+ }
|
|
|
+ return objectMapper.readValue(response, PaperBean.class);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取试卷结构失败", e);
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void writeStringToFile(File file, String data, String encoding) throws IOException {
|
|
|
+ try (FileWriter fileWriter = new FileWriter(file, true)) {
|
|
|
+ fileWriter.write(data);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ // 分页查询所有考生
|
|
|
+ private List<StudentBean> fetchAllStudentWithPagination(Long examId, String subjectCode) {
|
|
|
+ List<StudentBean> allStudentBeans = new ArrayList<>();
|
|
|
+ int pageNumber = 1;
|
|
|
+ while (true) {
|
|
|
+ try {
|
|
|
+ List<StudentBean> pageResult = fetchPageStudent(examId, subjectCode, pageNumber);
|
|
|
+ if (CollectionUtils.isEmpty(pageResult)) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ //过滤未评卷的考生
|
|
|
+ pageResult = pageResult.stream()
|
|
|
+ .filter(StudentBean::isUpload)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ if (!pageResult.isEmpty()) {
|
|
|
+ allStudentBeans.addAll(pageResult);
|
|
|
+ }
|
|
|
+ pageNumber++;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("分页查询考生信息失败,examId: {}, pageNum: {}", examId, pageNumber, e);
|
|
|
+ throw new RuntimeException("分页查询考生信息失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return allStudentBeans;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<StudentBean> fetchPageStudent(Long examId, String subjectCode, int pageNum) {
|
|
|
+ // 参数设置
|
|
|
+ MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
|
|
|
+ params.put("examId", Collections.singletonList(String.valueOf(examId)));
|
|
|
+ params.put("withScoreDetail", Collections.singletonList(String.valueOf(Boolean.TRUE)));
|
|
|
+ params.put("pageNumber", Collections.singletonList(String.valueOf(pageNum)));
|
|
|
+ params.put("pageSize", Collections.singletonList(String.valueOf(SystemConstant.PAGE_SIZE)));
|
|
|
+ if (subjectCode != null && !subjectCode.isEmpty()) {
|
|
|
+ params.put("subjectCode", Collections.singletonList(subjectCode));
|
|
|
+ }
|
|
|
+
|
|
|
+ //请求获取考生列表
|
|
|
+ String response = httpClient.postForm(pdfConfig.getStudentUrl() + SystemConstant.STUDENT_SCORE_URL, null, params);
|
|
|
+
|
|
|
+ if(response == null || response.isEmpty()) {
|
|
|
+ log.warn("考生列表响应为空: examId:{}, pageNum:{}", examId, pageNum);
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ return objectMapper.readValue(response, new TypeReference<List<StudentBean>>() {});
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取考生列表失败: examId:{}, pageNum:{}, error:{}", examId, pageNum, e.getMessage(), e);
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+}
|