Jelajahi Sumber

feat:优化逐句批改功能“

lalala 4 bulan lalu
induk
melakukan
4c659dca5d

+ 30 - 3
eai.sql

@@ -149,7 +149,8 @@ CREATE TABLE `evaluation`  (
   `overall` int NULL DEFAULT NULL COMMENT '整体评分',
   `sentence` int NULL DEFAULT NULL COMMENT '句子评分',
   PRIMARY KEY (`id`) USING BTREE,
-  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE
+  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE,
+  UNIQUE INDEX `uk_evaluation_assignment_student_version`(`assignment_id`, `student_id`, `version`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 17 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作业评价表' ROW_FORMAT = Dynamic;
 
 -- ----------------------------
@@ -177,7 +178,8 @@ CREATE TABLE `overall_evaluation`  (
   `version` int NOT NULL DEFAULT 1 COMMENT '批改版本(跟随作业版本)',
   `evaluation` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '整体评估结果',
   PRIMARY KEY (`id`) USING BTREE,
-  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE
+  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE,
+  UNIQUE INDEX `uk_overall_eval_assignment_student_version`(`assignment_id`, `student_id`, `version`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 20 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作业整体评估表' ROW_FORMAT = Dynamic;
 
 -- ----------------------------
@@ -218,9 +220,34 @@ CREATE TABLE `sentence_evaluation`  (
   `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '句子类型',
   `category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '句子类别',
   PRIMARY KEY (`id`) USING BTREE,
-  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE
+  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE,
+  UNIQUE INDEX `uk_sentence_eval_assignment_student_version_no`(`assignment_id`, `student_id`, `version`, `no`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 74 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '句子评估表' ROW_FORMAT = Dynamic;
 
+-- ----------------------------
+-- Migration notes (for existing databases)
+-- ----------------------------
+-- If your production DB already contains duplicate rows, the UNIQUE indexes above will fail to create.
+-- Recommended dedup strategy before adding UNIQUE indexes:
+--
+-- 1) evaluation: keep latest by time
+--   DELETE e1 FROM evaluation e1
+--   JOIN evaluation e2
+--     ON e1.assignment_id=e2.assignment_id AND e1.student_id=e2.student_id AND e1.version=e2.version
+--    AND e1.id < e2.id;
+--
+-- 2) overall_evaluation: keep latest row per (assignment_id, student_id, version)
+--   DELETE o1 FROM overall_evaluation o1
+--   JOIN overall_evaluation o2
+--     ON o1.assignment_id=o2.assignment_id AND o1.student_id=o2.student_id AND o1.version=o2.version
+--    AND o1.id < o2.id;
+--
+-- 3) sentence_evaluation: keep latest row per (assignment_id, student_id, version, no)
+--   DELETE s1 FROM sentence_evaluation s1
+--   JOIN sentence_evaluation s2
+--     ON s1.assignment_id=s2.assignment_id AND s1.student_id=s2.student_id AND s1.version=s2.version AND s1.no=s2.no
+--    AND s1.id < s2.id;
+
 -- ----------------------------
 -- Table structure for sign_records
 -- ----------------------------

+ 13 - 0
src/main/java/com/njuzr/eaibackend/controller/EvaluationController.java

@@ -1,6 +1,7 @@
 package com.njuzr.eaibackend.controller;
 
 import com.njuzr.eaibackend.service.AIEvaluationService;
+import com.njuzr.eaibackend.vo.EvaluationCurrentVO;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
@@ -104,6 +105,18 @@ public class EvaluationController {
         return MyResponse.success(aiEvaluationService.getVersionList(assignmentId, studentId));
     }
 
+    /**
+     * 获取当前最新提交版本号(student_assignment.version)与该版本的批改状态。
+     */
+    @GetMapping("/current")
+    public MyResponse getCurrent(
+            @RequestParam Long assignmentId,
+            @RequestParam Long studentId
+    ) {
+        EvaluationCurrentVO vo = aiEvaluationService.getCurrentEvaluationStatus(assignmentId, studentId);
+        return MyResponse.success(vo);
+    }
+
     /**
      * 用于支持单句重试
      * @param assignmentId

+ 207 - 45
src/main/java/com/njuzr/eaibackend/service/AIEvaluationService.java

@@ -1,5 +1,6 @@
 package com.njuzr.eaibackend.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.njuzr.eaibackend.constant.EvaluationPromptConstant;
@@ -9,6 +10,7 @@ import com.njuzr.eaibackend.dto.deepseek.DeepSeekResponse;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.mapper.*;
 import com.njuzr.eaibackend.po.*;
+import com.njuzr.eaibackend.vo.EvaluationCurrentVO;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.time.StopWatch;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.PlatformTransactionManager;
 import org.springframework.transaction.support.TransactionTemplate;
 import org.springframework.transaction.TransactionDefinition;
+import org.springframework.dao.DuplicateKeyException;
 
 import java.text.BreakIterator;
 import java.util.*;
@@ -105,6 +108,10 @@ public class AIEvaluationService {
         if(engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         if (assignment == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
+        final int version = engagement.getVersion();
+        log.info("[EVAL][OVERALL] request start assignmentId={}, studentId={}, version={}, textLen={}",
+                assignmentId, studentId, version,
+                engagement.getTextContent() == null ? -1 : engagement.getTextContent().length());
         /**
          * AI 作业评估主流程。
          *
@@ -131,37 +138,76 @@ public class AIEvaluationService {
          * - 防止系统异常导致任务永久卡死。
          * - 保证 evaluation 表始终只存在一条有效执行记录。
          */
+        // 1) 先检查当前版本是否已完成整体批改:已完成则直接返回历史结果(幂等)
+        Evaluation exist = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version);
+        log.info("[EVAL][OVERALL] current evaluation record assignmentId={}, studentId={}, version={}, exist={}, overall={}, sentence={}, time={}",
+                assignmentId, studentId, version,
+                exist != null,
+                exist == null ? null : exist.getOverall(),
+                exist == null ? null : exist.getSentence(),
+                exist == null ? null : exist.getTime());
+        if (exist != null && exist.getOverall() != null && exist.getOverall() == 1) {
+            OverallEvaluation overall = getOverallEvaluationIfExists(assignmentId, studentId, version);
+            if (overall != null) {
+                log.info("[EVAL][OVERALL] hit cached result assignmentId={}, studentId={}, version={}, evalLen={}",
+                        assignmentId, studentId, version,
+                        overall.getEvaluation() == null ? -1 : overall.getEvaluation().length());
+                return new AIEntry("assistant", overall.getEvaluation());
+            }
+            // 理论上不应该出现:标记完成但无结果;允许重新触发
+            log.warn("evaluation overall flag=1 but overall_evaluation missing, will re-run. assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        }
+
+        // 2) 抢占执行权:不存在则插入占位记录;并发时依赖唯一索引冲突
         try {
             initEvaluationRecord(assignmentId, studentId, engagement);
-        } catch (org.springframework.dao.DuplicateKeyException ex) {
-            Evaluation exist = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, engagement.getVersion());
-            if (exist == null) {
+            log.info("[EVAL][OVERALL] init evaluation lock success assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        } catch (DuplicateKeyException ex) {
+            log.info("[EVAL][OVERALL] init evaluation lock duplicate assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+            Evaluation locked = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version);
+            if (locked == null) {
                 throw MyException.create(HttpStatus.BAD_REQUEST, "作业正在进行AI分析,请稍后重试");
             }
-            if (exist.getOverall() == 0) {
 
+            // overall==0: 处理中;overall==1: 已完成(上面已处理直接返回)
+            if (locked.getOverall() != null && locked.getOverall() == 0) {
                 long timeoutMs = 10L * 60 * 1000;
                 Date expireBefore = new Date(System.currentTimeMillis() - timeoutMs);
-
-                if (exist.getTime() != null && exist.getTime().before(expireBefore)){
-                    evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, engagement.getVersion());
-                   throw MyException.create(HttpStatus.BAD_REQUEST, "之前的 AI 分析已超时,已为您清理,请重新发起");
-
-                } else {
-                    throw MyException.create(HttpStatus.BAD_REQUEST, "作业正在进行AI分析,请勿重复发起请求");
+                if (locked.getTime() != null && locked.getTime().before(expireBefore)) {
+                    evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, version);
+                    throw MyException.create(HttpStatus.BAD_REQUEST, "之前的 AI 分析已超时,已为您清理,请重新发起");
                 }
+                throw MyException.create(HttpStatus.BAD_REQUEST, "作业正在进行AI分析,请勿重复发起请求");
+            }
+
+            OverallEvaluation overall = getOverallEvaluationIfExists(assignmentId, studentId, version);
+            if (overall != null) {
+                log.info("[EVAL][OVERALL] duplicate request but result exists assignmentId={}, studentId={}, version={}, evalLen={}",
+                        assignmentId, studentId, version,
+                        overall.getEvaluation() == null ? -1 : overall.getEvaluation().length());
+                return new AIEntry("assistant", overall.getEvaluation());
             }
             throw MyException.create(HttpStatus.BAD_REQUEST, "作业已进行过AI分析");
         }
+
         try {
             String prompt = String.format(EvaluationPromptConstant.OVERALL_EVALUATION_PROMPT,
                     assignment.getDescription(), engagement.getTextContent());
+            log.info("[EVAL][OVERALL] llm request start assignmentId={}, studentId={}, version={}, promptLen={}",
+                    assignmentId, studentId, version, prompt.length());
             AIRequestService.AIResponse response = requestAIWithRetry(prompt, 0);
             String retContent = response.getChoices().get(0).getMessage().getContent();
             String role = response.getChoices().get(0).getMessage().getRole();
 
             // 保存Markdown格式的评估结果
             saveOverallEvaluationResult(assignmentId, studentId, engagement, retContent);
+            log.info("[EVAL][OVERALL] save success assignmentId={}, studentId={}, version={}, role={}, evalLen={}, cost={}",
+                    assignmentId, studentId, version, role,
+                    retContent == null ? -1 : retContent.length(),
+                    sw.formatTime());
 
             log.info("【评分完成】作文长度: {}, 评价长度: {}", 
                     engagement.getTextContent().length(), retContent.length());
@@ -170,7 +216,7 @@ public class AIEvaluationService {
 
         } catch (Exception e) {
             log.error("evaluation requestAI error: {}", e.getMessage(), e);
-            evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, engagement.getVersion());
+            evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, version);
             throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(),HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase() + ":" + "请求失败,请重新尝试"
             );
         }
@@ -216,19 +262,30 @@ public class AIEvaluationService {
 
     @Transactional(rollbackFor = Exception.class)
     public void saveOverallEvaluationResult(Long assignmentId, Long studentId, Engagement engagement, String retContent) {
+        final int version = engagement.getVersion();
         OverallEvaluation overallEvaluation = new OverallEvaluation()
             .setStudentId(studentId)
             .setAssignmentId(assignmentId)
             .setContent(engagement.getTextContent())
             .setEvaluation(retContent)
-            .setVersion(engagement.getVersion());
-        overallEvaluationMapper.insert(overallEvaluation);
+            .setVersion(version);
+        try {
+            overallEvaluationMapper.insert(overallEvaluation);
+            log.info("[EVAL][OVERALL] overall_evaluation insert ok assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        } catch (DuplicateKeyException e) {
+            // 幂等:同版本重复写入时忽略(依赖唯一索引)
+            log.info("overall_evaluation already exists, ignore duplicate insert. assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        }
 
         evaluationMapperServiceImpl.update(new UpdateWrapper<Evaluation>()
                 .set("overall", 1)
                 .eq("student_id", studentId)
                 .eq("assignment_id", assignmentId)
-                .eq("version", engagement.getVersion()));
+                .eq("version", version));
+        log.info("[EVAL][OVERALL] evaluation flag update overall=1 assignmentId={}, studentId={}, version={}",
+                assignmentId, studentId, version);
     }
 
     private AIRequestService.AIResponse requestAIWithRetry(String prompt, int retryTime) {
@@ -274,41 +331,56 @@ public class AIEvaluationService {
         if(engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         if (assignment == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
+        final int version = engagement.getVersion();
+        log.info("[EVAL][SENTENCE] request start assignmentId={}, studentId={}, version={}, textLen={}",
+                assignmentId, studentId, version,
+                engagement.getTextContent() == null ? -1 : engagement.getTextContent().length());
+
+        // 逐句批改可独立触发:确保 evaluation 占位记录存在(不依赖整体先跑)
+        Evaluation evaluationJudge = ensureEvaluationRecordExists(assignmentId, studentId, engagement);
+        log.info("[EVAL][SENTENCE] current evaluation record assignmentId={}, studentId={}, version={}, sentence={}, overall={}, time={}",
+                assignmentId, studentId, version,
+                evaluationJudge == null ? null : evaluationJudge.getSentence(),
+                evaluationJudge == null ? null : evaluationJudge.getOverall(),
+                evaluationJudge == null ? null : evaluationJudge.getTime());
+
+        // 检查是否已完成逐句批改(sentence=1表示已完成)
+        if (evaluationJudge != null && evaluationJudge.getSentence() != null && evaluationJudge.getSentence() == 1) {
+            log.info("逐句批改已完成,直接返回现有结果 - assignmentId: {}, studentId: {}, version: {}",
+                    assignmentId, studentId, version);
+            LambdaQueryWrapper<SentenceEvaluation> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(SentenceEvaluation::getAssignmentId, assignmentId)
+                    .eq(SentenceEvaluation::getStudentId, studentId)
+                    .eq(SentenceEvaluation::getVersion, version);
+            List<SentenceEvaluation> list = sentenceEvaluationMapperServiceImpl.list(queryWrapper);
+            log.info("[EVAL][SENTENCE] hit cached result assignmentId={}, studentId={}, version={}, count={}",
+                    assignmentId, studentId, version, list == null ? -1 : list.size());
+            return list;
+        }
 
-        // 没有智能评价记录时,进行重试,等待整体智能批改将记录写入数据库
-        // 使用编程式事务在新事务中查询,避免REPEATABLE READ隔离级别导致看不到其他事务提交的数据
-        int retryTime = 0;
-        Evaluation evaluationJudge = null;
-        while (retryTime < 3) {
-            final int currentVersion = engagement.getVersion();
-            evaluationJudge = requiresNewTransactionTemplate.execute(status ->
-                evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, currentVersion)
-            );
-            log.info("rewritePerSentence find evaluation record, assignmentId: {}, studentId: {}, version: {}", assignmentId, studentId, engagement.getVersion());
-            if (evaluationJudge == null){
-                try {
-                    log.info("rewritePerSentence find evaluation record failed, assignmentId: {}, studentId: {}, retryTime: {}", assignmentId, studentId, retryTime + 1);
-                    Thread.sleep(1500);
-                    retryTime++;
-                } catch (InterruptedException e) {
-                    Thread.currentThread().interrupt();
-                    throw MyException.create(HttpStatus.BAD_REQUEST, "处理被中断: " + e.getMessage());
-                }
-            } else {
-                break;
-            }
-        }   
-        if(retryTime >= 3) {
-            log.info("rewritePerSentence failed, assignmentId: {}, studentId: {}, reason: over max retry times(3)", assignmentId, studentId);
-            throw MyException.create(HttpStatus.BAD_REQUEST, "逐句批改失败,超过重试次数,请重新发起请求");
+        // sentence=2 表示处理中,直接提示前端轮询读取即可
+        if (evaluationJudge != null && evaluationJudge.getSentence() != null && evaluationJudge.getSentence() == 2) {
+            log.info("[EVAL][SENTENCE] already processing assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+            throw MyException.create(HttpStatus.BAD_REQUEST, "逐句批改正在进行中,请稍后刷新");
         }
 
+        // 设置状态为处理中(2),避免重复处理
+        evaluationMapperServiceImpl.update(new UpdateWrapper<Evaluation>()
+                .set("sentence", 2)
+                .eq("student_id", studentId)
+                .eq("assignment_id", assignmentId)
+                .eq("version", version)
+        );
+        log.info("[EVAL][SENTENCE] mark processing sentence=2 assignmentId={}, studentId={}, version={}",
+                assignmentId, studentId, version);
+
         try {
             String content = engagement.getTextContent();
             String[] sentences = getSentenceList(content);
 
             log.info("rewritePerSentence start, assignmentId: {}, studentId: {}, sentenceCount: {}, version: {}",
-                    assignmentId, studentId, sentences.length, engagement.getVersion());
+                    assignmentId, studentId, sentences.length, version);
 
             List<SentenceEvaluation> sentenceEvaluationsList = new ArrayList<>();
             @SuppressWarnings("unchecked")
@@ -323,7 +395,7 @@ public class AIEvaluationService {
                                 assignmentId, studentId,
                                 content, assignment.getDescription(),
                                 sentence, sentenceNo,
-                                engagement.getVersion()
+                                version
                         ),
                         aiPerSentenceExecutor
                 );
@@ -352,6 +424,8 @@ public class AIEvaluationService {
             // 3) 批量保存逐句结果
             if (!sentenceEvaluationsList.isEmpty()) {
                 sentenceEvaluationMapperServiceImpl.saveBatch(sentenceEvaluationsList);
+                log.info("[EVAL][SENTENCE] sentence_evaluation batch insert ok assignmentId={}, studentId={}, version={}, count={}",
+                        assignmentId, studentId, version, sentenceEvaluationsList.size());
             }
 
             // 4) 完成态:2 -> 1(只允许从处理中改成完成,避免误覆盖)
@@ -359,9 +433,11 @@ public class AIEvaluationService {
                     .set("sentence", 1)
                     .eq("student_id", studentId)
                     .eq("assignment_id", assignmentId)
-                    .eq("version", engagement.getVersion())
+                    .eq("version", version)
                     .eq("sentence", 2)
             );
+            log.info("[EVAL][SENTENCE] mark done sentence=1 assignmentId={}, studentId={}, version={}, cost={}",
+                    assignmentId, studentId, version, sw.formatTime());
 
             log.info("rewritePerSentence success, assignmentId: {}, studentId: {}, cost: {}",
                     assignmentId, studentId, sw.formatTime());
@@ -374,7 +450,7 @@ public class AIEvaluationService {
                 .set("sentence", 0)
                 .eq("student_id", studentId)
                 .eq("assignment_id", assignmentId)
-                .eq("version", engagement.getVersion())
+                .eq("version", version)
                 .eq("sentence", 2)
         );
         throw ex;
@@ -545,14 +621,19 @@ public class AIEvaluationService {
      * @return
      */
     public OverallEvaluation getRewrite(Long assignmentId, Long studentId, int version){
+        log.info("[EVAL][SELECT][OVERALL] select assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
         QueryWrapper<OverallEvaluation> queryWrapper =  new QueryWrapper<>();
         queryWrapper.eq("assignment_id", assignmentId);
         queryWrapper.eq("student_id", studentId);
         queryWrapper.eq("version", version);
         OverallEvaluation overallEvaluation = overallEvaluationMapper.selectOne(queryWrapper);
         if(overallEvaluation == null){
+            log.info("[EVAL][SELECT][OVERALL] not found assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI批改不存在");
         }
+        log.info("[EVAL][SELECT][OVERALL] found assignmentId={}, studentId={}, version={}, evalLen={}",
+                assignmentId, studentId, version,
+                overallEvaluation.getEvaluation() == null ? -1 : overallEvaluation.getEvaluation().length());
         return overallEvaluation;
     }
 
@@ -564,15 +645,19 @@ public class AIEvaluationService {
      * @return
      */
     public List<SentenceEvaluation> getRewritePerSentence(Long assignmentId, Long studentId, int version){
+        log.info("[EVAL][SELECT][SENTENCE] select assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
         List<SentenceEvaluation> sentenceEvaluations = sentenceEvaluationMapperServiceImpl.list(new QueryWrapper<SentenceEvaluation>()
                .eq("assignment_id", assignmentId)
                .eq("student_id", studentId)
                .eq("version", version));
 
         if(sentenceEvaluations.isEmpty()){
+            log.info("[EVAL][SELECT][SENTENCE] not found assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI批改不存在");
         }
 
+        log.info("[EVAL][SELECT][SENTENCE] found assignmentId={}, studentId={}, version={}, count={}",
+                assignmentId, studentId, version, sentenceEvaluations.size());
         return sentenceEvaluations.stream().sorted(Comparator.comparing(SentenceEvaluation::getNo)).toList();
     }
 
@@ -617,6 +702,83 @@ public class AIEvaluationService {
         return result;
     }
 
+    private OverallEvaluation getOverallEvaluationIfExists(Long assignmentId, Long studentId, int version) {
+        try {
+            QueryWrapper<OverallEvaluation> queryWrapper =  new QueryWrapper<>();
+            queryWrapper.eq("assignment_id", assignmentId);
+            queryWrapper.eq("student_id", studentId);
+            queryWrapper.eq("version", version);
+            return overallEvaluationMapper.selectOne(queryWrapper);
+        } catch (Exception e) {
+            log.warn("getOverallEvaluationIfExists failed: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private Evaluation ensureEvaluationRecordExists(Long assignmentId, Long studentId, Engagement engagement) {
+        final int version = engagement.getVersion();
+        Evaluation existing = requiresNewTransactionTemplate.execute(status ->
+                evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version)
+        );
+        if (existing != null) {
+            log.info("[EVAL][ENSURE] evaluation record exists assignmentId={}, studentId={}, version={}, overall={}, sentence={}",
+                    assignmentId, studentId, version, existing.getOverall(), existing.getSentence());
+            return existing;
+        }
+        try {
+            initEvaluationRecord(assignmentId, studentId, engagement);
+            log.info("[EVAL][ENSURE] init evaluation record ok assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        } catch (DuplicateKeyException e) {
+            // 并发插入:忽略并重新查
+            log.info("[EVAL][ENSURE] init evaluation record duplicate assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        }
+        Evaluation created = requiresNewTransactionTemplate.execute(status ->
+                evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version)
+        );
+        if (created == null) {
+            log.warn("[EVAL][ENSURE] init evaluation record failed assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+            throw MyException.create(HttpStatus.BAD_REQUEST, "初始化逐句批改记录失败,请重试");
+        }
+        log.info("[EVAL][ENSURE] evaluation record ready assignmentId={}, studentId={}, version={}, overall={}, sentence={}",
+                assignmentId, studentId, version, created.getOverall(), created.getSentence());
+        return created;
+    }
+
+    /**
+     * 获取当前最新提交版本号(student_assignment.version)以及该版本的整体/逐句批改状态。
+     */
+    public EvaluationCurrentVO getCurrentEvaluationStatus(Long assignmentId, Long studentId) {
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
+        if (engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
+        int version = engagement.getVersion();
+
+        Evaluation evaluation = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version);
+        Integer overall = evaluation == null ? 0 : Optional.ofNullable(evaluation.getOverall()).orElse(0);
+        Integer sentence = evaluation == null ? 0 : Optional.ofNullable(evaluation.getSentence()).orElse(0);
+
+        OverallEvaluation overallRow = getOverallEvaluationIfExists(assignmentId, studentId, version);
+        int sentenceCount = Math.toIntExact(sentenceEvaluationMapperServiceImpl.count(new LambdaQueryWrapper<SentenceEvaluation>()
+                .eq(SentenceEvaluation::getAssignmentId, assignmentId)
+                .eq(SentenceEvaluation::getStudentId, studentId)
+                .eq(SentenceEvaluation::getVersion, version)));
+        log.info("[EVAL][CURRENT] assignmentId={}, studentId={}, version={}, evalRow={}, overallStatus={}, sentenceStatus={}, overallRow={}, sentenceCount={}",
+                assignmentId, studentId, version,
+                evaluation != null,
+                overall, sentence,
+                overallRow != null,
+                sentenceCount);
+
+        return new EvaluationCurrentVO()
+                .setAssignmentId(assignmentId)
+                .setStudentId(studentId)
+                .setVersion(version)
+                .setOverallStatus(overall)
+                .setSentenceStatus(sentence);
+    }
+
     /**
      * 双智评:同时触发整体智评和逐句智评
      * 1. 创建智评记录

+ 5 - 0
src/main/java/com/njuzr/eaibackend/service/impl/AssignmentServiceImpl.java

@@ -448,6 +448,8 @@ public class AssignmentServiceImpl implements AssignmentService {
         Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
         if (engagement == null)
             throw MyException.create(HttpStatus.BAD_REQUEST, "参与作业情况不存在");
+        log.info("[ENGAGE][STAGE] start assignmentId={}, studentId={}, version(before)={}, status={}",
+                assignmentId, studentId, engagement.getVersion(), engagement.getStatus());
         String quillContent = jsonObject.getString("quillContent");
         String textContent = jsonObject.getString("textContent");
         String htmlContent = jsonObject.getString("htmlContent");
@@ -458,6 +460,9 @@ public class AssignmentServiceImpl implements AssignmentService {
             int code = studentAssignmentMapper.updateById(engagement);
             if (code == 0)
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
+            log.info("[ENGAGE][STAGE] success assignmentId={}, studentId={}, version(after)={}, textLen={}",
+                    assignmentId, studentId, engagement.getVersion(),
+                    textContent == null ? -1 : textContent.length());
             // 异步启动文本分析,不影响主流程返回速度
             try {
                 textAnalysisService.analyzeAndSaveTextContentAsync(studentId, assignmentId);

+ 21 - 0
src/main/java/com/njuzr/eaibackend/vo/EvaluationCurrentVO.java

@@ -0,0 +1,21 @@
+package com.njuzr.eaibackend.vo;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 当前版本评价状态:
+ * - version: student_assignment.version(提交/补交时自增后的最新版本)
+ * - overallStatus: evaluation.overall(0未完成 / 1完成)
+ * - sentenceStatus: evaluation.sentence(0未开始 / 2处理中 / 1完成)
+ */
+@Data
+@Accessors(chain = true)
+public class EvaluationCurrentVO {
+    private Long assignmentId;
+    private Long studentId;
+    private Integer version;
+    private Integer overallStatus;
+    private Integer sentenceStatus;
+}
+