Browse Source

feat:merge lll_refactor

lalala 4 tháng trước cách đây
mục cha
commit
378335ac99

+ 1 - 0
application.yaml

@@ -22,6 +22,7 @@ spring:
     mongodb:
       uri: mongodb://admin:eai123456@8.130.28.19:27017/eai?authSource=admin
       authentication-database: admin
+      auto-index-creation: true
     #redis配置更新
     redis:
       host: 8.130.28.19

+ 76 - 0
db/mysql_suggested_indexes.sql

@@ -0,0 +1,76 @@
+-- =============================================================================
+-- EAI MySQL 建议索引(结合 Mapper / 服务层查询,与 eai 库结构一致)
+-- 执行前请:1) 在从库或备份后执行  2) 用 SHOW INDEX / EXPLAIN 确认与线上一致
+-- 若报 Duplicate key / 索引名已存在,则跳过该条
+-- MySQL 8.0+ InnoDB
+-- =============================================================================
+
+USE eai;
+
+-- ---------------------------------------------------------------------------
+-- 1) assignment
+-- 依据:AssignmentMapper selectByCourseId + ORDER BY create_time DESC
+-- 仅 idx_course_id 时常见 Using filesort;联合索引可兼顾过滤与排序
+-- ---------------------------------------------------------------------------
+-- 若线上已有单列 idx_course_id 且会保留本联合索引,可视 EXPLAIN 结果再决定是否删除单列
+CREATE INDEX idx_assignment_course_create_time
+    ON assignment (course_id, create_time);
+
+-- 门户/教师维度按 teacher_portal_id 拉作业(若业务有此类查询,且列非空时选择性更好)
+-- CREATE INDEX idx_assignment_teacher_portal_id ON assignment (teacher_portal_id);
+
+-- ---------------------------------------------------------------------------
+-- 2) course_student
+-- 依据:CourseStudentMapper:按 course_id 拉人、按 student_id 拉课、dropCourse 等值双条件
+-- 原 eai.sql 无二级索引
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_course_student_course_id ON course_student (course_id);
+CREATE INDEX idx_course_student_student_id ON course_student (student_id);
+-- 若需强制「同一学生同一课一条」且已清洗完重复数据,可再考虑:
+-- ALTER TABLE course_student ADD CONSTRAINT uk_cs UNIQUE (course_id, student_id);
+
+-- ---------------------------------------------------------------------------
+-- 3) users
+-- 依据:UserMapper selectByOfficialNumber / Phone / OfficialEmail、selectNameByPid、
+--       IN (official_number)、登录态 id 等
+-- 原 eai.sql 无二级索引
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_users_official_number ON users (official_number);
+CREATE INDEX idx_users_pid ON users (pid);
+CREATE INDEX idx_users_phone ON users (phone);
+CREATE INDEX idx_users_official_email ON users (official_email);
+
+-- ---------------------------------------------------------------------------
+-- 4) ai_speaking_assignment
+-- 依据:按 assignment_id 反查口语任务扩展信息
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_ai_speaking_assignment_fk ON ai_speaking_assignment (assignment_id);
+
+-- ---------------------------------------------------------------------------
+-- 5) student_window_switch_record
+-- 依据:WindowSwitchRecordMapper:WHERE assignment_id = ?(且 GROUP BY 学生);
+--       现有多为 (student_id, assignment_id) 时,无法很好支持仅 assignment 过滤
+-- 注意:表需在库中存在、列名与下划线一致(与 MyBatis-Plus 默认驼峰一致)
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_window_switch_assignment_student
+    ON student_window_switch_record (assignment_id, student_id);
+
+-- 若上表已有 idx_student_assignment(student_id, assignment_id) 可保留,两者用途不同、可并存
+
+-- ---------------------------------------------------------------------------
+-- 6) 可选清理:与唯一约束重复的二级索引(减少写入与空间)
+-- ---------------------------------------------------------------------------
+-- phone_verification 上若已有 UNIQUE(phone),则普通索引 verification_phone_index 可删:
+-- ALTER TABLE phone_verification DROP INDEX verification_phone_index;
+
+-- course 上 idx_time_range 与 idx_course_time_range 若列完全一致,保留一个即可:
+-- ALTER TABLE course DROP INDEX idx_course_time_range;
+-- 或 DROP INDEX idx_course_time_range 保留 idx_course_time_range 其一
+
+-- =============================================================================
+-- 已在 eai.sql 中覆盖较好、一般无需再补(仅作说明)
+-- - student_assignment:uk(assignment_id, student_id) + idx(student_id, assignment_id)
+-- - evaluation / overall_evaluation / sentence_evaluation:唯一键与 idx_student_assignment
+-- - student_assignment_history:uk 与 idx(assignment, student, submit_time)
+-- - translations、sign_*、class:已有合适索引
+-- =============================================================================

+ 8 - 3
src/main/java/com/njuzr/eaibackend/controller/AIController.java

@@ -72,7 +72,7 @@ public class AIController {
             @RequestParam Long assignmentId,
             @AuthenticationPrincipal(expression = "id") Long userId
     ) {
-        aiDialogueService.createAIDialogue(assignmentId, userId);
+        aiDialogueService.ensureAIDialogueExists(assignmentId, userId);
         return MyResponse.success("创建AI会话成功");
     }
 
@@ -103,10 +103,11 @@ public class AIController {
     @PutMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
     public SseEmitter requestAIStream(
             @RequestParam String dialogueId,
-            @RequestBody(required = false) AIDTO aidto
+            @RequestBody(required = false) AIDTO aidto,
+            @AuthenticationPrincipal(expression = "id") Long principalUserId
     ) {
         log.info("========== 流式接口被调用 ==========");
-        log.info("流式对话请求 - dialogueId={}, aidto={}", dialogueId, aidto);
+        log.info("流式对话请求 - dialogueId={}, aidto={}, principalUserId={}", dialogueId, aidto, principalUserId);
         
         if (aidto == null) {
             log.error("【错误】请求体为空");
@@ -120,6 +121,10 @@ public class AIController {
             emitter.completeWithError(new RuntimeException("消息列表不能为空"));
             return emitter;
         }
+
+        if (aidto.getUserId() == null && principalUserId != null) {
+            aidto.setUserId(principalUserId);
+        }
         
         log.info("【参数校验通过】dialogueId={}, 消息数={}", dialogueId, aidto.getMessages().size());
         

+ 1 - 1
src/main/java/com/njuzr/eaibackend/controller/AssignmentController.java

@@ -140,7 +140,7 @@ public class AssignmentController {
             @RequestParam Long assignmentId
     ) {
         assignmentService.engageAssignment(studentId, assignmentId);
-        aiDialogueService.createAIDialogue(assignmentId, studentId);
+        aiDialogueService.ensureAIDialogueExists(assignmentId, studentId);
         return MyResponse.success("参加作业成功");
     }
 

+ 5 - 0
src/main/java/com/njuzr/eaibackend/dto/AIDTO.java

@@ -15,4 +15,9 @@ import java.util.List;
 public class AIDTO {
     private String model; // 设置AI类别,目前支持ChatGLM3、Qwen
     private List<AIEntry> messages;
+    /**
+     * 用于在无会话/会话 id 失配时自动补建/纠偏(可选,建议前端在流式对话时携带)
+     */
+    private Long assignmentId;
+    private Long userId;
 }

+ 12 - 4
src/main/java/com/njuzr/eaibackend/po/AIDialogue.java

@@ -3,8 +3,9 @@ package com.njuzr.eaibackend.po;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.redis.core.index.Indexed;
 
 import java.util.List;
 
@@ -16,15 +17,22 @@ import java.util.List;
 
 @Data
 @Document(collection = "aiDialogues")
+@CompoundIndexes({
+        // findOne(assignmentId+userId)、按作业前缀查询、$push 按 _id
+        @CompoundIndex(
+                name = "idx_ai_dialogue_assignment_user",
+                def = "{'assignmentId': 1, 'userId': 1}",
+                // 历史数据可能重复,先不唯一;去重后可在库中改回 unique
+                background = true
+        )
+})
 public class AIDialogue {
     @Id
     private String id;
 
-    @Indexed
     private Long assignmentId; // 外键
 
-    @Indexed
-    private Long userId; // 外键,需要建立(assignmentId, userId)外键索引
+    private Long userId; // 外键
 
     private List<DialogueEntry> dialogues;
 

+ 11 - 4
src/main/java/com/njuzr/eaibackend/po/AIRewriteRecord.java

@@ -3,8 +3,9 @@ package com.njuzr.eaibackend.po;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.redis.core.index.Indexed;
 
 import java.util.List;
 
@@ -16,15 +17,21 @@ import java.util.List;
 
 @Data
 @Document(collection = "aiRewriteRecords")
+@CompoundIndexes({
+        @CompoundIndex(
+                name = "idx_ai_rewrite_assignment_user",
+                def = "{'assignmentId': 1, 'userId': 1}",
+                unique = true,
+                background = true
+        )
+})
 public class AIRewriteRecord {
     @Id
     private String id;
 
-    @Indexed
     private Long assignmentId; // 外键
 
-    @Indexed
-    private Long userId; // 外键,需要建立(assignmentId, userId)外键索引
+    private Long userId; // 外键
 
     private List<RecordEntry> records;
 

+ 6 - 3
src/main/java/com/njuzr/eaibackend/po/BehaviorOverview.java

@@ -4,6 +4,7 @@ import lombok.Data;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.mongodb.core.index.CompoundIndex;
 import org.springframework.data.mongodb.core.index.CompoundIndexes;
+import org.springframework.data.mongodb.core.index.Indexed;
 import org.springframework.data.mongodb.core.mapping.Document;
 import org.springframework.data.mongodb.core.mapping.Field;
 
@@ -18,10 +19,11 @@ import java.util.List;
 @Data
 @Document(collection = "behaviorOverviews")
 @CompoundIndexes({
-        // 复合唯一索引:确保 student_id + assignment_id 的业务唯一性
+        // 唯一:(assignment, student) —— 与 findByAssignmentId、按作业+学生查询、聚合 $match 一致
+        // 若库中已有同名旧索引 student_assignment_unique( student_id, assignment_id ),可手动 drop 后由应用重建
         @CompoundIndex(
-                name = "student_assignment_unique",
-                def = "{'student_id': 1, 'assignment_id': 1}",
+                name = "uk_behavior_overview_assignment_student",
+                def = "{'assignment_id': 1, 'student_id': 1}",
                 unique = true,
                 background = true
         )
@@ -32,6 +34,7 @@ public class BehaviorOverview {
     private String id; // MongoDB自动生成的ObjectId
 
     @Field("student_id")
+    @Indexed(name = "idx_behavior_overview_student", background = true)
     private Long studentId; // 学生ID
 
     @Field("assignment_id")

+ 10 - 3
src/main/java/com/njuzr/eaibackend/po/SpeakingAIDialogue.java

@@ -5,8 +5,9 @@ import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.redis.core.index.Indexed;
 
 import java.util.List;
 
@@ -16,14 +17,20 @@ import java.util.List;
  */
 @Data
 @Document(collection = "speakingAiDialogues")
+@CompoundIndexes({
+        // findOne(assignmentId+userId)、仅 assignmentId 过滤(左前缀)
+        @CompoundIndex(
+                name = "idx_speaking_dialogue_assignment_user",
+                def = "{'assignmentId': 1, 'userId': 1}",
+                background = true
+        )
+})
 public class SpeakingAIDialogue {
     @Id
     private String id;
 
-    @Indexed
     private Long assignmentId; // 外键
 
-    @Indexed
     private Long userId;
 
     /**

+ 11 - 5
src/main/java/com/njuzr/eaibackend/po/TextAnalysis.java

@@ -3,11 +3,10 @@ package com.njuzr.eaibackend.po;
 import com.njuzr.eaibackend.vo.TextAnalysisVO;
 import lombok.Data;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.mongodb.core.mapping.Field;
-import org.springframework.data.redis.core.index.Indexed;
 
-import java.util.Date;
 import java.util.List;
 
 /**
@@ -17,15 +16,22 @@ import java.util.List;
  */
 @Data
 @Document(collection = "textAnalyses")
+@CompoundIndexes({
+        // getTextAnalysis:studentId + assignmentId
+        @CompoundIndex(
+                name = "idx_text_analysis_student_assignment",
+                def = "{'studentId': 1, 'assignmentId': 1}",
+                unique = true,
+                background = true
+        )
+})
 public class TextAnalysis {
 
     @Id
     private String id; // MongoDB自动生成的ObjectId
     // 1. 标识信息
-    @Indexed
     private Long studentId; // 学生ID
 
-    @Indexed
     private Long assignmentId; // 作业ID
 
     // 2. 原始文本

+ 90 - 5
src/main/java/com/njuzr/eaibackend/service/AIDialogueService.java

@@ -21,6 +21,7 @@ import org.springframework.data.mongodb.core.aggregation.*;
 import org.springframework.data.mongodb.core.query.Criteria;
 import org.springframework.data.mongodb.core.query.Query;
 import org.springframework.data.mongodb.core.query.Update;
+import com.mongodb.client.result.UpdateResult;
 import org.springframework.http.HttpStatus;
 import org.springframework.stereotype.Service;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -85,6 +86,14 @@ public class AIDialogueService {
         aiDialogueMapper.save(aiDialogue);
     }
 
+    /**
+     * 幂等补建:若 (assignmentId, userId) 在 Mongo 中已有会话则直接返回,否则创建空会话。
+     * 用于参加作业、流式前纠偏等场景,避免「已参与但无 AIDialogue」或重复调用 createAIDialogue 抛错导致整体失败。
+     */
+    public void ensureAIDialogueExists(Long assignmentId, Long userId) {
+        findOrCreateByAssignmentAndUser(assignmentId, userId, false);
+    }
+
     public void createAIRewriteRecords(Long assignmentId, Long userId) {
         if (aiRewriteRecordMapper.findByAssignmentIdAndUserId(assignmentId, userId, PageRequest.of(0,1)).hasContent()) {
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI改写记录已存在");
@@ -115,7 +124,14 @@ public class AIDialogueService {
         );
 
         if (targetDialogue == null) {
-            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase()+":"+"找不到AI会话");
+            // 自愈合:如果历史上出现“能对话但无会话文档”的脏数据,补一条空会话,避免前端反复 400
+            log.warn("【AI会话】未找到对话文档,将自动补建: assignmentId={}, userId={}", assignmentId, userId);
+            AIDialogue created = new AIDialogue();
+            created.setAssignmentId(assignmentId);
+            created.setUserId(userId);
+            created.setDialogues(new ArrayList<>());
+            AIDialogue saved = mongoTemplate.save(created, "aiDialogues");
+            targetDialogue = saved;
         }
 
         Pageable pageable = PageRequest.of(page, size);
@@ -270,9 +286,14 @@ public class AIDialogueService {
                     assistantMsgCount++;
                 }
             }
-            log.info("【Service层】消息统计 - user消息={}, assistant消息={}, 总消息数={}", 
+            log.info("【Service层】消息统计 - user消息={}, assistant消息={}, deepSeek入参消息数(含system)={}", 
                     userMsgCount, assistantMsgCount, messages.size());
 
+            // 在发起外部模型请求前,确保 Mongo 中存在可用于 $push 的根文档;必要时按 assignmentId+userId 自动补建/纠偏 dialogueId
+            String effectiveDialogueId = resolveOrCreateDialogueId(dialogueId, aidto);
+            log.info("【Service层】resolved dialogueId: requested={}, effective={}, assignmentId={}, userId={}",
+                    dialogueId, effectiveDialogueId, aidto.getAssignmentId(), aidto.getUserId());
+
             SseEmitter emitter = new SseEmitter(0L);
             StringBuilder fullResponse = new StringBuilder();
             
@@ -297,7 +318,7 @@ public class AIDialogueService {
                             fullResponse.toString(), 
                             System.currentTimeMillis() / 1000
                     );
-                    addDialogueEntry(dialogueId, userEntry, aiEntry);
+                    addDialogueEntry(effectiveDialogueId, userEntry, aiEntry);
                     emitter.complete();
                     log.info("【Service层】emitter.complete()已调用");
                 },
@@ -319,6 +340,61 @@ public class AIDialogueService {
         }
     }
 
+    /**
+     * 解析/补建对话根文档 id:
+     * - 优先信任请求参数 dialogueId(若存在且能查到)
+     * - 否则按 assignmentId+userId 查找/创建
+     * - 若仅有 userId 缺失,尝试用 aidto.userId(若前端传入)
+     */
+    private String resolveOrCreateDialogueId(String requestedDialogueId, AIDTO aidto) {
+        if (requestedDialogueId != null && !requestedDialogueId.isBlank()) {
+            AIDialogue byId = mongoTemplate.findById(requestedDialogueId, AIDialogue.class);
+            if (byId != null) {
+                return byId.getId();
+            }
+            log.warn("【AI会话】request dialogueId 不存在,将尝试按 assignmentId+userId 纠偏/补建。requestedDialogueId={}", requestedDialogueId);
+        }
+
+        Long assignmentId = aidto == null ? null : aidto.getAssignmentId();
+        Long userId = aidto == null ? null : aidto.getUserId();
+        if (assignmentId == null || userId == null) {
+            throw MyException.create(HttpStatus.BAD_REQUEST,
+                    "AI会话不存在,且缺少补建参数(assignmentId/userId)。请刷新页面后重试,或在流式请求体携带 assignmentId,并由登录态或请求体提供 userId");
+        }
+
+        return findOrCreateByAssignmentAndUser(assignmentId, userId, true);
+    }
+
+    /**
+     * 按 (assignmentId, userId) 查 Mongo 会话;不存在则补建。优先走 Repository 分页,避免与 MongoTemplate 各查各的导致误判。
+     *
+     * @param logRepair true 时仅在“本次新建了文档”打 warn 日志(流式纠偏场景);false 为参与作业等静默幂等
+     */
+    private String findOrCreateByAssignmentAndUser(Long assignmentId, Long userId, boolean logRepair) {
+        Page<AIDialogue> page = aiDialogueMapper.findByAssignmentIdAndUserId(assignmentId, userId, PageRequest.of(0, 1));
+        if (page.hasContent()) {
+            return page.getContent().get(0).getId();
+        }
+        AIDialogue byPair = mongoTemplate.findOne(
+                Query.query(Criteria.where("assignmentId").is(assignmentId).and("userId").is(userId)),
+                AIDialogue.class
+        );
+        if (byPair != null) {
+            return byPair.getId();
+        }
+        AIDialogue created = new AIDialogue();
+        created.setAssignmentId(assignmentId);
+        created.setUserId(userId);
+        created.setDialogues(new ArrayList<>());
+        AIDialogue saved = mongoTemplate.save(created, "aiDialogues");
+        if (logRepair) {
+            log.warn("【AI会话】已自动补建对话文档: id={}, assignmentId={}, userId={}", saved.getId(), assignmentId, userId);
+        } else {
+            log.info("【AI会话】ensure: 已补建空会话, id={}, assignmentId={}, userId={}", saved.getId(), assignmentId, userId);
+        }
+        return saved.getId();
+    }
+
 
     public AIEntry rewrite(Long assignmentId, Long studentId) {
         // 1 通过assignmentId和studentId找到engagement,获取当前文件链接
@@ -409,9 +485,18 @@ public class AIDialogueService {
         try {
             Query query =  new Query(Criteria.where("id").is(dialogueId));
             Update update = new Update().push("dialogues").each(userEntry, aiEntry);
-            mongoTemplate.updateFirst(query, update, AIDialogue.class);
+            UpdateResult result = mongoTemplate.updateFirst(query, update, AIDialogue.class);
+            if (result == null || result.getMatchedCount() == 0) {
+                log.error("【AI会话】写库未匹配到对话文档: dialogueId={}, matched={}, modified={}",
+                        dialogueId,
+                        result == null ? "null" : result.getMatchedCount(),
+                        result == null ? "null" : result.getModifiedCount());
+                throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "AI会话不存在,无法保存对话");
+            }
+        } catch (MyException e) {
+            throw e;
         } catch (Exception e) {
-            log.error("MongoDB数据库更新出错");
+            log.error("MongoDB数据库更新出错: {}", e.getMessage(), e);
             throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()+":"+"数据库更新失败");
         }
     }

+ 2 - 0
src/main/resources/application-dev.yaml

@@ -21,6 +21,8 @@ spring:
     mongodb:
       uri: mongodb://admin:eai123456@139.196.252.184:27017/eai?authSource=admin
       authentication-database: admin
+      # 根据 @Document/@CompoundIndex 等元数据在启动时建索引(生产可改为运维脚本建索引后关闭)
+      auto-index-creation: true
     redis:
       host: 139.196.252.184
       port: 6378

+ 1 - 0
src/main/resources/application-prod.yaml

@@ -21,6 +21,7 @@ spring:
     mongodb:
       uri: mongodb://admin:eai123456@139.196.252.184:27017/eai?authSource=admin
       authentication-database: admin
+      auto-index-creation: true
     redis:
       host: 139.196.252.184
       port: 6378