diff --git a/debug_roi.jpg b/debug_roi.jpg deleted file mode 100644 index a4cd025..0000000 Binary files a/debug_roi.jpg and /dev/null differ diff --git a/enlish-service/pom.xml b/enlish-service/pom.xml index de04116..56c5dd0 100644 --- a/enlish-service/pom.xml +++ b/enlish-service/pom.xml @@ -153,6 +153,25 @@ java-jwt + + org.springframework.ai + spring-ai-openai + + + + org.springframework.ai + spring-ai-starter-model-openai + + + + com.google.zxing + core + + + + com.google.zxing + javase + diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/LessonPlanController.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/LessonPlanController.java index 600ed99..c2249c3 100644 --- a/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/LessonPlanController.java +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/controller/LessonPlanController.java @@ -1,9 +1,10 @@ package com.yinlihupo.enlish.service.controller; import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO; -import com.yinlihupo.enlish.service.model.vo.plan.AddLessonPlanReqVO; -import com.yinlihupo.enlish.service.model.vo.plan.DownLoadLessonPlanReqVO; +import com.yinlihupo.enlish.service.domain.dataobject.VocabularyBankDO; +import com.yinlihupo.enlish.service.model.vo.plan.*; import com.yinlihupo.enlish.service.service.LessonPlansService; +import com.yinlihupo.enlish.service.utils.TTSUtil; import com.yinlihupo.enlish.service.utils.WordExportUtil; import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog; import com.yinlihupo.framework.common.response.Response; @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Map; import java.util.concurrent.Executor; @@ -30,6 +32,8 @@ public class LessonPlanController { @Resource(name = "taskExecutor") private Executor taskExecutor; + @Resource + private TTSUtil ttsUtil; @Value("${templates.plan.weekday}") private String planWeekday; @@ -60,13 +64,36 @@ public class LessonPlanController { try { Map map = JsonUtils.parseMap(lessonPlanById.getContentDetails(), String.class, Object.class); if (!lessonPlanById.getTitle().contains("复习")) { - WordExportUtil.generateLessonPlanDocx(map, lessonPlanById.getTitle(), response, planWeekday, true); + WordExportUtil.generateLessonPlanDocx(map, lessonPlanById, response, planWeekday, true); } else { - WordExportUtil.generateLessonPlanDocx(map, lessonPlanById.getTitle(), response, planWeekend, false); + WordExportUtil.generateLessonPlanDocx(map, lessonPlanById, response, planWeekend, false); } } catch (Exception e) { throw new RuntimeException(e); } } + + @PostMapping("word/voice") + @ApiOperationLog(description = "获取单词") + public Response findPlanWordVoice(@RequestBody FindWordVoiceReqVO findWordVoiceReqVO) { + Integer id = findWordVoiceReqVO.getPlanId(); + LessonPlansDO lessonPlanById = lessonPlanService.findLessonPlanById(id); + try { + Map map = JsonUtils.parseMap(lessonPlanById.getContentDetails(), String.class, Object.class); + Object syncVocabList = map.get("syncVocabList"); + List list = JsonUtils.parseList(JsonUtils.toJsonString(syncVocabList), VocabularyBankDO.class); + List words = list.stream().map(VocabularyBankDO::getWord).toList(); + + return Response.success(FindWordVoiceRspVO.builder().words(words).build()); + } catch (Exception e) { + log.error(e.getMessage()); + return Response.fail("获取单词失败"); + } + } + + @PostMapping("word/voice/tts") + public void findPlanWordVoiceTTS(@RequestBody FindWordTTSVoiceReqVO findWordVoiceReqVO, HttpServletResponse response) { + ttsUtil.generateWordVoice(findWordVoiceReqVO.getText(), response); + } } diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordTTSVoiceReqVO.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordTTSVoiceReqVO.java new file mode 100644 index 0000000..7773785 --- /dev/null +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordTTSVoiceReqVO.java @@ -0,0 +1,17 @@ +package com.yinlihupo.enlish.service.model.vo.plan; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class FindWordTTSVoiceReqVO { + + String text; + String voice; + String format; +} diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordVoiceReqVO.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordVoiceReqVO.java new file mode 100644 index 0000000..b390b64 --- /dev/null +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordVoiceReqVO.java @@ -0,0 +1,15 @@ +package com.yinlihupo.enlish.service.model.vo.plan; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class FindWordVoiceReqVO { + + private Integer planId; +} diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordVoiceRspVO.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordVoiceRspVO.java new file mode 100644 index 0000000..fd1aa8a --- /dev/null +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/model/vo/plan/FindWordVoiceRspVO.java @@ -0,0 +1,17 @@ +package com.yinlihupo.enlish.service.model.vo.plan; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class FindWordVoiceRspVO { + + private List words; +} diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/TTSUtil.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/TTSUtil.java new file mode 100644 index 0000000..400b020 --- /dev/null +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/TTSUtil.java @@ -0,0 +1,66 @@ +package com.yinlihupo.enlish.service.utils; + +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.openai.OpenAiAudioSpeechModel; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.IOException; + +@Component +@Slf4j +public class TTSUtil { + + private final OpenAiAudioSpeechModel speechModel; + + public TTSUtil(OpenAiAudioSpeechModel speechModel) { + this.speechModel = speechModel; + } + + /** + * 生成语音并将二进制流写入 HTTP 响应 + * + * @param text 需要转换的文本 + * @param response HTTP 响应对象 + */ + public void generateWordVoice(String text, HttpServletResponse response) { + // 1. 参数校验 + if (!StringUtils.hasText(text)) { + sendError(response, HttpServletResponse.SC_BAD_REQUEST, "Input text cannot be empty"); + return; + } + + try { + // 3. 调用 OpenAI 接口获取音频数据 + byte[] audioBytes = speechModel.call(text); + + // 4. 设置 HTTP 响应头 + response.setContentType("audio/mpeg"); + response.setContentLength(audioBytes.length); + + // 可选:如果不希望浏览器自动播放,而是强制下载,请取消下面这行的注释 + // response.setHeader("Content-Disposition", "attachment; filename=\"speech.mp3\""); + + // 5. 将音频数据写入响应流 + try (ServletOutputStream outputStream = response.getOutputStream()) { + outputStream.write(audioBytes); + outputStream.flush(); + } + + } catch (Exception e) { + log.error("TTS Generation failed: {}", e.getMessage()); + sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "TTS Generation failed: " + e.getMessage()); + } + } + + // 辅助方法:发送错误响应 + private void sendError(HttpServletResponse response, int status, String message) { + try { + response.sendError(status, message); + } catch (IOException e) { + // 忽略这里的错误,因为响应可能已经提交 + } + } +} diff --git a/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/WordExportUtil.java b/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/WordExportUtil.java index e6e7c9b..1621afe 100644 --- a/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/WordExportUtil.java +++ b/enlish-service/src/main/java/com/yinlihupo/enlish/service/utils/WordExportUtil.java @@ -2,13 +2,25 @@ package com.yinlihupo.enlish.service.utils; import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.config.Configure; +import com.deepoove.poi.data.PictureType; +import com.deepoove.poi.data.Pictures; import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; import com.yinlihupo.enlish.service.domain.dataobject.ExamWordsDO; +import com.yinlihupo.enlish.service.domain.dataobject.LessonPlansDO; import jakarta.servlet.http.HttpServletResponse; +import java.awt.image.BufferedImage; import java.io.*; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.zip.ZipEntry; @@ -68,8 +80,8 @@ public class WordExportUtil { } } - public static void generateLessonPlanDocx(Map map, String fileName, HttpServletResponse response, String templateWordPath, boolean isWeekday) throws IOException { - fileName = URLEncoder.encode(fileName + ".docx", StandardCharsets.UTF_8).replaceAll("\\+", "%20"); + public static void generateLessonPlanDocx(Map map, LessonPlansDO lessonPlan, HttpServletResponse response, String templateWordPath, boolean isWeekday) throws IOException { + String fileName = URLEncoder.encode(lessonPlan.getTitle() + ".docx", StandardCharsets.UTF_8).replaceAll("\\+", "%20"); // 3. 设置响应头 response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); @@ -82,6 +94,8 @@ public class WordExportUtil { } else { template = XWPFTemplate.compile(inputStream, configLessonPlanWeekend); } + String url = "http://localhost:5173/#/plan/tts?planId=" + lessonPlan.getId(); + map.put("img", Pictures.ofBytes(generateQR(url), PictureType.PNG).create()); OutputStream out = response.getOutputStream(); template.render(map); template.write(out); @@ -161,4 +175,38 @@ public class WordExportUtil { out.flush(); } } + + private static byte[] generateQR(String content) { + int width = 300; + int height = 300; + String format = "png"; + + Map hints = new HashMap<>(); + hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); + hints.put(EncodeHintType.MARGIN, 1); + + try { + // 1. 生成比特矩阵 + BitMatrix bitMatrix = new MultiFormatWriter().encode( + content, + BarcodeFormat.QR_CODE, + width, + height, + hints + ); + + // 2. 将 BitMatrix 转为字节数组 + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + // MatrixToImageWriter 是 Zxing 提供的工具类 + MatrixToImageWriter.writeToStream(bitMatrix, format, outputStream); + + return outputStream.toByteArray(); + + } catch (WriterException | IOException e) { + // 建议增加对 IOException 的捕获,因为涉及流操作 + throw new RuntimeException("生成二维码失败", e); + } + } } diff --git a/enlish-service/src/main/resources/config/application-dev.yml b/enlish-service/src/main/resources/config/application-dev.yml index 86fc7de..17ed3d5 100644 --- a/enlish-service/src/main/resources/config/application-dev.yml +++ b/enlish-service/src/main/resources/config/application-dev.yml @@ -19,6 +19,15 @@ spring: max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制) min-idle: 0 # 连接池中的最小空闲连接 max-idle: 10 # 连接池中的最大空闲连接 + ai: + openai: + api-key: your_api_key_here + base-url: http://124.220.58.5:2233 + audio: + speech: + options: + model: tts-1 + voice: alloy templates: @@ -26,7 +35,7 @@ templates: count: 100 data: C:\project\tess plan: - weekday: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\tem_study_plan_v2.docx + weekday: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\tem_study_plan_v3.docx weekend: C:\project\java\enlish_edu\enlish\enlish-service\src\main\resources\templates\study_plan_review_v1.docx plan_day: 7 tmp: @@ -38,4 +47,4 @@ ai: aliyun: accessKeyId: - accessKeySecret: \ No newline at end of file + accessKeySecret: diff --git a/enlish-service/src/main/resources/templates/tem_study_plan_v3.docx b/enlish-service/src/main/resources/templates/tem_study_plan_v3.docx new file mode 100644 index 0000000..3d4fb8d Binary files /dev/null and b/enlish-service/src/main/resources/templates/tem_study_plan_v3.docx differ diff --git a/enlish-service/src/test/java/com/yinlihupo/enlish/service/voice/VoiceTest.java b/enlish-service/src/test/java/com/yinlihupo/enlish/service/voice/VoiceTest.java new file mode 100644 index 0000000..dfa5e73 --- /dev/null +++ b/enlish-service/src/test/java/com/yinlihupo/enlish/service/voice/VoiceTest.java @@ -0,0 +1,18 @@ +package com.yinlihupo.enlish.service.voice; + +import com.yinlihupo.enlish.service.utils.TTSUtil; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class VoiceTest { + + @Resource + private TTSUtil ttsUtil; + + @Test + public void test() { + + } +} diff --git a/enlish-vue/src/api/plan.js b/enlish-vue/src/api/plan.js index 44aa105..c0f6e65 100644 --- a/enlish-vue/src/api/plan.js +++ b/enlish-vue/src/api/plan.js @@ -45,6 +45,12 @@ export function downloadLessonPlan(data) { }); } +export function getLessonPlanWords(planId) { + return axios.post('plan/word/voice', { + planId: planId + }) +} + const resolveBlob = (res, fileName) => { // 创建 Blob 对象,可以指定 type,也可以让浏览器自动推断 const blob = new Blob([res], { type: 'application/octet-stream' }); diff --git a/enlish-vue/src/api/tts.js b/enlish-vue/src/api/tts.js new file mode 100644 index 0000000..2b9cc68 --- /dev/null +++ b/enlish-vue/src/api/tts.js @@ -0,0 +1,12 @@ +import axios from "@/axios"; + +export function synthesizeOpenAITTS(text, voice = 'alloy', format = 'mp3') { + return axios.post('/plan/word/voice/tts', { + text, + voice, + format + }, { + responseType: 'blob' + }) +} + diff --git a/enlish-vue/src/pages/PlanTTS.vue b/enlish-vue/src/pages/PlanTTS.vue new file mode 100644 index 0000000..bb983cb --- /dev/null +++ b/enlish-vue/src/pages/PlanTTS.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/enlish-vue/src/router/index.js b/enlish-vue/src/router/index.js index 902246c..37ba295 100644 --- a/enlish-vue/src/router/index.js +++ b/enlish-vue/src/router/index.js @@ -4,6 +4,7 @@ import Class from '@/pages/class.vue' import { createRouter, createWebHashHistory } from 'vue-router' import Admid from '@/pages/admid/admid.vue' import Student from '@/pages/student.vue' +import PlanTTS from '@/pages/PlanTTS.vue' // 统一在这里声明所有路由 const routes = [ @@ -41,6 +42,13 @@ const routes = [ meta: { // meta 信息 title: '管理员页面' // 页面标题 } + }, + { + path: '/plan/tts', + component: PlanTTS, + meta: { + title: 'TTS生成' + } } ] diff --git a/pom.xml b/pom.xml index e73f516..b2decd8 100644 --- a/pom.xml +++ b/pom.xml @@ -281,6 +281,27 @@ ${jaxb-runtime.version} + + org.springframework.ai + spring-ai-bom + 1.1.0 + pom + import + + + + + com.google.zxing + core + 3.5.2 + + + + com.google.zxing + javase + 3.5.2 + +