Browse Source

add ai_speaking_module

lalala 1 year ago
parent
commit
9e705e9968
38 changed files with 1976 additions and 42 deletions
  1. 19 3
      application.yaml
  2. 23 0
      pom.xml
  3. 32 0
      src/main/java/com/njuzr/eaibackend/config/DoubaoConfig.java
  4. 50 0
      src/main/java/com/njuzr/eaibackend/config/IseClientFactory.java
  5. 65 0
      src/main/java/com/njuzr/eaibackend/config/ThreadPoolConfig.java
  6. 64 0
      src/main/java/com/njuzr/eaibackend/controller/AiSpeakingAssignmentController.java
  7. 2 1
      src/main/java/com/njuzr/eaibackend/controller/AssignmentController.java
  8. 0 1
      src/main/java/com/njuzr/eaibackend/controller/CourseController.java
  9. 72 0
      src/main/java/com/njuzr/eaibackend/controller/DoubaoController.java
  10. 1 0
      src/main/java/com/njuzr/eaibackend/controller/FileController.java
  11. 46 0
      src/main/java/com/njuzr/eaibackend/controller/MediaAnalysisController.java
  12. 24 0
      src/main/java/com/njuzr/eaibackend/dto/AiSpeakingAssignmentDTO.java
  13. 5 2
      src/main/java/com/njuzr/eaibackend/dto/AssignmentDTO.java
  14. 2 0
      src/main/java/com/njuzr/eaibackend/dto/AssignmentQueryDTO.java
  15. 4 2
      src/main/java/com/njuzr/eaibackend/dto/AssignmentUpdateDTO.java
  16. 2 2
      src/main/java/com/njuzr/eaibackend/dto/course/CourseDTO.java
  17. 2 2
      src/main/java/com/njuzr/eaibackend/dto/course/CourseUpdateDTO.java
  18. 31 0
      src/main/java/com/njuzr/eaibackend/enums/VoiceType.java
  19. 13 0
      src/main/java/com/njuzr/eaibackend/mapper/AiSpeakingAssignmentMapper.java
  20. 14 0
      src/main/java/com/njuzr/eaibackend/mapper/SpeakingAIDialogueMapper.java
  21. 29 0
      src/main/java/com/njuzr/eaibackend/po/AiSpeakingAssignment.java
  22. 2 0
      src/main/java/com/njuzr/eaibackend/po/Assignment.java
  23. 115 0
      src/main/java/com/njuzr/eaibackend/po/SpeakingAIDialogue.java
  24. 3 3
      src/main/java/com/njuzr/eaibackend/service/AIRequestService.java
  25. 21 0
      src/main/java/com/njuzr/eaibackend/service/AiSpeakingAssignmentService.java
  26. 28 0
      src/main/java/com/njuzr/eaibackend/service/DoubaoService.java
  27. 20 0
      src/main/java/com/njuzr/eaibackend/service/MediaAnalysisService.java
  28. 172 0
      src/main/java/com/njuzr/eaibackend/service/impl/AiSpeakingAssignmentServiceImpl.java
  29. 9 3
      src/main/java/com/njuzr/eaibackend/service/impl/AssignmentServiceImpl.java
  30. 402 0
      src/main/java/com/njuzr/eaibackend/service/impl/DoubaoServiceImpl.java
  31. 502 0
      src/main/java/com/njuzr/eaibackend/service/impl/MediaAnalysisServiceImpl.java
  32. 23 23
      src/main/java/com/njuzr/eaibackend/service/impl/UserServiceImpl.java
  33. 95 0
      src/main/java/com/njuzr/eaibackend/utils/XmlParasUtil.java
  34. 17 0
      src/main/java/com/njuzr/eaibackend/vo/AIReplyVo.java
  35. 24 0
      src/main/java/com/njuzr/eaibackend/vo/AiSpeakingAssignmentVO.java
  36. 2 0
      src/main/java/com/njuzr/eaibackend/vo/AssignmentVO.java
  37. 16 0
      src/main/java/com/njuzr/eaibackend/vo/RestrictDataVo.java
  38. 25 0
      src/main/java/com/njuzr/eaibackend/vo/VideoAnalysisVo.java

+ 19 - 3
application.yaml

@@ -22,6 +22,12 @@ spring:
     username: seecoder2022@163.com # Seecoder的公共邮箱
     password: WEIHUIOOJYUFMZGV # 邮箱开启stmp的秘钥
     default-encoding: UTF-8
+  servlet:
+    multipart:
+      # 单个文件最大大小
+      max-file-size: 500MB
+      # 所有上传文件最大大小
+      max-request-size: 500MB
 
 
 # MyBatis配置
@@ -45,9 +51,6 @@ aliyun:
     bucketName: eai-files
 
 
-logging:
-  config: classpath:log4j2-prod.xml
-
 openai:
   chatgpt:
     model: gpt-3.5-turbo
@@ -69,7 +72,20 @@ config:
       url: https://dify.seec.seecoder.cn/v1/workflows/run
       token: app-6hPbwfdQdEDZ8c81oh62avyS
 
+doubao:
+  ark:
+    api-key: a60ca069-fdd2-4060-a6a4-1a63d7659480
+    endpoint-id: ep-20250210154808-gj4ln
+xunfei:
+  ark:
+    app-id: fdf4a5d8
+    api-key: 9d44aad124b15562cd36f1c69f907c23
+    api-secret: MWZkZTM5ZjMwMGJkNjJkM2NmNjJjMjI3
 
+microsoft:
+  ark:
+    speech_key: DzMBKYw2oVnL7brsyLZYlpvOqhiCDshlzRqQNtDOrwJA6KL5DOdcJQQJ99BCAC3pKaRXJ3w3AAAYACOGDWcJ
+    speech_regin: eastasia
 
 proxy:
   enabled: false

+ 23 - 0
pom.xml

@@ -168,6 +168,29 @@
             <version>5.2.5</version>
         </dependency>
 
+        <!-- 豆包大语言模型依赖       -->
+        <dependency>
+            <groupId>com.volcengine</groupId>
+            <artifactId>volcengine-java-sdk-ark-runtime</artifactId>
+            <version>LATEST</version>
+        </dependency>
+        <!--   讯飞语音合成依赖     -->
+        <dependency>
+            <groupId>cn.xfyun</groupId>
+            <artifactId>websdk-java-speech</artifactId>
+            <version>2.0.3</version>
+        </dependency>
+        <!--    微软语音合成依赖    -->
+        <dependency>
+            <groupId>com.microsoft.cognitiveservices.speech</groupId>
+            <artifactId>client-sdk</artifactId>
+            <version>1.42.0</version>
+        </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>3.14.2</version>
+        </dependency>
 
 
     </dependencies>

+ 32 - 0
src/main/java/com/njuzr/eaibackend/config/DoubaoConfig.java

@@ -0,0 +1,32 @@
+package com.njuzr.eaibackend.config;
+
+import com.volcengine.ark.runtime.service.ArkService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * @author Liululin
+ * @date 2025/4/3 - 8:14
+ *
+ * 豆包大语言模型配置
+ */
+@Configuration
+public class DoubaoConfig {
+    @Value("${doubao.ark.api-key}")
+    private String apiKey;
+    @Value("${doubao.ark.endpoint-id}")
+    private String endpointId;
+    @Bean
+    public ArkService arkService() {
+        return new ArkService(apiKey);
+    }
+
+    public String getApiKey() {
+        return apiKey;
+    }
+
+    public String getEndpointId() {
+        return endpointId;
+    }
+}

+ 50 - 0
src/main/java/com/njuzr/eaibackend/config/IseClientFactory.java

@@ -0,0 +1,50 @@
+package com.njuzr.eaibackend.config;
+
+import cn.xfyun.api.IseClient;
+import com.microsoft.cognitiveservices.speech.SpeechConfig;
+import com.microsoft.cognitiveservices.speech.SpeechSynthesizer;
+import com.microsoft.cognitiveservices.speech.audio.AudioConfig;
+import com.microsoft.cognitiveservices.speech.audio.AudioOutputStream;
+import com.microsoft.cognitiveservices.speech.audio.PullAudioOutputStream;
+import com.njuzr.eaibackend.enums.VoiceType;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+import javax.annotation.PostConstruct;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author Liululin
+ * @date 2025/4/3 - 8:17
+ *
+ * 语音评估客户端
+ */
+@Slf4j
+@Configuration
+public class IseClientFactory {
+    @Value("${xunfei.ark.app-id}")
+    private  String appId;
+    @Value("${xunfei.ark.api-key}")
+    private  String apiKey;
+    @Value("${xunfei.ark.api-secret}")
+    private  String apiSecret;
+
+    public IseClient getIseClient(String text){
+        return new IseClient.Builder()
+                .signature(appId, apiKey, apiSecret)
+                .addSub("ise")
+                .addEnt("en_vip")
+                .addCategory("read_sentence")
+                .addTte("utf-8")
+                .addText('\uFEFF' + text)
+                .addRstcd("utf8")
+                .addExtraAbility("multi_dimension")
+                .addRst("entirety")
+                .addIseUnite("1")
+                .addAue("raw")
+                .build();
+    }
+
+}

+ 65 - 0
src/main/java/com/njuzr/eaibackend/config/ThreadPoolConfig.java

@@ -0,0 +1,65 @@
+package com.njuzr.eaibackend.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * @author Liululin
+ * @date 2025/4/3 - 8:23
+ */
+@Configuration
+@EnableAsync
+@Slf4j
+public class ThreadPoolConfig {
+    @Bean(name = "doubaoTaskExecutor")
+    public ThreadPoolTaskExecutor doubaoTaskExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor() {
+            @Override
+            public void execute(Runnable task) {
+                // 记录任务提交日志
+                log.info("Task submitted to thread pool: {}", task.toString());
+                super.execute(wrap(task));
+            }
+
+            private Runnable wrap(Runnable task) {
+                return () -> {
+                    try {
+                        // 记录任务开始日志
+                        log.info("Task started: {}", task.toString());
+                        task.run();
+                        // 记录任务完成日志
+                        log.info("Task completed: {}", task);
+                    } catch (Exception e) {
+                        // 记录任务异常日志
+                        log.error("Task failed: {}", task.toString(), e);
+                        throw e;
+                    }
+                };
+            }
+        };
+
+        // 设置核心线程数
+        executor.setCorePoolSize(20);
+        // 设置最大线程数
+        executor.setMaxPoolSize(50);
+        // 设置队列容量
+        executor.setQueueCapacity(50);
+        // 设置线程名称前缀
+        executor.setThreadNamePrefix("Doubao-");
+        // 线程池关闭的时候等待所有任务都完成再继续销毁其他的 Bean
+        executor.setWaitForTasksToCompleteOnShutdown(true);
+        // 设置线程池中任务的等待时间,如果超过这个时间还没有销毁就强制销毁
+        executor.setAwaitTerminationSeconds(60);
+
+        // 设置拒绝策略(当队列和线程池都满了时的处理方式)
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+
+        executor.initialize();
+        return executor;
+    }
+}

+ 64 - 0
src/main/java/com/njuzr/eaibackend/controller/AiSpeakingAssignmentController.java

@@ -0,0 +1,64 @@
+package com.njuzr.eaibackend.controller;
+
+import com.njuzr.eaibackend.dto.AiSpeakingAssignmentDTO;
+import com.njuzr.eaibackend.po.MyUserDetails;
+import com.njuzr.eaibackend.service.AiSpeakingAssignmentService;
+import com.njuzr.eaibackend.vo.AiSpeakingAssignmentVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 14:43
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/aispeaking")
+public class AiSpeakingAssignmentController {
+
+    @Autowired
+    AiSpeakingAssignmentService assignmentService;
+
+    @PreAuthorize("hasRole('TEACHER')")
+    @PostMapping
+    public MyResponse createAssignment(
+            @AuthenticationPrincipal MyUserDetails user,
+            @RequestParam Long courseId,
+            @Validated @RequestBody AiSpeakingAssignmentDTO aiSpeakingAssignmentDTO
+    ) {
+        assignmentService.createSpeakingAssignment(user, courseId, aiSpeakingAssignmentDTO);
+        return MyResponse.success("创建听力任务成功!");
+    }
+
+    @GetMapping("/byAssignment/{assignmentId}")
+    public MyResponse getAssignmentById(
+            @PathVariable Long assignmentId
+    ) {
+        return MyResponse.success(assignmentService.findAssignmentById(assignmentId));
+    }
+
+    @PreAuthorize("hasRole('TEACHER')")
+    @PostMapping("/update")
+    public MyResponse updateAssignment(
+            @AuthenticationPrincipal MyUserDetails user,
+            @RequestParam Long assignmentId,
+            @RequestBody AiSpeakingAssignmentDTO aiSpeakingAssignmentDTO
+    ) {
+        return MyResponse.success(assignmentService.updateAssignment(user, assignmentId, aiSpeakingAssignmentDTO));
+    }
+
+    @PreAuthorize("hasRole('TEACHER')")
+    @DeleteMapping
+    public MyResponse deleteAssignment(
+            @AuthenticationPrincipal(expression = "id") Long teacherId,
+            @RequestParam Long assignmentId
+    ) {
+        assignmentService.deleteById(teacherId, assignmentId);
+        return MyResponse.success("删除作业成功");
+    }
+
+}

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

@@ -58,6 +58,7 @@ public class AssignmentController {
             @RequestParam Long courseId,
             @Validated @RequestBody AssignmentDTO assignmentDTO
     ) {
+
         return MyResponse.success(assignmentService.createAssignment(user, courseId, assignmentDTO));
     }
 
@@ -95,7 +96,7 @@ public class AssignmentController {
     }
 
     @PreAuthorize("hasRole('TEACHER')")
-    @PutMapping
+    @PostMapping("/update")
     public MyResponse updateAssignment(
             @AuthenticationPrincipal MyUserDetails user,
             @RequestParam Long assignmentId,

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

@@ -36,7 +36,6 @@ public class CourseController {
         this.courseService = courseService;
     }
 
-
     /**
      * 创建课程
      * @param courseDTO

+ 72 - 0
src/main/java/com/njuzr/eaibackend/controller/DoubaoController.java

@@ -0,0 +1,72 @@
+package com.njuzr.eaibackend.controller;
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import com.njuzr.eaibackend.service.DoubaoService;
+import com.njuzr.eaibackend.vo.AIReplyVo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/doubao")
+public class DoubaoController {
+    @Autowired
+    private DoubaoService doubaoService;
+
+    @GetMapping
+    public MyResponse getRecord(@RequestParam Long userId, @RequestParam Long assignmentId){
+         SpeakingAIDialogue record = doubaoService.getRecord(userId, assignmentId);
+        return MyResponse.success(record);
+    }
+
+    // 启动一个新聊天会话
+    @GetMapping("/startChat")
+    public MyResponse startChat(@RequestParam Long userId, @RequestParam Long assignmentId, @RequestParam String userQuestion, @RequestParam Integer voiceCode) {
+        List<SpeakingAIDialogue.DialogueEntry> dialogueEntries = doubaoService.startChat(userId, assignmentId, userQuestion, voiceCode);
+        log.info("Chat started for user: " + userId + " with assignmentId: " + assignmentId );
+        return MyResponse.success(dialogueEntries);
+    }
+
+    // 发送消息并获取回复
+    @GetMapping("/sendMessage")
+    public MyResponse sendMessage(@RequestParam Long userId, @RequestParam Long assignmentId, @RequestParam String userQuestion, @RequestParam String audioUrl, @RequestParam Double audioTime, @RequestParam Integer voiceCode) {
+        AIReplyVo answer= doubaoService.sendMessage(userId, assignmentId, userQuestion, audioUrl, audioTime, voiceCode);
+        return MyResponse.success(answer);
+    }
+
+    // 结束聊天会话,打印聊天记录
+    @GetMapping("/endChat")
+    public MyResponse endChat(@RequestParam Long userId, @RequestParam Long assignmentId) {
+        doubaoService.endChat(userId, assignmentId);
+        return MyResponse.success("提交成功");
+    }
+
+    @GetMapping("/getChatHistory")
+    public MyResponse getChatHistory(@RequestParam Long userId, @RequestParam Long assignmentId) {
+        List<SpeakingAIDialogue.DialogueEntry> chatHistory = doubaoService.getChatHistory(userId, assignmentId);
+        return MyResponse.success(chatHistory);
+    }
+
+    @DeleteMapping("/deleteChatHistory")
+    public MyResponse deleteChatHistory(@RequestParam Long userId, @RequestParam Long assignmentId, @RequestParam String userQuestion, @RequestParam Integer voiceCode) {
+        return MyResponse.success(doubaoService.deleteChatHistory(userId, assignmentId, userQuestion, voiceCode));
+    }
+
+    @GetMapping("/saveSigment")
+    public MyResponse saveSigment(@RequestParam Long userId, @RequestParam Long assignmentId, @RequestParam String sigmentUrl, @RequestParam Double duration) {
+        return MyResponse.success(doubaoService.saveSigment(userId, assignmentId, sigmentUrl, duration));
+    }
+
+    @GetMapping("/getSigment")
+    public MyResponse getSigment(@RequestParam Long userId, @RequestParam Long assignmentId) {
+        return MyResponse.success(doubaoService.getSigment(userId, assignmentId));
+    }
+
+    @GetMapping("/restrictData")
+    public MyResponse getRestrictData(@RequestParam Long userId, @RequestParam Long assignmentId) {
+        return MyResponse.success(doubaoService.getRestrictData(userId, assignmentId));
+    }
+
+}

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

@@ -30,6 +30,7 @@ public class FileController {
     public MyResponse upload(@RequestParam("file") MultipartFile file) throws IOException {
         String filePath = "uploads/" + file.getOriginalFilename();
         String url = ossUtil.uploadFile(file.getInputStream(), filePath);
+        log.info("url:" + url);
         return MyResponse.success(url);
     }
 

+ 46 - 0
src/main/java/com/njuzr/eaibackend/controller/MediaAnalysisController.java

@@ -0,0 +1,46 @@
+package com.njuzr.eaibackend.controller;
+
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import com.njuzr.eaibackend.service.MediaAnalysisService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.concurrent.ExecutionException;
+
+/**
+ * @author Liululin
+ * @date 2025/3/13 - 15:01
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/media")
+public class MediaAnalysisController {
+
+    @Autowired
+    MediaAnalysisService mediaAnalysisService;
+
+    @GetMapping("/audioScore")
+    public MyResponse getAudioScore(@RequestParam Long userId, @RequestParam Long assignmentId, @RequestParam Integer index) throws ExecutionException, InterruptedException {
+        SpeakingAIDialogue.DialogueEntry entry= mediaAnalysisService.getAudioScore(userId, assignmentId, index).get();
+        return MyResponse.success(entry);
+    }
+
+    @GetMapping("/audioSentiment")
+    public MyResponse getAudioSentiment(@RequestParam Long userId, @RequestParam Long assignmentId, @RequestParam Integer index){
+        return MyResponse.success(mediaAnalysisService.getAudioSentimentWithTimeout(userId, assignmentId, index));
+    }
+
+    @GetMapping("/speakingSuggestion")
+    public MyResponse getSpeakingSuggestion(@RequestParam Long userId, @RequestParam Long assignmentId){
+        return MyResponse.success(mediaAnalysisService.getSpeakingSuggestionWithTimeout(userId, assignmentId));
+    }
+
+    @GetMapping("/videoSentiment")
+    public MyResponse getVideoSentiment(@RequestParam Long userId, @RequestParam Long assignmentId){
+        return MyResponse.success(mediaAnalysisService.getVideoSentiment(userId, assignmentId));
+    }
+}

+ 24 - 0
src/main/java/com/njuzr/eaibackend/dto/AiSpeakingAssignmentDTO.java

@@ -0,0 +1,24 @@
+package com.njuzr.eaibackend.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import java.util.List;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 14:46
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class AiSpeakingAssignmentDTO extends AssignmentDTO{
+    private Integer minDuration;
+
+    private Integer minInteraction;
+
+    private List<String> surveyLinks;
+
+    private Integer surveyDuration;
+}

+ 5 - 2
src/main/java/com/njuzr/eaibackend/dto/AssignmentDTO.java

@@ -21,15 +21,18 @@ public class AssignmentDTO {
     @NotNull(message = "作业描述不能缺失")
     private String description;
 
+    @NotNull(message = "作业类型不能缺失")
+    private String type;
+
     private String descriptionFile; // 作业要求,Word/Pdf文件
 
     private List<String> attachments; // 作业附件
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     @NotNull(message = "起始时间不能缺失")
     private Date startTime;
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss",timezone = "Asia/Shanghai")
     @NotNull(message = "结束时间不能缺失")
     private Date endTime;
 }

+ 2 - 0
src/main/java/com/njuzr/eaibackend/dto/AssignmentQueryDTO.java

@@ -22,4 +22,6 @@ public class AssignmentQueryDTO {
     private String assignmentName;
 
     private AssignmentStatus status;
+
+    private String type;
 }

+ 4 - 2
src/main/java/com/njuzr/eaibackend/dto/AssignmentUpdateDTO.java

@@ -19,13 +19,15 @@ public class AssignmentUpdateDTO {
 
     private String description;
 
+    private String type;
+
     private String descriptionFile; // 作业要求,Word/Pdf文件
 
     private List<String> attachments; // 作业附件
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private Date startTime;
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private Date endTime;
 }

+ 2 - 2
src/main/java/com/njuzr/eaibackend/dto/course/CourseDTO.java

@@ -22,11 +22,11 @@ public class CourseDTO {
 
     private String targetGrade;
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     @NotNull(message = "起始时间不能缺失")
     private Date startTime;
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     @NotNull(message = "截止时间不能缺失")
     private Date endTime;
 

+ 2 - 2
src/main/java/com/njuzr/eaibackend/dto/course/CourseUpdateDTO.java

@@ -23,10 +23,10 @@ public class CourseUpdateDTO {
 
     private String targetGrade;
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private Date startTime;
 
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private Date endTime;
 
     private String description;

+ 31 - 0
src/main/java/com/njuzr/eaibackend/enums/VoiceType.java

@@ -0,0 +1,31 @@
+package com.njuzr.eaibackend.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+
+/**
+ * @author Liululin
+ * @date 2025/4/3 - 8:18
+ */
+public enum VoiceType {
+    MAN1(0, "en-GB-OllieMultilingualNeural"),
+    MAN2(1, "en-US-BrandonMultilingualNeural"),
+    WOMAN1(2,"en-GB-AdaMultilingualNeural"),
+    WOMAN2(3,"en-US-AmandaMultilingualNeural");
+
+    @EnumValue
+    private final int code;
+    private final String type;
+
+    VoiceType(int code, String type) {
+        this.code = code;
+        this.type = type;
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public String getType() {
+        return type;
+    }
+}

+ 13 - 0
src/main/java/com/njuzr/eaibackend/mapper/AiSpeakingAssignmentMapper.java

@@ -0,0 +1,13 @@
+package com.njuzr.eaibackend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.njuzr.eaibackend.po.AiSpeakingAssignment;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 14:44
+ */
+@Mapper
+public interface AiSpeakingAssignmentMapper extends BaseMapper<AiSpeakingAssignment> {
+}

+ 14 - 0
src/main/java/com/njuzr/eaibackend/mapper/SpeakingAIDialogueMapper.java

@@ -0,0 +1,14 @@
+package com.njuzr.eaibackend.mapper;
+
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+/**
+ * @author Liululin
+ * @date 2025/2/23 - 0:10
+ */
+public interface SpeakingAIDialogueMapper extends MongoRepository<SpeakingAIDialogue, String> {
+    Page<SpeakingAIDialogue> findByAssignmentIdAndUserId(Long assignmentId, Long userId, Pageable pageable);
+}

+ 29 - 0
src/main/java/com/njuzr/eaibackend/po/AiSpeakingAssignment.java

@@ -0,0 +1,29 @@
+package com.njuzr.eaibackend.po;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 13:34
+ */
+
+@Data
+@TableName("ai_speaking_assignment")
+public class AiSpeakingAssignment {
+    @TableId(type = IdType.AUTO)
+    private Long aiSpeakingAssignmentId;
+
+    private Long assignmentId;
+
+    private Integer minDuration;
+
+    private Integer minInteraction;
+
+    private String surveyLinks;
+
+    private Integer surveyDuration;
+}
+

+ 2 - 0
src/main/java/com/njuzr/eaibackend/po/Assignment.java

@@ -26,6 +26,8 @@ public class Assignment {
 
     private String description;
 
+    private String type; //记录作业类型
+
     private String descriptionFile; // 作业要求,Word/Pdf文件
 
     private String attachments; // 作业附件

+ 115 - 0
src/main/java/com/njuzr/eaibackend/po/SpeakingAIDialogue.java

@@ -0,0 +1,115 @@
+package com.njuzr.eaibackend.po;
+
+import com.njuzr.eaibackend.vo.VideoAnalysisVo;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.mapping.Document;
+import org.springframework.data.redis.core.index.Indexed;
+
+import java.util.List;
+
+/**
+ * @author Liululin
+ * @date 2025/2/23 - 0:07
+ */
+@Data
+@Document(collection = "speakingAiDialogues")
+public class SpeakingAIDialogue {
+    @Id
+    private String id;
+
+    @Indexed
+    private Long assignmentId; // 外键
+
+    @Indexed
+    private Long userId;
+
+    /**
+     * ai建议
+     */
+    private  String suggestion;
+    /**
+     * 视频地址
+     */
+    private String videoUrl;
+    /**
+     * 视频片段地址
+     */
+    private List<String> audioSigment;
+    /**
+     * 视频情感分析结果
+     */
+    private VideoAnalysisVo videoAnalysis;
+    /**
+     * 视频片段对应时长
+     */
+    private List<Double> audioDuration;
+    /**
+     * 是否提交
+     */
+    private Boolean hasCommitted;
+    /**
+     * 提交时间
+     */
+    private Long committedTime;
+    /**
+     * 对话条目
+     */
+    private List<DialogueEntry> dialogues;
+    /**
+     * 总体统计信息
+     */
+    private Double totalVideoTime;
+    private Double totalSpeakingTime;
+    private Integer totalUserWordCount;
+    private Integer totalAssistantWordCount;
+    private Integer chatTimes;
+    private Integer voiceCode;
+    /**
+     * 总体语音评分信息
+     */
+    private Double accuracyScoreAvg;
+    private Double fluencyScoreAvg;
+    private Double integrityScoreAvg;
+    private Double standardScoreAvg;
+    private Double totalScoreAvg;
+
+    @Data
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class DialogueEntry {
+        private String role;
+        private String content;
+        private Integer wordCount;
+        private String audioUrl;
+        private Double audioTime;
+        private Long timestamp;
+        private Double accuracyScore;
+        private Double fluencyScore;
+        private Double integrityScore;
+        private Double standardScore;
+        private Double totalScore;
+        private List<WordScore> wordScoreList;
+        private Integer entryVoiceCode;
+        private String audioSentiment;
+        public DialogueEntry(String role, String content, Long timestamp) {
+            this.role = role;
+            this.content = content;
+            this.timestamp = timestamp;
+        }
+
+    }
+
+    // 用于存储单词及其分数的类
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class WordScore {
+        private String word;
+        private double totalScore;
+    }
+
+
+}

+ 3 - 3
src/main/java/com/njuzr/eaibackend/service/AIRequestService.java

@@ -194,7 +194,7 @@ public class AIRequestService {
     }
 
     @Data
-    static class Choice {
+    public static class Choice {
         private String finish_reason;
         private int index;
         private String logprobs;
@@ -202,7 +202,7 @@ public class AIRequestService {
     }
 
     @Data
-    static class ResponseMessage {
+    public static class ResponseMessage {
         private String content; // AI生成的回复
         private String role;
         private String function_call;
@@ -210,7 +210,7 @@ public class AIRequestService {
     }
 
     @Data
-    static class Usage {
+    public static class Usage {
         private int completion_tokens;
         private int prompt_tokens;
         private int total_tokens;

+ 21 - 0
src/main/java/com/njuzr/eaibackend/service/AiSpeakingAssignmentService.java

@@ -0,0 +1,21 @@
+package com.njuzr.eaibackend.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.njuzr.eaibackend.dto.AiSpeakingAssignmentDTO;
+import com.njuzr.eaibackend.po.AiSpeakingAssignment;
+import com.njuzr.eaibackend.po.MyUserDetails;
+import com.njuzr.eaibackend.vo.AiSpeakingAssignmentVO;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 14:45
+ */
+public interface AiSpeakingAssignmentService extends IService<AiSpeakingAssignment> {
+    void createSpeakingAssignment(MyUserDetails user, Long courseId, AiSpeakingAssignmentDTO aiSpeakingAssignmentDTO);
+
+    AiSpeakingAssignmentVO findAssignmentById(Long assignmentId);
+
+    boolean updateAssignment(MyUserDetails user, Long assignmentId, AiSpeakingAssignmentDTO  aiSpeakingAssignmentDTO);
+
+    void deleteById(Long teacherId, Long assignmentId);
+}

+ 28 - 0
src/main/java/com/njuzr/eaibackend/service/DoubaoService.java

@@ -0,0 +1,28 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import com.njuzr.eaibackend.vo.AIReplyVo;
+import com.njuzr.eaibackend.vo.RestrictDataVo;
+
+import java.util.List;
+
+
+/**
+ * @author Liululin
+ * @date 2025/2/22 - 11:26
+ */
+public interface DoubaoService {
+    List<SpeakingAIDialogue.DialogueEntry> startChat(Long userId, Long assignmentId, String userQuestion, Integer voiceCode) ;
+    AIReplyVo sendMessage(Long userId, Long assignmentId, String userQuestion, String audioUrl, Double audioTime, Integer voiceCode);
+    void endChat(Long userId, Long assignmentId);
+    List<SpeakingAIDialogue.DialogueEntry> getChatHistory(Long userId, Long assignmentId);
+    List<SpeakingAIDialogue.DialogueEntry> deleteChatHistory(Long userId, Long assignmentId, String userQuestion, Integer voiceCode);
+
+    List<String> saveSigment(Long userId, Long assignmentId, String sigmentUrl, Double duration);
+
+    List<String> getSigment(Long userId, Long assignmentId);
+
+    SpeakingAIDialogue getRecord(Long userId, Long assignmentId);
+
+    RestrictDataVo getRestrictData(Long userId, Long assignmentId);
+}

+ 20 - 0
src/main/java/com/njuzr/eaibackend/service/MediaAnalysisService.java

@@ -0,0 +1,20 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import com.njuzr.eaibackend.vo.VideoAnalysisVo;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * @author Liululin
+ * @date 2025/3/13 - 15:03
+ */
+public interface MediaAnalysisService {
+    CompletableFuture<SpeakingAIDialogue.DialogueEntry> getAudioScore(Long userId, Long assignmentId, Integer index);
+
+    String getAudioSentimentWithTimeout(Long userId, Long assignmentId, Integer index);
+
+    String getSpeakingSuggestionWithTimeout(Long userId, Long assignmentId);
+
+    VideoAnalysisVo getVideoSentiment(Long userId, Long assignmentId);
+}

+ 172 - 0
src/main/java/com/njuzr/eaibackend/service/impl/AiSpeakingAssignmentServiceImpl.java

@@ -0,0 +1,172 @@
+package com.njuzr.eaibackend.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.njuzr.eaibackend.dto.AiSpeakingAssignmentDTO;
+import com.njuzr.eaibackend.enums.AssignmentStatus;
+import com.njuzr.eaibackend.enums.Role;
+import com.njuzr.eaibackend.exception.MyException;
+import com.njuzr.eaibackend.mapper.*;
+import com.njuzr.eaibackend.po.*;
+import com.njuzr.eaibackend.service.AiSpeakingAssignmentService;
+import com.njuzr.eaibackend.utils.ConvertUtil;
+import com.njuzr.eaibackend.vo.AiSpeakingAssignmentVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 14:53
+ */
+@Service
+@Slf4j
+public class AiSpeakingAssignmentServiceImpl extends ServiceImpl<AiSpeakingAssignmentMapper, AiSpeakingAssignment>  implements AiSpeakingAssignmentService {
+    @Autowired
+    CourseMapper courseMapper;
+    @Autowired
+    AssignmentMapper assignmentMapper;
+
+    @Autowired
+    UserMapper userMapper;
+
+    @Autowired
+    StudentAssignmentMapper studentAssignmentMapper;
+    @Override
+    public void createSpeakingAssignment(MyUserDetails user, Long courseId, AiSpeakingAssignmentDTO aiSpeakingAssignmentDTO) {
+        Course course = courseMapper.selectById(courseId);
+        if (course == null)
+            throw MyException.create(HttpStatus.BAD_REQUEST, "课程不存在");
+        if (user.getRole() != Role.ADMIN) {
+            List<String> teacherIds = Collections.singletonList(Optional.ofNullable(course.getTeacherIds())
+                    .orElse(Collections.emptyList().toString()));
+            String userIdStr = String.valueOf(user.getId());
+            boolean hasTeacherId = teacherIds.contains(userIdStr);
+            boolean pidMatches = Objects.equals(course.getTeacherPid(), user.getPid());
+            if (!hasTeacherId && !pidMatches) {
+                throw MyException.create(HttpStatus.BAD_REQUEST, "无权限创建");
+            }
+        }
+
+        Assignment assignment = new Assignment();
+        BeanUtils.copyProperties(aiSpeakingAssignmentDTO, assignment);
+        assignment.setCourseId(courseId);
+        assignment.setTeacherId(user.getId());
+        assignment.setCreateTime(new Date());
+        try {
+            assignmentMapper.insert(assignment);
+            Long assignmentId = assignment.getAssignmentId();
+            log.info("assignmentId:" + assignmentId);
+            AiSpeakingAssignment aiSpeakingAssignment = new AiSpeakingAssignment();
+            aiSpeakingAssignment.setAssignmentId(assignmentId);
+            BeanUtils.copyProperties(aiSpeakingAssignmentDTO, aiSpeakingAssignment);
+            if(aiSpeakingAssignmentDTO.getSurveyLinks() != null){
+                aiSpeakingAssignment.setSurveyLinks(ConvertUtil.listToString(aiSpeakingAssignmentDTO.getSurveyLinks()));
+            }
+            baseMapper.insert(aiSpeakingAssignment);
+        } catch (Exception e) {
+            log.error(e.getMessage());
+            throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "创建作业失败");
+        }
+    }
+
+    @Override
+    public AiSpeakingAssignmentVO findAssignmentById(Long assignmentId) {
+        QueryWrapper<Assignment> wrapper = new QueryWrapper<>();
+        wrapper.eq("assignment_id", assignmentId);
+        Assignment assignment = assignmentMapper.selectOne(wrapper);
+        if (assignment == null)
+            throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
+        QueryWrapper<AiSpeakingAssignment> speakingAssignmentQueryWrapper = new QueryWrapper<>();
+        speakingAssignmentQueryWrapper.eq("assignment_id", assignmentId);
+        AiSpeakingAssignment aiSpeakingAssignment = baseMapper.selectOne(speakingAssignmentQueryWrapper);
+        return convertToVO(assignment, aiSpeakingAssignment);
+    }
+
+    private AiSpeakingAssignmentVO convertToVO(Assignment assignment, AiSpeakingAssignment aiSpeakingAssignment) {
+        AiSpeakingAssignmentVO aiSpeakingAssignmentVO = new AiSpeakingAssignmentVO();
+        BeanUtils.copyProperties(assignment, aiSpeakingAssignmentVO);
+        BeanUtils.copyProperties(aiSpeakingAssignment, aiSpeakingAssignmentVO);
+        if(aiSpeakingAssignment.getSurveyLinks() != null){
+            aiSpeakingAssignmentVO.setSurveyLinks(ConvertUtil.stringToList(aiSpeakingAssignment.getSurveyLinks(), String::valueOf));
+        }
+        aiSpeakingAssignmentVO.setStatus(calculateStatus(aiSpeakingAssignmentVO.getStartTime(), aiSpeakingAssignmentVO.getEndTime()));
+        // 组装老师姓名
+        aiSpeakingAssignmentVO.setTeacherName(userMapper.selectNameById(assignment.getTeacherId()));
+        return aiSpeakingAssignmentVO;
+    }
+
+    @Override
+    @Transactional
+    public boolean updateAssignment(MyUserDetails user, Long assignmentId, AiSpeakingAssignmentDTO aiSpeakingAssignmentDTO) {
+        Assignment assignment = assignmentMapper.selectById(assignmentId);
+        if (!Objects.equals(assignment.getTeacherId(), user.getId()))
+            throw MyException.create(HttpStatus.BAD_REQUEST, "无权限更新");
+        Assignment newAssignment = new Assignment();
+        BeanUtils.copyProperties(aiSpeakingAssignmentDTO, newAssignment);
+        newAssignment.setAssignmentId(assignmentId);
+        AiSpeakingAssignment aiSpeakingAssignment = new AiSpeakingAssignment();
+        BeanUtils.copyProperties(aiSpeakingAssignmentDTO, aiSpeakingAssignment);
+        aiSpeakingAssignment.setAssignmentId(assignmentId);
+        if(aiSpeakingAssignmentDTO.getSurveyLinks() != null){
+            aiSpeakingAssignment.setSurveyLinks(ConvertUtil.listToString(aiSpeakingAssignmentDTO.getSurveyLinks()));
+        }
+        try {
+            int code1 =  assignmentMapper.updateById(newAssignment);
+            UpdateWrapper<AiSpeakingAssignment> updateWrapper = new UpdateWrapper<>();
+            updateWrapper.eq("assignment_id",assignmentId);
+            int code2 = baseMapper.update(aiSpeakingAssignment, updateWrapper);
+            if ((code1 | code2) == 0)
+                throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "服务器更新失败");
+        } catch (Exception e) {
+            log.error(e.getMessage());
+            throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "服务器更新失败");
+        }
+        return true;
+    }
+
+    @Override
+    @Transactional
+    @CacheEvict(value = "assignmentInfo", key = "'assignment_' + #assignmentId")
+    public void deleteById(Long teacherId, Long assignmentId) {
+        Assignment target = assignmentMapper.selectById(assignmentId);
+        if (!Objects.equals(target.getTeacherId(), teacherId))
+            throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "无权限删除");
+
+        try {
+            List<Engagement> engagements = studentAssignmentMapper.findEngagementByAssignmentId(assignmentId);
+            for(Engagement engagement : engagements) {
+                studentAssignmentMapper.delete(engagement.getStudentId(), assignmentId);
+            }
+            int code = assignmentMapper.deleteById(assignmentId);
+            QueryWrapper<AiSpeakingAssignment> speakingAssignmentQueryWrapper = new QueryWrapper<>();
+            speakingAssignmentQueryWrapper.eq("assignment_id", assignmentId);
+            int code1 = baseMapper.delete(speakingAssignmentQueryWrapper);
+            if (code==0 || code1 == 0) {
+                throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "删除作业错误");
+            }
+        } catch (Exception e) {
+            log.error(e.getMessage());
+            throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "删除作业错误");
+        }
+
+    }
+
+    private AssignmentStatus calculateStatus(Date startTime, Date endTime) {
+        Date now = new Date();
+        if (now.before(startTime)) {
+            return AssignmentStatus.NOT_STARTED;
+        } else if (now.after(endTime)) {
+            return AssignmentStatus.FINISHED;
+        } else {
+            return AssignmentStatus.PROCEEDING;
+        }
+    }
+}

+ 9 - 3
src/main/java/com/njuzr/eaibackend/service/impl/AssignmentServiceImpl.java

@@ -87,9 +87,9 @@ public class AssignmentServiceImpl implements AssignmentService {
 
         Assignment target = convertToPO(assignmentDTO);
         target.setCourseId(courseId);
-//        target.setTeacherId(user.getId());
+        target.setTeacherId(user.getId());
         // TODO:权宜之计
-        target.setTeacherId(Long.valueOf(user.getPid()));
+      //  target.setTeacherId(Long.valueOf(user.getPid()));
         target.setCreateTime(new Date());
 
         try {
@@ -134,6 +134,10 @@ public class AssignmentServiceImpl implements AssignmentService {
         if (assignmentQueryDTO.getAssignmentName() != null) { // 模糊匹配作业名字
             wrapper.like("assignment_name", assignmentQueryDTO.getAssignmentName());
         }
+
+        if(assignmentQueryDTO.getType() != null){
+            wrapper.eq("type",assignmentQueryDTO.getType());
+        }
         if (assignmentQueryDTO.getStatus() != null) {
             AssignmentStatus status = assignmentQueryDTO.getStatus();
             if (status == AssignmentStatus.NOT_STARTED) {
@@ -145,7 +149,6 @@ public class AssignmentServiceImpl implements AssignmentService {
                 wrapper.lt("end_time", LocalDateTime.now());
             }
         }
-
         IPage<Assignment> assignments = assignmentMapper.selectPage(page, wrapper);
 
         return PageMapperUtil.convert(assignments, this::convertToVO);
@@ -205,6 +208,9 @@ public class AssignmentServiceImpl implements AssignmentService {
 
     @Override
     public void engageAssignment(Long studentId, Long assignmentId) {
+        if(studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId) != null){
+            return;
+        }
         // 1创建Engagement实体类,初始化基本信息
         Engagement engagement = new Engagement();
         engagement.setStudentId(studentId);

+ 402 - 0
src/main/java/com/njuzr/eaibackend/service/impl/DoubaoServiceImpl.java

@@ -0,0 +1,402 @@
+package com.njuzr.eaibackend.service.impl;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.njuzr.eaibackend.config.DoubaoConfig;
+import com.njuzr.eaibackend.config.IseClientFactory;
+import com.njuzr.eaibackend.config.OssConfig;
+import com.njuzr.eaibackend.exception.MyException;
+import com.njuzr.eaibackend.mapper.SpeakingAIDialogueMapper;
+import com.njuzr.eaibackend.po.AIEntry;
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import com.njuzr.eaibackend.service.AIRequestService;
+import com.njuzr.eaibackend.service.DoubaoService;
+import com.njuzr.eaibackend.vo.AIReplyVo;
+import com.njuzr.eaibackend.vo.RestrictDataVo;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.mongodb.core.MongoTemplate;
+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 org.springframework.http.HttpStatus;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+
+/**
+ * @author Liululin
+ * @date 2025/3/9 - 14:45
+ */
+@Slf4j
+@Service
+public class DoubaoServiceImpl implements DoubaoService {
+
+    @Autowired
+    MongoTemplate mongoTemplate;
+
+    @Autowired
+    SpeakingAIDialogueMapper speakingAIDialogueMapper;
+
+    @Autowired
+    AssignmentServiceImpl assignmentService;
+
+    @Autowired
+    AIRequestService aiRequestService;
+
+    @Autowired
+    @Qualifier("doubaoTaskExecutor") // 指定 Bean 名称
+    private ThreadPoolTaskExecutor doubaoTaskExecutor; // 注入线程池
+
+    final static Base64.Decoder decoder = Base64.getDecoder();
+
+
+    @Override
+    public List<SpeakingAIDialogue.DialogueEntry> startChat(Long userId, Long assignmentId, String userQuestion, Integer voiceCode) {
+        // 先尝试查找是否已经存在对应的对话
+        SpeakingAIDialogue existingDialogue = mongoTemplate.findOne(
+                Query.query(Criteria.where("assignmentId").is(assignmentId).and("userId").is(userId)),
+                SpeakingAIDialogue.class
+        );
+        if(existingDialogue != null){
+            return existingDialogue.getDialogues();
+        }
+        // 创建新的 engagement
+        assignmentService.engageAssignment(userId, assignmentId);
+        List<SpeakingAIDialogue.DialogueEntry> dialogues = new ArrayList<>();
+        // 构造系统消息
+        addMessageToDialogue(dialogues, "user", getSystemMessageContent() + "开始对话,本轮主题词为" + userQuestion, null , voiceCode, -1);
+        CompletableFuture<String> arkFuture = getArkServiceResponse(dialogues); // ArkService 任务
+        // 等待任务完成并处理结果
+        return arkFuture.thenCompose(response -> {
+            try {
+                CompletableFuture<String> messageAudioFuture = CompletableFuture.completedFuture(null);
+                if (voiceCode != null) {
+                    // 异步生成音频
+                    messageAudioFuture = getMessageAudio(response, voiceCode, userId, assignmentId)
+                            .exceptionally(ex -> {
+                                System.err.println("音频生成失败: " + ex.getMessage());
+                                return null; // 返回 null 表示音频生成失败
+                            });
+                }
+                // 等待音频生成完成,然后处理结果
+                return messageAudioFuture.thenApply(messageAudio -> {
+                    // 添加助手回复到对话记录
+                    int responseCount = response.replaceAll("[^a-zA-Z\\s]", "").split("\\s+").length;
+                    addMessageToDialogue(dialogues, "assistant", response, messageAudio, voiceCode, responseCount);
+                    // 创建新的对话记录并保存
+                    SpeakingAIDialogue newDialogue = new SpeakingAIDialogue();
+                    newDialogue.setUserId(userId);
+                    newDialogue.setAssignmentId(assignmentId);
+                    newDialogue.setDialogues(dialogues);
+                    newDialogue.setChatTimes(0);
+                    newDialogue.setHasCommitted(false);
+                    newDialogue.setVoiceCode(voiceCode);
+                    speakingAIDialogueMapper.save(newDialogue);
+                    return dialogues;
+                });
+
+            } catch (Exception e) {
+                throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "处理结果失败:" + e.getMessage());
+            }
+        }).join(); // 阻塞等待结果
+    }
+
+    @Override
+    public AIReplyVo sendMessage(Long userId, Long assignmentId, String userQuestion, String audioUrl, Double audioTime, Integer voiceCode) {
+        SpeakingAIDialogue targetDialogue = mongoTemplate.findOne(
+                Query.query(Criteria.where("assignmentId").is(assignmentId).and("userId").is(userId)), SpeakingAIDialogue.class
+        );
+        if (targetDialogue == null) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "找不到AI会话");
+        }
+        List<SpeakingAIDialogue.DialogueEntry> dialogues = targetDialogue.getDialogues();
+        // 构造用户发送的消息
+        SpeakingAIDialogue.DialogueEntry userEntry = new SpeakingAIDialogue.DialogueEntry(
+                "user", userQuestion, System.currentTimeMillis()
+        );
+        // 1. 去除标点符号
+        String cleanedSentence = userQuestion.replaceAll("[^a-zA-Z\\s]", "");
+        String[] words = cleanedSentence.split("\\s+");
+        userEntry.setWordCount(words.length);
+        userEntry.setAudioUrl(audioUrl);
+        userEntry.setAudioTime(audioTime);
+        userEntry.setEntryVoiceCode(voiceCode);
+        dialogues.add(userEntry);
+        // 执行 ArkService 任务并处理结果
+        return getArkServiceResponse(dialogues).thenCompose(response -> {
+            try {
+                CompletableFuture<String> messageAudioFuture = CompletableFuture.completedFuture(null);
+                if (voiceCode != null) {
+                    // 异步生成音频
+                    messageAudioFuture = getMessageAudio(response, voiceCode, userId, assignmentId)
+                            .exceptionally(ex -> {
+                                System.err.println("音频生成失败: " + ex.getMessage());
+                                return null; // 返回 null 表示音频生成失败
+                            });
+                }
+                // 等待音频生成完成,然后处理结果
+                return messageAudioFuture.thenApply(messageAudio -> {
+                    // 添加助手回复到对话记录
+                    int responseCount = response.replaceAll("[^a-zA-Z\\s]", "").split("\\s+").length;
+                    addMessageToDialogue(dialogues, "assistant", response, messageAudio, voiceCode, responseCount);
+                    // 更新对话记录
+                    targetDialogue.setDialogues(dialogues);
+                    targetDialogue.setChatTimes(targetDialogue.getChatTimes() + 1);
+                    targetDialogue.setVoiceCode(voiceCode);
+                    speakingAIDialogueMapper.save(targetDialogue);
+                    return new AIReplyVo(response, messageAudio);
+                });
+            } catch (Exception e) {
+                throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "处理结果失败:" + e.getMessage());
+            }
+        }).join(); // 阻塞等待结果
+    }
+
+
+    private static final String TSS_URL = "http://127.0.0.1:2002/tts";
+    @Async("doubaoTaskExecutor")
+    private CompletableFuture<String> getMessageAudio(String message, int code, Long userId, Long assignmentId) {
+        return CompletableFuture.supplyAsync(() -> {
+            OkHttpClient httpClient = new OkHttpClient.Builder()
+                    .connectTimeout(20, TimeUnit.SECONDS) // 连接超时
+                    .readTimeout(60, TimeUnit.SECONDS)    // 读取超时
+                    .writeTimeout(30, TimeUnit.SECONDS)   // 写入超时
+                    .build();
+            JSONObject json = new JSONObject();
+            json.put("code", code);
+            json.put("text",message);
+            RequestBody body = RequestBody.create(MediaType.get("application/json; charset=utf-8"), json.toString());
+            Request request = new Request.Builder()
+                    .url(TSS_URL)
+                    .post(body)
+                    .build();
+            try (Response response = httpClient.newCall(request).execute()) {
+                if (!response.isSuccessful()) {
+                    throw new IOException("Unexpected code " + response);
+                }
+                String res = response.body().string();
+                return res;
+            } catch (IOException e) {
+                throw new MyException(HttpStatus.BAD_REQUEST.value(), "ai语音生成失败: " + e.getMessage());
+            }
+
+        });
+    }
+
+
+
+    @Override
+    public void endChat(Long userId, Long assignmentId) {
+        SpeakingAIDialogue targetDialogue = mongoTemplate.findOne(
+                Query.query(Criteria.where("assignmentId").is(assignmentId).and("userId").is(userId)), SpeakingAIDialogue.class
+        );
+        if (targetDialogue == null) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "找不到AI会话");
+        }
+        targetDialogue.setHasCommitted(true);
+        targetDialogue.setCommittedTime(System.currentTimeMillis());
+        double  totalVideoTime = 0.0;
+        for (Double aDouble : targetDialogue.getAudioDuration()) {
+            totalVideoTime += aDouble;
+        }
+        double totalSpeakingTime = 0.0;
+        int totalUserWordCount = 0;
+        int totalAssistantWordCount = 0;
+        double accuracyScoreAvg = 0.0;
+        double fluencyScoreAvg = 0.0;
+        double integrityScoreAvg = 0.0;
+        double standardScoreAvg = 0.0;
+        double totalScoreAvg = 0.0;
+        for (SpeakingAIDialogue.DialogueEntry dialogue : targetDialogue.getDialogues()) {
+            if(dialogue.getAudioTime() != null){
+                totalSpeakingTime += dialogue.getAudioTime();
+            }
+            if(dialogue.getWordCount() != null){
+                if(dialogue.getRole().equals("user")){
+                    totalUserWordCount += dialogue.getWordCount();
+                } else if(dialogue.getRole().equals("assistant")){
+                    totalAssistantWordCount += dialogue.getWordCount();
+                }
+            }
+            if(dialogue.getAccuracyScore() != null){
+                accuracyScoreAvg += dialogue.getAccuracyScore();
+            }
+            if(dialogue.getFluencyScore() != null){
+                fluencyScoreAvg += dialogue.getFluencyScore();
+            }
+            if(dialogue.getIntegrityScore() != null){
+                integrityScoreAvg += dialogue.getIntegrityScore();
+            }
+            if(dialogue.getStandardScore() != null){
+                standardScoreAvg += dialogue.getStandardScore();
+            }
+            if(dialogue.getTotalScore() != null){
+                totalScoreAvg +=dialogue.getTotalScore();
+            }
+        }
+        targetDialogue.setTotalVideoTime(totalVideoTime);
+        targetDialogue.setTotalSpeakingTime(totalSpeakingTime);
+        targetDialogue.setTotalUserWordCount(totalUserWordCount);
+        targetDialogue.setTotalAssistantWordCount(totalAssistantWordCount);
+        int count = targetDialogue.getChatTimes();
+        targetDialogue.setAccuracyScoreAvg(Double.parseDouble(String.format("%.1f", accuracyScoreAvg / count)));
+        targetDialogue.setFluencyScoreAvg(Double.parseDouble(String.format("%.1f", fluencyScoreAvg / count)));
+        targetDialogue.setIntegrityScoreAvg(Double.parseDouble(String.format("%.1f", integrityScoreAvg / count)));
+        targetDialogue.setStandardScoreAvg(Double.parseDouble(String.format("%.1f",standardScoreAvg / count)));
+        targetDialogue.setTotalScoreAvg(Double.parseDouble(String.format("%.1f", totalScoreAvg / count)));
+        List<SpeakingAIDialogue.DialogueEntry> dialogues = targetDialogue.getDialogues();
+        // 构造用户结束对话消息
+        addMessageToDialogue(dialogues, "user", "结束对话", null, null, -1);
+        AIRequestService.AIResponse aiResponse = aiRequestService.requestChatGLM4(dialoguesToAIEntry(dialogues));
+        String response = aiResponse.getChoices().get(0).getMessage().getContent();
+        addMessageToDialogue(dialogues, "assistant", response, null, null, -1);
+        targetDialogue.setDialogues(dialogues);
+        speakingAIDialogueMapper.save(targetDialogue);
+        assignmentService.engagementSubmit(userId, assignmentId);
+    }
+
+
+
+    @Async("doubaoTaskExecutor")
+    private CompletableFuture<String> getArkServiceResponse(List<SpeakingAIDialogue.DialogueEntry> dialogues) {
+        return CompletableFuture.supplyAsync(() -> {
+            try {
+                AIRequestService.AIResponse response = aiRequestService.requestChatGLM4(dialoguesToAIEntry(dialogues));
+                String content = response.getChoices().get(0).getMessage().getContent();
+                return content;
+            } catch (Exception e) {
+                throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "ArkService调用失败:" + e.getMessage());
+            }
+        });
+    }
+
+
+    private void addMessageToDialogue(List<SpeakingAIDialogue.DialogueEntry> dialogues, String role, String content, String audioUrl, Integer voiceCode, int responseCount) {
+        SpeakingAIDialogue.DialogueEntry dialogueEntry = new SpeakingAIDialogue.DialogueEntry(role, content, System.currentTimeMillis());
+        if(audioUrl != null){
+            dialogueEntry.setAudioUrl(audioUrl);
+        }
+        if(voiceCode != null){
+            dialogueEntry.setEntryVoiceCode(voiceCode);
+        }
+        if(responseCount != -1){
+            dialogueEntry.setWordCount(responseCount);
+        }
+        dialogues.add(dialogueEntry);
+    }
+
+    private String getSystemMessageContent() {
+        return "请你扮演一位经验丰富的英文外教,当用户发出 “开始对话” " +
+                "的指令时,在第一次时需使用固定开场话术:“Hello! Glad to have a conversation with you about [用户指定主题词]. " +
+                "Let us get started!” 对话过程中,全程使用自然、地道的英语表达,注重语音语调的模仿,通过提问、分享观点等方式积极引导对话深入进行,\n" +
+                "但绝对不能在对话中给出任何反馈、错误指正、建议或分析影响用户体验和对你的评价,只进行对话内容。当用户发出 “结束对话” 的指令时," +
+                "必须回复:“Okay, our conversation about [用户指定主题词] ends here. I hope it was helpful to you.”\n" ;
+    }
+
+    @Override
+    public List<SpeakingAIDialogue.DialogueEntry> getChatHistory(Long userId, Long assignmentId) {
+        // 构建查询条件,根据 userId 和 assignmentId 查找对话记录
+        Query query = Query.query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        // 执行查询操作,获取符合条件的 AIDialogue 对象
+        SpeakingAIDialogue aiDialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+        if (aiDialogue != null) {
+            // 如果找到了对话记录,将其中的 DialogueEntry 转换为 ChatMessage
+            return aiDialogue.getDialogues();
+        }
+        // 如果未找到对话记录,返回一个空列表
+        return new ArrayList<>();
+    }
+
+    @Override
+    public List<SpeakingAIDialogue.DialogueEntry> deleteChatHistory(Long userId, Long assignmentId, String userQuestion, Integer voiceCode) {
+        // 构建查询条件,根据 userId 和 assignmentId 查找对话记录
+        Query query = Query.query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        // 执行删除操作,获取删除的结果
+        SpeakingAIDialogue result = mongoTemplate.findAndRemove(query, SpeakingAIDialogue.class);
+        // 如果删除操作返回的结果不为空,说明成功删除,返回 true;否则返回 false
+        return startChat(userId, assignmentId, userQuestion, voiceCode);
+    }
+
+    public List<String> saveSigment(Long userId, Long assignmentId, String sigmentUrl, Double duration) {
+        Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        // 查询是否存在对应的文档
+        SpeakingAIDialogue dialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+        if (dialogue != null) {
+            // 如果文档存在,更新 audioSigment 列表
+            Update update = new Update().push("audioSigment", sigmentUrl).push("audioDuration",duration);
+            mongoTemplate.updateFirst(query, update, SpeakingAIDialogue.class);
+            return dialogue.getAudioSigment();
+        } else {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "不存在相应记录");
+        }
+    }
+
+    @Override
+    public List<String> getSigment(Long userId, Long assignmentId) {
+        Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        // 查询是否存在对应的文档
+        SpeakingAIDialogue dialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+        if (dialogue != null) {
+            return dialogue.getAudioSigment();
+        } else {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "不存在相应记录");
+        }
+    }
+
+
+    @Override
+    public SpeakingAIDialogue getRecord(Long userId, Long assignmentId) {
+        Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        // 查询是否存在对应的文档
+        SpeakingAIDialogue dialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+        if (dialogue != null) {
+            return dialogue;
+        } else {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "不存在相应记录");
+        }
+    }
+
+    @Override
+    public RestrictDataVo getRestrictData(Long userId, Long assignmentId) {
+        Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        // 查询是否存在对应的文档
+        SpeakingAIDialogue dialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+        if (dialogue == null) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "不存在相应记录");
+        } else {
+            RestrictDataVo restrictDataVo = new RestrictDataVo();
+            if(dialogue.getChatTimes() != null){
+                restrictDataVo.setChatTimes(dialogue.getChatTimes());
+            } else{
+                restrictDataVo.setChatTimes(0);
+            }
+            double sum = 0.0;
+            if(dialogue.getAudioDuration() != null){
+                for (Double aDouble : dialogue.getAudioDuration()) {
+                    sum += aDouble;
+                }
+            }
+            restrictDataVo.setRecordTime(sum);
+            return restrictDataVo;
+        }
+    }
+
+
+    private List<AIEntry> dialoguesToAIEntry(List<SpeakingAIDialogue.DialogueEntry> dialogues) {
+        List<AIEntry> entryList = new ArrayList<>();
+        for (SpeakingAIDialogue.DialogueEntry entry : dialogues) {
+            entryList.add(new AIEntry(entry.getRole(), entry.getContent()));
+        }
+        return entryList;
+    }
+}

+ 502 - 0
src/main/java/com/njuzr/eaibackend/service/impl/MediaAnalysisServiceImpl.java

@@ -0,0 +1,502 @@
+package com.njuzr.eaibackend.service.impl;
+
+import cn.xfyun.api.IseClient;
+import cn.xfyun.model.response.ise.IseResponseData;
+import cn.xfyun.service.ise.AbstractIseWebSocketListener;
+import com.alibaba.fastjson2.JSONObject;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.njuzr.eaibackend.config.DoubaoConfig;
+import com.njuzr.eaibackend.config.IseClientFactory;
+import com.njuzr.eaibackend.exception.MyException;
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import com.njuzr.eaibackend.service.MediaAnalysisService;
+import com.njuzr.eaibackend.utils.XmlParasUtil;
+import com.njuzr.eaibackend.vo.VideoAnalysisVo;
+import com.volcengine.ark.runtime.model.completion.chat.ChatCompletionRequest;
+import com.volcengine.ark.runtime.model.completion.chat.ChatMessage;
+import com.volcengine.ark.runtime.model.completion.chat.ChatMessageRole;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.mongodb.core.MongoTemplate;
+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 org.springframework.http.HttpStatus;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLConnection;
+import java.security.SignatureException;
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * @author Liululin
+ * @date 2025/3/13 - 15:04
+ */
+@Slf4j
+@Service
+public class MediaAnalysisServiceImpl implements MediaAnalysisService {
+    private static final String VIDEO_URL = "http://127.0.0.1:2002/deepface";
+    private static final String AUDIO_URL = "http://127.0.0.1:2003/asr";
+    private static final HashSet<String> emotionSet = new HashSet<>();
+    private static final Base64.Decoder decoder = Base64.getDecoder();
+    private static final long DEFAULT_TIMEOUT_SECONDS = 30;
+    private static final long VIDEO_ANALYSIS_TIMEOUT_SECONDS = 600;
+    private static final String AUDIO_SCORE_HEADER = "[audio-score]";
+    private static final String AUDIO_SENTIMENT_HEADER = "[audio-sentiment]";
+    private static final String VIDEO_SENTIMENT_HEADER = "[video-sentiment]";
+    private static final String SPEAKING_SUGGESTION_HEADER = "[speaking-suggestion]";
+
+    static {
+        emotionSet.add("😊");
+        emotionSet.add("😔");
+        emotionSet.add("😡");
+        emotionSet.add("😰");
+        emotionSet.add("🤢");
+        emotionSet.add("😮");
+    }
+    @Autowired
+    DoubaoConfig doubaoConfig;
+
+    @Autowired
+    MongoTemplate mongoTemplate;
+
+    @Autowired
+    IseClientFactory iseClientFactory;
+
+
+    @Autowired
+    @Qualifier("doubaoTaskExecutor") // 指定 Bean 名称
+    private ThreadPoolTaskExecutor doubaoTaskExecutor; // 注入线程池
+
+    private final Map<String, CompletableFuture<String>> pendingSuggestionRequests = new ConcurrentHashMap<>();
+    private final Map<String, CompletableFuture<VideoAnalysisVo>> pendingVideoAnalysisRequests = new ConcurrentHashMap<>();
+
+    private final OkHttpClient httpClient = new OkHttpClient.Builder()
+            .connectTimeout(20, TimeUnit.SECONDS)
+            .readTimeout(600, TimeUnit.SECONDS)
+            .writeTimeout(300, TimeUnit.SECONDS)
+            .build();
+
+    @Override
+    public CompletableFuture<SpeakingAIDialogue.DialogueEntry> getAudioScore(Long userId, Long assignmentId, Integer index) {
+        log.info("{} 开始获取音频评分, userId: {}, assignmentId: {}, index: {}",
+                AUDIO_SCORE_HEADER, userId, assignmentId, index);
+
+        Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        SpeakingAIDialogue dialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+
+        if (dialogue == null) {
+            log.error("{} 未找到对应的对话记录, userId: {}, assignmentId: {}",
+                    AUDIO_SCORE_HEADER, userId, assignmentId);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "不存在相应记录");
+        }
+
+        if (index == -1) {
+            index = Math.max(dialogue.getDialogues().size() - 2, 0);
+        }
+
+        SpeakingAIDialogue.DialogueEntry entry = dialogue.getDialogues().get(index);
+
+        Integer finalIndex = index;
+        return CompletableFuture.supplyAsync(() -> {
+            try {
+                log.debug("{} 开始处理音频评分, 音频URL: {}", AUDIO_SCORE_HEADER, entry.getAudioUrl());
+                URL url = new URL(entry.getAudioUrl());
+                URLConnection connection = url.openConnection();
+                InputStream inputStream = connection.getInputStream();
+
+                IseClient client = iseClientFactory.getIseClient(entry.getContent());
+                CompletableFuture<SpeakingAIDialogue.DialogueEntry> future = new CompletableFuture<>();
+
+                client.send(inputStream, new AbstractIseWebSocketListener() {
+                    @Override
+                    public void onSuccess(WebSocket webSocket, IseResponseData iseResponseData) {
+                        try {
+                            String decodedData = new String(decoder.decode(iseResponseData.getData().getData()));
+                            XmlParasUtil.paras(decodedData, entry);
+                            updateDialogueEntry(userId, assignmentId, finalIndex, entry);
+                            log.info("{} 音频评分处理成功, userId: {}, assignmentId: {}, index: {}",
+                                    AUDIO_SCORE_HEADER, userId, assignmentId, finalIndex);
+                            future.complete(entry);
+                        } catch (Exception e) {
+                            log.error("{} 音频评分数据处理异常", AUDIO_SCORE_HEADER, e);
+                            future.completeExceptionally(e);
+                        } finally {
+                            client.closeWebsocket();
+                        }
+                    }
+
+                    @Override
+                    public void onFail(WebSocket webSocket, Throwable throwable, Response response) {
+                        try {
+                            log.error("{} ISE服务调用失败, 响应: {}, 错误: {}",
+                                    AUDIO_SCORE_HEADER, response, throwable.getMessage());
+                            future.completeExceptionally(throwable);
+                        } finally {
+                            client.closeWebsocket();
+                        }
+                    }
+                });
+
+                return future.get();
+            } catch (IOException | SignatureException e) {
+                log.error("{} 音频评分处理IO异常", AUDIO_SCORE_HEADER, e);
+                throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "ISEService调用失败:" + e.getMessage());
+            } catch (InterruptedException | ExecutionException e) {
+                log.error("{} 音频评分处理中断异常", AUDIO_SCORE_HEADER, e);
+                Thread.currentThread().interrupt();
+                throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "音频评分处理中断");
+            }
+        }, doubaoTaskExecutor);
+    }
+
+    @Async("doubaoTaskExecutor")
+    public CompletableFuture<String> getAudioSentimentAsync(Long userId, Long assignmentId, Integer index) {
+        return CompletableFuture.supplyAsync(() -> getAudioSentiment(userId, assignmentId, index), doubaoTaskExecutor);
+    }
+
+    public String getAudioSentiment(Long userId, Long assignmentId, Integer index) {
+        log.info("{} 开始获取音频情感分析, userId: {}, assignmentId: {}, index: {}",
+                AUDIO_SENTIMENT_HEADER, userId, assignmentId, index);
+
+        Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        SpeakingAIDialogue dialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+
+        if (dialogue == null) {
+            log.error("{} 未找到对话记录, userId: {}, assignmentId: {}",
+                    AUDIO_SENTIMENT_HEADER, userId, assignmentId);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "不存在相应记录");
+        }
+
+        if (index == -1) {
+            index = Math.max(dialogue.getDialogues().size() - 2, 0);
+            log.debug("{} 自动计算index: {}", AUDIO_SENTIMENT_HEADER, index);
+        }
+
+        SpeakingAIDialogue.DialogueEntry entry = dialogue.getDialogues().get(index);
+
+        JSONObject json = new JSONObject();
+        json.put("url", entry.getAudioUrl());
+        RequestBody body = RequestBody.create(MediaType.get("application/json; charset=utf-8"), json.toString());
+
+        Request request = new Request.Builder()
+                .url(AUDIO_URL)
+                .post(body)
+                .build();
+
+        try (Response response = httpClient.newCall(request).execute()) {
+            if (!response.isSuccessful()) {
+                log.error("{} 音频情感分析请求失败, 状态码: {}, 响应: {}",
+                        AUDIO_SENTIMENT_HEADER, response.code(), response.body().string());
+                throw new IOException("Unexpected code " + response);
+            }
+
+            String res = response.body().string();
+            log.debug("{} 音频情感分析原始响应: {}", AUDIO_SENTIMENT_HEADER, res);
+
+            int endIndex = res.lastIndexOf("\"");
+            String emotion = res.substring(endIndex - 2, endIndex);
+
+            if (!emotionSet.contains(emotion)) {
+                emotion = "😐";
+                log.warn("{} 未识别的表情符号, 使用默认表情", AUDIO_SENTIMENT_HEADER);
+            }
+
+            entry.setAudioSentiment(emotion);
+            updateDialogueEntry(userId, assignmentId, index, entry);
+            log.info("{} 音频情感分析完成, 结果: {}", AUDIO_SENTIMENT_HEADER, emotion);
+
+            return emotion;
+        } catch (IOException e) {
+            log.error("{} 音频情感分析IO异常", AUDIO_SENTIMENT_HEADER, e);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "情感识别失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public String getAudioSentimentWithTimeout(Long userId, Long assignmentId, Integer index) {
+        log.info("{} 开始带超时的音频情感分析, userId: {}, assignmentId: {}, index: {}",
+                AUDIO_SENTIMENT_HEADER, userId, assignmentId, index);
+
+        CompletableFuture<String> future = getAudioSentimentAsync(userId, assignmentId, index);
+        try {
+            return future.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } catch (TimeoutException e) {
+            log.error("{} 音频情感分析请求超时", AUDIO_SENTIMENT_HEADER, e);
+            throw new MyException(HttpStatus.REQUEST_TIMEOUT.value(), "请求超时");
+        } catch (Exception e) {
+            log.error("{} 音频情感分析异常", AUDIO_SENTIMENT_HEADER, e);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "情感识别失败: " + e.getMessage());
+        }
+    }
+
+    @Async("doubaoTaskExecutor")
+    public CompletableFuture<String> getSpeakingSuggestionAsync(Long userId, Long assignmentId) {
+        return CompletableFuture.supplyAsync(() -> getSpeakingSuggestion(userId, assignmentId), doubaoTaskExecutor);
+    }
+
+    public String getSpeakingSuggestion(Long userId, Long assignmentId) {
+        String requestKey = userId + "-" + assignmentId + "-suggestion";
+        log.info("{} 开始获取口语建议, userId: {}, assignmentId: {}",
+                SPEAKING_SUGGESTION_HEADER, userId, assignmentId);
+
+        // 检查是否有相同的请求正在进行
+        CompletableFuture<String> existingFuture = pendingSuggestionRequests.get(requestKey);
+        if (existingFuture != null) {
+            log.debug("{} 发现重复请求, 等待已有请求完成", SPEAKING_SUGGESTION_HEADER);
+            try {
+                return existingFuture.get();
+            } catch (Exception e) {
+                pendingSuggestionRequests.remove(requestKey);
+                log.error("{} 等待重复请求时发生异常", SPEAKING_SUGGESTION_HEADER, e);
+                throw new MyException(HttpStatus.BAD_REQUEST.value(), "请求次数过多,请稍后再试");
+            }
+        }
+
+        SpeakingAIDialogue existingDialogue = mongoTemplate.findOne(
+                Query.query(Criteria.where("assignmentId").is(assignmentId).and("userId").is(userId)),
+                SpeakingAIDialogue.class
+        );
+
+        if (existingDialogue == null) {
+            log.error("{} 未找到对话记录, userId: {}, assignmentId: {}",
+                    SPEAKING_SUGGESTION_HEADER, userId, assignmentId);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "不存在相应记录");
+        }
+
+        if (existingDialogue.getSuggestion() != null && existingDialogue.getSuggestion().length() != 0) {
+            log.debug("{} 返回已存在的建议", SPEAKING_SUGGESTION_HEADER);
+            return existingDialogue.getSuggestion();
+        }
+
+        // 创建新的Future并放入缓存
+        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
+            try {
+                String message = buildSuggestionMessage(existingDialogue);
+                log.debug("{} 构建的建议请求消息: {}", SPEAKING_SUGGESTION_HEADER, message);
+
+                ChatMessage chatMessage = ChatMessage.builder()
+                        .role(ChatMessageRole.USER)
+                        .content(message)
+                        .build();
+
+                List<ChatMessage> list = Collections.singletonList(chatMessage);
+
+                ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
+                        .model(doubaoConfig.getEndpointId())
+                        .messages(list)
+                        .build();
+
+                String response = (String) doubaoConfig.arkService()
+                        .createChatCompletion(chatCompletionRequest)
+                        .getChoices().get(0).getMessage().getContent();
+
+                log.info("{} 获取到口语建议响应: {}", SPEAKING_SUGGESTION_HEADER, response);
+                existingDialogue.setSuggestion(response);
+                mongoTemplate.save(existingDialogue);
+
+                return response;
+            } catch (Exception e) {
+                log.error("{} 获取口语建议异常", SPEAKING_SUGGESTION_HEADER, e);
+                throw new RuntimeException("获取建议失败: " + e.getMessage());
+            } finally {
+                pendingSuggestionRequests.remove(requestKey);
+            }
+        }, doubaoTaskExecutor);
+
+        pendingSuggestionRequests.put(requestKey, future);
+
+        try {
+            return future.get();
+        } catch (Exception e) {
+            pendingSuggestionRequests.remove(requestKey);
+            log.error("{} 处理口语建议时异常", SPEAKING_SUGGESTION_HEADER, e);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "获取建议失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    public String getSpeakingSuggestionWithTimeout(Long userId, Long assignmentId) {
+        log.info("{} 开始带超时的口语建议获取, userId: {}, assignmentId: {}",
+                SPEAKING_SUGGESTION_HEADER, userId, assignmentId);
+
+        CompletableFuture<String> future = getSpeakingSuggestionAsync(userId, assignmentId);
+        try {
+            return future.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } catch (TimeoutException e) {
+            log.error("{} 获取口语建议请求超时", SPEAKING_SUGGESTION_HEADER, e);
+            throw new MyException(HttpStatus.REQUEST_TIMEOUT.value(), "获取建议请求超时");
+        } catch (Exception e) {
+            log.error("{} 获取口语建议异常", SPEAKING_SUGGESTION_HEADER, e);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "获取建议失败: " + e.getMessage());
+        }
+    }
+
+    public VideoAnalysisVo getVideoSentiment(Long userId, Long assignmentId) {
+        String requestKey = userId + "-" + assignmentId + "-video-sentiment";
+        log.info("{} 开始获取视频情感分析, userId: {}, assignmentId: {}",
+                VIDEO_SENTIMENT_HEADER, userId, assignmentId);
+
+        // 检查是否有相同的请求正在进行
+        CompletableFuture<VideoAnalysisVo> existingFuture = pendingVideoAnalysisRequests.get(requestKey);
+        if (existingFuture != null) {
+            log.debug("{} 发现重复视频分析请求, 等待已有请求完成", VIDEO_SENTIMENT_HEADER);
+            try {
+                return existingFuture.get(VIDEO_ANALYSIS_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+            } catch (Exception e) {
+                pendingVideoAnalysisRequests.remove(requestKey);
+                log.error("{} 等待重复视频分析请求时异常", VIDEO_SENTIMENT_HEADER, e);
+                throw new MyException(HttpStatus.BAD_REQUEST.value(), "请求次数过多,请稍后再试");
+            }
+        }
+
+        Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+        SpeakingAIDialogue dialogue = mongoTemplate.findOne(query, SpeakingAIDialogue.class);
+
+        if (dialogue == null) {
+            log.error("{} 未找到对话记录, userId: {}, assignmentId: {}",
+                    VIDEO_SENTIMENT_HEADER, userId, assignmentId);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "不存在相应记录");
+        }
+
+        if (dialogue.getVideoAnalysis() != null && dialogue.getVideoAnalysis().getNewAudioUrl() != null) {
+            log.debug("{} 返回已存在的视频分析结果", VIDEO_SENTIMENT_HEADER);
+            return dialogue.getVideoAnalysis();
+        }
+
+        // 提交任务到线程池
+        CompletableFuture<VideoAnalysisVo> future = CompletableFuture.supplyAsync(() -> {
+            try {
+                VideoAnalysisVo result = sendRequestToDeepface(dialogue.getAudioSigment(), dialogue);
+                log.info("{} 视频情感分析完成, 结果: {}", VIDEO_SENTIMENT_HEADER, result);
+                return result;
+            } catch (IOException e) {
+                log.error("{} 视频情感分析IO异常", VIDEO_SENTIMENT_HEADER, e);
+                throw new RuntimeException("视频情感识别失败: " + e.getMessage());
+            } finally {
+                pendingVideoAnalysisRequests.remove(requestKey);
+            }
+        }, doubaoTaskExecutor);
+
+        pendingVideoAnalysisRequests.put(requestKey, future);
+
+        try {
+            return future.get(VIDEO_ANALYSIS_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+        } catch (TimeoutException e) {
+            log.error("{} 视频情感分析请求超时", VIDEO_SENTIMENT_HEADER, e);
+            throw new MyException(HttpStatus.REQUEST_TIMEOUT.value(), "请求超时");
+        } catch (Exception e) {
+            pendingVideoAnalysisRequests.remove(requestKey);
+            log.error("{} 视频情感分析异常", VIDEO_SENTIMENT_HEADER, e);
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "情感识别失败: " + e.getMessage());
+        }
+    }
+
+    private VideoAnalysisVo sendRequestToDeepface(List<String> audioSegment, SpeakingAIDialogue dialogue) throws IOException {
+        log.debug("{} 开始发送视频分析请求, 音频片段数量: {}", VIDEO_SENTIMENT_HEADER, audioSegment.size());
+
+        JSONObject json = new JSONObject();
+        json.put("video_urls", audioSegment);
+        RequestBody body = RequestBody.create(MediaType.get("application/json; charset=utf-8"), json.toString());
+
+        Request request = new Request.Builder()
+                .url(VIDEO_URL)
+                .post(body)
+                .build();
+
+        try (Response response = httpClient.newCall(request).execute()) {
+            if (!response.isSuccessful()) {
+                String errorBody = response.body() != null ? response.body().string() : "empty body";
+                log.error("{} 视频分析请求失败, 状态码: {}, 响应: {}",
+                        VIDEO_SENTIMENT_HEADER, response.code(), errorBody);
+                throw new IOException("Unexpected code " + response);
+            }
+
+            String res = response.body().string();
+            log.debug("{} 视频分析原始响应: {}", VIDEO_SENTIMENT_HEADER, res);
+
+            VideoAnalysisVo videoAnalysisVo = parseResponse(res);
+            dialogue.setVideoAnalysis(videoAnalysisVo);
+            mongoTemplate.save(dialogue);
+
+            return videoAnalysisVo;
+        }
+    }
+
+    private VideoAnalysisVo parseResponse(String response) throws JsonProcessingException {
+        try {
+            ObjectMapper objectMapper = new ObjectMapper();
+            Map<String, Object> jsonMap = objectMapper.readValue(response, Map.class);
+
+            VideoAnalysisVo videoAnalysisVo = new VideoAnalysisVo();
+            videoAnalysisVo.setNewAudioUrl((String) jsonMap.get("merged_video_path"));
+
+            Map<String, Integer> emotionPercentageDict = (Map<String, Integer>) jsonMap.get("emotion_percentage_dict");
+            List<VideoAnalysisVo.EmotionPercentage> emotionPercentageList = new ArrayList<>();
+
+            for (Map.Entry<String, Integer> entry : emotionPercentageDict.entrySet()) {
+                VideoAnalysisVo.EmotionPercentage emotionPercentage = new VideoAnalysisVo.EmotionPercentage();
+                emotionPercentage.setEmotion(entry.getKey());
+                emotionPercentage.setPercentage(entry.getValue().floatValue());
+                emotionPercentageList.add(emotionPercentage);
+            }
+
+            videoAnalysisVo.setEmotionPercentageList(emotionPercentageList);
+            return videoAnalysisVo;
+        } catch (Exception e) {
+            log.error("{} 解析视频分析响应异常, 响应内容: {}", VIDEO_SENTIMENT_HEADER, response, e);
+            throw e;
+        }
+    }
+
+    private void updateDialogueEntry(Long userId, Long assignmentId, int index, SpeakingAIDialogue.DialogueEntry updatedEntry) {
+        try {
+            Query query = new Query(Criteria.where("userId").is(userId).and("assignmentId").is(assignmentId));
+            Update update = new Update().set("dialogues." + index, updatedEntry);
+            mongoTemplate.updateFirst(query, update, SpeakingAIDialogue.class);
+            log.debug("{} 成功更新对话条目, userId: {}, assignmentId: {}, index: {}",
+                    AUDIO_SCORE_HEADER, userId, assignmentId, index);
+        } catch (Exception e) {
+            log.error("{} 更新对话条目失败", AUDIO_SCORE_HEADER, e);
+            throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "更新对话条目失败");
+        }
+    }
+
+    private String buildSuggestionMessage(SpeakingAIDialogue dialogue) {
+        return "你是一位英语助教老师,下面是一位学生口语练习时的口语记录和口语得分," + dialogue
+                + "\n请严格按照以下格式返回详细且实用的口语和交流建议:"
+                + "1. 语法错误:[评估句法复杂性与准确性,提出修改建议,提供修改后的样本,提供提升建议]\n"
+                + "2. 主题相关性:[分析对话内容与主题的关联程度,是否紧扣主题,有无偏离主题的情况,提出改进方向]\n"
+                + "3. 逻辑表现情况:[判断对话逻辑是否清晰,过渡是否自然,给出优化逻辑的建议]\n"
+                + "4. 词汇量:[评估说话人的词汇量、词汇复杂性、词汇准确性、词汇搭配,提供修改建议,提供修改后的样本,提供提升建议]\n"
+                + "5. 语用能力评估和反馈:[根据语境、身份等评估说话人话语在内容和语言表达上的恰当性,提供修改建议,提供修改后的样本,提出提升建议]"
+                + "6. 发音准确性:"
+                + "- 具体表现:详细描述学生的发音准确性,例如哪些单词或音标发音正确(如元音、辅音、连读等),哪些发音存在问题(如常见的 /θ/、/ð/、/r/ 等音标)。"
+                + "- 改进建议:提供具体的练习方法,例如:"
+                + "  - 针对错误发音,推荐使用音标练习工具(如 IPA 图表)或模仿母语者的发音视频。"
+                + "  - 练习单词时,建议使用“慢速-正常速”对比法,逐步纠正发音。"
+                + "  - 对于连读和弱读问题,推荐练习常见的连读规则(如 \"want to\" → \"wanna\")。"
+                + "7. 对话流利度:"
+                + "- 具体表现:评估学生的流利度,例如是否存在长时间停顿、重复、自我纠正或语法错误导致的卡顿。"
+                + "- 改进建议:提供实用的提升方法"
+                + "8. 语音情绪分析:"
+                + "- 具体表现:分析学生在对话中的情绪表达,例如是否表现出自信、紧张、平淡或热情。"
+                + "- 改进建议:提供情绪表达的优化建议,例如:"
+                + "  - 如果学生语气平淡,建议练习通过语调变化(如升调、降调)增强表达力。"
+                + "  - 如果学生表现出紧张,建议通过深呼吸和放松练习缓解压力,同时多进行模拟对话以增强信心。"
+                + "  - 鼓励学生在对话中加入情感词汇(如 \"I'm really excited about...\" 或 \"I feel a bit unsure about...\")以增强情感表达。";
+    }
+
+
+
+
+}

+ 23 - 23
src/main/java/com/njuzr/eaibackend/service/impl/UserServiceImpl.java

@@ -59,42 +59,42 @@ public class UserServiceImpl implements UserService {
 
     @Override
     public UserVO createUser(UserRegisterDTO userDTO) {
-//        // 从Redis中获取验证码
-//        final String key = "registerVerifyCode:" + userDTO.getOfficialEmail();
-//        String storedCode = (String) redisTemplate.opsForValue().get(key);
-//
-//        if (storedCode == null) {
-//            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "验证码已过期");
-//        }
-//        // 验证码校验
-//        if (storedCode.equals(userDTO.getVerifyCode())) {
+        // 从Redis中获取验证码
+        final String key = "registerVerifyCode:" + userDTO.getOfficialEmail();
+        String storedCode = (String) redisTemplate.opsForValue().get(key);
+
+        if (storedCode == null) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "验证码已过期");
+        }
+        // 验证码校验
+        if (storedCode.equals(userDTO.getVerifyCode())) {
             User targetUser = ModelMapperUtil.map(userDTO, User.class);
 
-//            targetUser.setPassword(new BCryptPasswordEncoder().encode(targetUser.getPassword())); // 密码加密存储
+            targetUser.setPassword(new BCryptPasswordEncoder().encode(targetUser.getPassword())); // 密码加密存储
             targetUser.setCreateTime(new Date()); // 设置创建时间
 
             // 同一个邮箱、学号只能注册一次
-//            if (isOfficialNumberNotExists(targetUser.getOfficialNumber())
-//                    && isEmailNotExists(targetUser.getOfficialEmail())) {
-//                int status = userMapper.insert(targetUser);
-//                if (status == 0) {
-//                    log.error("createUser -- 数据库插入错误:");
-//                    throw new MyException(501, "数据库插入错误");
-//                }
-//            }
+            if (isOfficialNumberNotExists(targetUser.getOfficialNumber())
+                    && isEmailNotExists(targetUser.getOfficialEmail())) {
+                int status = userMapper.insert(targetUser);
+                if (status == 0) {
+                    log.error("createUser -- 数据库插入错误:");
+                    throw new MyException(501, "数据库插入错误");
+                }
+            }
             int state = userMapper.insert(targetUser);
             log.info("state: {}", state);
             log.info("User创建完毕,User对象如下:" + targetUser);
 
-//            redisTemplate.delete(key); //注册成功后删除验证码
+            redisTemplate.delete(key); //注册成功后删除验证码
 
             return ModelMapperUtil.map(targetUser, UserVO.class);
 
         }
-//        else {
-//            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "验证码错误");
-//        }
-//    }
+        else {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "验证码错误");
+        }
+    }
 
 
     /**

+ 95 - 0
src/main/java/com/njuzr/eaibackend/utils/XmlParasUtil.java

@@ -0,0 +1,95 @@
+package com.njuzr.eaibackend.utils;
+
+import com.njuzr.eaibackend.po.SpeakingAIDialogue;
+import lombok.extern.slf4j.Slf4j;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author Liululin
+ * @date 2025/3/2 - 13:25
+ */
+@Slf4j
+public class XmlParasUtil {
+
+    public static void paras(String xml, SpeakingAIDialogue.DialogueEntry entry) {
+        try {
+            if (xml == null || xml.trim().isEmpty()) {
+                log.error("XML 数据为空");
+                return;
+            }
+            // 解析 XML
+            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+            DocumentBuilder builder = factory.newDocumentBuilder();
+            Document document = builder.parse(new ByteArrayInputStream(xml.getBytes()));
+
+            // 提取关键指标
+            NodeList readChapterList = document.getElementsByTagName("read_chapter");
+            if (readChapterList.getLength() == 0) {
+                log.error("XML 中未找到 read_chapter 标签");
+                return;
+            }
+            Element readChapter = (Element) readChapterList.item(0);
+            // 提取分数
+            double accuracyScore = parseDoubleAttribute(readChapter, "accuracy_score");
+            double fluencyScore = parseDoubleAttribute(readChapter, "fluency_score");
+            double integrityScore = parseDoubleAttribute(readChapter, "integrity_score");
+            double standardScore = parseDoubleAttribute(readChapter, "standard_score");
+            double totalScore = parseDoubleAttribute(readChapter, "total_score");
+
+            // 提取每个单词的分数
+            List<SpeakingAIDialogue.WordScore> wordScores = new ArrayList<>();
+            NodeList wordNodes = document.getElementsByTagName("word");
+            for (int i = 0; i < wordNodes.getLength(); i++) {
+                Element wordElement = (Element) wordNodes.item(i);
+                String content = wordElement.getAttribute("content");
+                double totalScoreWord = parseDoubleAttribute(wordElement, "total_score");
+                wordScores.add(new SpeakingAIDialogue.WordScore(content, Double.parseDouble(String.format("%.1f", totalScoreWord))));
+            }
+
+            // 设置分数到 entry 中
+            // 设置分数到 entry 中
+            entry.setAccuracyScore(Double.parseDouble(String.format("%.1f", accuracyScore)));
+            entry.setFluencyScore(Double.parseDouble(String.format("%.1f", fluencyScore)));
+            entry.setIntegrityScore(Double.parseDouble(String.format("%.1f", integrityScore)));
+            entry.setStandardScore(Double.parseDouble(String.format("%.1f", standardScore)));
+            entry.setTotalScore(Double.parseDouble(String.format("%.1f", totalScore)));
+            entry.setWordScoreList(wordScores);
+
+            // 输出结果
+            System.out.println("准确度:" + accuracyScore);
+            System.out.println("流畅度:" + fluencyScore);
+            System.out.println("完整性:" + integrityScore);
+            System.out.println("标准分:" + standardScore);
+            System.out.println("总分:" + totalScore);
+            System.out.println("\n每个单词的分数:");
+            for (SpeakingAIDialogue.WordScore wordScore : wordScores) {
+                System.out.println(wordScore.getWord() + ":" + wordScore.getTotalScore());
+            }
+
+        } catch (Exception e) {
+            log.error("提取分数错误: " + e.getMessage(), e);
+        }
+    }
+
+    private static double parseDoubleAttribute(Element element, String attributeName) {
+        String value = element.getAttribute(attributeName);
+        if (value.trim().isEmpty()) {
+            log.warn("属性 " + attributeName + " 为空");
+            return 0.0; // 默认值
+        }
+        try {
+            return Double.parseDouble(value);
+        } catch (NumberFormatException e) {
+            log.error("属性 " + attributeName + " 格式错误: " + value);
+            return 0.0; // 默认值
+        }
+    }
+}

+ 17 - 0
src/main/java/com/njuzr/eaibackend/vo/AIReplyVo.java

@@ -0,0 +1,17 @@
+package com.njuzr.eaibackend.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * @author Liululin
+ * @date 2025/3/1 - 13:25
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class AIReplyVo {
+    private String message;
+    private String audioUrl;
+}

+ 24 - 0
src/main/java/com/njuzr/eaibackend/vo/AiSpeakingAssignmentVO.java

@@ -0,0 +1,24 @@
+package com.njuzr.eaibackend.vo;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import java.util.List;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 14:50
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class AiSpeakingAssignmentVO extends AssignmentVO{
+    private Integer minDuration;
+
+    private Integer minInteraction;
+
+    private List<String> surveyLinks;
+
+    private Integer surveyDuration;
+}

+ 2 - 0
src/main/java/com/njuzr/eaibackend/vo/AssignmentVO.java

@@ -21,6 +21,8 @@ public class AssignmentVO implements Serializable {
 
     private String description;
 
+    private String type;
+
     private String descriptionFile; // 作业要求,Word/Pdf文件
 
     private List<String> attachments; // 作业附件

+ 16 - 0
src/main/java/com/njuzr/eaibackend/vo/RestrictDataVo.java

@@ -0,0 +1,16 @@
+package com.njuzr.eaibackend.vo;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * @author Liululin
+ * @date 2025/3/18 - 16:55
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class RestrictDataVo {
+    private Integer chatTimes;
+    private Double recordTime;
+}

+ 25 - 0
src/main/java/com/njuzr/eaibackend/vo/VideoAnalysisVo.java

@@ -0,0 +1,25 @@
+package com.njuzr.eaibackend.vo;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author Liululin
+ * @date 2025/3/12 - 16:50
+ */
+@Data
+public class VideoAnalysisVo {
+    @JsonProperty("merged_video_path") // 映射 JSON 中的 merged_video_path 字段
+    private String newAudioUrl;
+
+    @JsonProperty("emotion_percentage_dict") // 映射 JSON 中的 emotion_percentage_dict 字段
+    private List<EmotionPercentage> emotionPercentageList;
+
+    @Data
+    public static class EmotionPercentage {
+        private String emotion;
+        private Float percentage;
+    }
+}