diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/controller/AppletApiTTSController.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/controller/AppletApiTTSController.java index 362fb6c..96cf38f 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/controller/AppletApiTTSController.java +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/controller/AppletApiTTSController.java @@ -268,4 +268,13 @@ public class AppletApiTTSController { } } + @IgnoreAuth + @Operation(summary = "获取音频构建状态", description = "根据内容哈希码获取音频构建状态") + @GetMapping(value = "/getAudioBuildStatus") + public Result> getAudioBuildStatus( + @Parameter(description = "内容哈希码", required = true) String contentHashcode) { + Map status = appletApiTTService.getAudioBuildStatus(contentHashcode); + return Result.OK(status); + } + } diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/AppletApiTTService.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/AppletApiTTService.java index 7f253ee..25d8181 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/AppletApiTTService.java +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/AppletApiTTService.java @@ -7,6 +7,7 @@ import org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; public interface AppletApiTTService { /** @@ -26,12 +27,17 @@ public interface AppletApiTTService { /** * 创建长文本TTS任务(异步),如已在生成中则直接返回任务信息 */ - String createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl); + CompletableFuture createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl); /** * 为文章内容遍历所有启用音色创建长文本TTS任务(异步) */ - void generateLongTextForArticleContentAllTimbres(String content); + String generateLongTextForArticleContentAllTimbres(String content); + + /** + * 为课程页面内容遍历所有启用音色创建长文本TTS任务(异步) + */ + String generateLongTextForCoursePageContentAllTimbres(String pageId); /** * 按页面创建长文本TTS任务(异步),仅接收页面ID与音色ID @@ -60,5 +66,12 @@ public interface AppletApiTTService { Map selectMapByHtml(String content); + /** + * 根据内容哈希码获取音频构建状态 + * @param contentHashcode 内容哈希码 + * @return Map包含total(总数)和completed(已完成数) + */ + Map getAudioBuildStatus(String contentHashcode); + } diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/impl/AppletApiTTServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/impl/AppletApiTTServiceImpl.java index b253dfa..02b02d7 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/impl/AppletApiTTServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/service/impl/AppletApiTTServiceImpl.java @@ -1,6 +1,5 @@ package org.jeecg.modules.applet.service.impl; - import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; @@ -33,11 +32,16 @@ import org.jeecg.modules.demo.appletTtsTimbre.service.IAppletTtsTimbreService; import org.jeecg.modules.demo.appletTtsCache.entity.AppletTtsCache; import org.jeecg.modules.demo.appletTtsCache.service.IAppletTtsCacheService; import org.jeecg.modules.applet.util.AudioDurationUtil; +import org.jeecg.modules.applet.util.HtmlUtils; import java.io.ByteArrayInputStream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import java.util.concurrent.TimeUnit; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.CompletableFuture; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.*; @@ -69,6 +73,109 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { @Autowired private RedisTemplate redisTemplate; + // 长文本TTS并发控制 - 滑动窗口限流器 + private final ConcurrentLinkedQueue requestTimestamps = new ConcurrentLinkedQueue<>(); + private final AtomicInteger currentRequests = new AtomicInteger(0); + private static final int MAX_REQUESTS_PER_SECOND = 10; + private static final long WINDOW_SIZE_MS = 1000L; // 1秒窗口 + + /** + * 长文本TTS请求限流控制 + * 使用滑动窗口算法,限制每秒最多10个请求 + * @return true表示可以继续请求,false表示需要等待 + */ + private boolean acquireLongTextTtsPermit() { + long currentTime = System.currentTimeMillis(); + + // 清理过期的时间戳(超过1秒的请求记录) + while (!requestTimestamps.isEmpty()) { + Long oldestTime = requestTimestamps.peek(); + if (oldestTime != null && currentTime - oldestTime > WINDOW_SIZE_MS) { + requestTimestamps.poll(); + currentRequests.decrementAndGet(); + } else { + break; + } + } + + // 检查当前窗口内的请求数量 + if (currentRequests.get() < MAX_REQUESTS_PER_SECOND) { + requestTimestamps.offer(currentTime); + currentRequests.incrementAndGet(); + return true; + } + + return false; + } + + /** + * 等待获取长文本TTS请求许可 + * 如果当前请求数已达上限,则等待直到可以发送请求 + */ + private void waitForLongTextTtsPermit() { + while (!acquireLongTextTtsPermit()) { + try { + // 等待100毫秒后重试 + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("长文本TTS限流等待被中断: {}", e.getMessage()); + break; + } + } + } + + /** + * 创建腾讯云TTS客户端 + * @return TtsClient实例 + */ + private TtsClient createTtsClient() { + // 创建认证对象 + Credential cred = new Credential(secretId, secretKey); + + // 实例化一个http选项 + HttpProfile httpProfile = new HttpProfile(); + httpProfile.setEndpoint("tts.tencentcloudapi.com"); + + // 实例化一个client选项 + ClientProfile clientProfile = new ClientProfile(); + clientProfile.setHttpProfile(httpProfile); + + // 实例化要请求产品的client对象 + return new TtsClient(cred, "", clientProfile); + } + + /** + * 设置TTS请求的通用参数 + * @param speed 语速 + * @param voiceType 音色类型 + * @param volume 音量 + * @param codec 编码格式 + */ + private void setCommonTtsParams(Object request, Float speed, Integer voiceType, Float volume, String codec) { + try { + // 使用反射设置通用参数 + if (speed != null) { + request.getClass().getMethod("setSpeed", Float.class).invoke(request, speed); + } + if (voiceType != null) { + request.getClass().getMethod("setVoiceType", Long.class).invoke(request, voiceType.longValue()); + } + if (volume != null) { + request.getClass().getMethod("setVolume", Float.class).invoke(request, volume); + } + + String codecValue = (codec != null && !codec.isEmpty()) ? codec : "wav"; + request.getClass().getMethod("setCodec", String.class).invoke(request, codecValue); + + // 设置其他默认参数 + request.getClass().getMethod("setModelType", Long.class).invoke(request, 1L); + request.getClass().getMethod("setPrimaryLanguage", Long.class).invoke(request, 2L); + request.getClass().getMethod("setSampleRate", Long.class).invoke(request, 16000L); + } catch (Exception e) { + log.warn("设置TTS请求参数时发生异常: {}", e.getMessage()); + } + } public TtsVo textToVoice(String text, Float speed, Integer voiceType, Float volume, String codec) { long startTime = System.currentTimeMillis(); @@ -107,19 +214,8 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { // 2. 缓存未命中,调用腾讯云TTS接口生成音频 log.info("TTS缓存未命中,调用腾讯云接口生成音频"); - // 创建认证对象 - Credential cred = new Credential(secretId, secretKey); - - // 实例化一个http选项,可选的,没有特殊需求可以跳过 - HttpProfile httpProfile = new HttpProfile(); - httpProfile.setEndpoint("tts.tencentcloudapi.com"); - - // 实例化一个client选项,可选的,没有特殊需求可以跳过 - ClientProfile clientProfile = new ClientProfile(); - clientProfile.setHttpProfile(httpProfile); - - // 实例化要请求产品的client对象 - TtsClient client = new TtsClient(cred, "", clientProfile); + // 创建TTS客户端 + TtsClient client = createTtsClient(); // 实例化一个请求对象 TextToVoiceRequest req = new TextToVoiceRequest(); @@ -128,26 +224,8 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { req.setText(text); req.setSessionId(UUID.randomUUID().toString()); - // 设置可选参数 - if (speed != null) { - req.setSpeed(speed); - } - if (voiceType != null) { - req.setVoiceType(voiceType.longValue()); - } - if (volume != null) { - req.setVolume(volume); - } - if (codec != null && !codec.isEmpty()) { - req.setCodec(codec); - } else { - req.setCodec("wav"); // 默认wav格式 - } - - // 设置其他默认参数 - req.setModelType(1L); // 默认模型 - req.setPrimaryLanguage(2L); // 中文 - req.setSampleRate(16000L); // 16k采样率 + // 设置通用参数 + setCommonTtsParams(req, speed, voiceType, volume, codec); // 返回的resp是一个TextToVoiceResponse的实例,与请求对象对应 TextToVoiceResponse resp = client.TextToVoice(req); @@ -308,11 +386,13 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { } @Override - public String createLongTextTtsTask(String text, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl) { + @Async + public CompletableFuture createLongTextTtsTask(String normalized, Float speed, Integer voiceType, Float volume, String codec, String callbackUrl) { + log.info("开始异步创建长文本TTS任务,文本长度: {}, voiceType: {}", normalized != null ? normalized.length() : 0, voiceType); + try { - // 将文章内容去除HTML并计算哈希,用于缓存唯一标识 - String normalized = stripHtml(text); - String textHash = String.valueOf(normalized.hashCode()); + // 计算哈希,用于缓存唯一标识 + String textHash = HtmlUtils.calculateStringHash(normalized); // 先检查是否存在生成中的任务(相同文本哈希与音色) LambdaQueryWrapper generatingWrapper = new LambdaQueryWrapper<>(); @@ -323,7 +403,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { AppletTtsCache generating = appletTtsCacheService.getOne(generatingWrapper); if (generating != null && generating.getTaskId() != null) { log.info("已有长文本TTS任务正在生成中,直接返回任务ID: {}", generating.getTaskId()); - return generating.getTaskId(); + return CompletableFuture.completedFuture(generating.getTaskId()); } // 再检查是否已有成功缓存 @@ -335,30 +415,21 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { AppletTtsCache successCache = appletTtsCacheService.getOne(successWrapper); if (successCache != null && successCache.getTaskId() != null) { log.info("长文本TTS已完成,返回任务ID: {}", successCache.getTaskId()); - return successCache.getTaskId(); + return CompletableFuture.completedFuture(successCache.getTaskId()); } + // 应用并发控制,等待获取请求许可 + log.info("开始等待长文本TTS请求许可,textHash: {}", textHash); + waitForLongTextTtsPermit(); + log.info("获得长文本TTS请求许可,开始调用腾讯云API,textHash: {}", textHash); + // 调用腾讯云异步创建任务 - Credential cred = new Credential(secretId, secretKey); - HttpProfile httpProfile = new HttpProfile(); - httpProfile.setEndpoint("tts.tencentcloudapi.com"); - ClientProfile clientProfile = new ClientProfile(); - clientProfile.setHttpProfile(httpProfile); - TtsClient client = new TtsClient(cred, "", clientProfile); + TtsClient client = createTtsClient(); CreateTtsTaskRequest req = new CreateTtsTaskRequest(); - req.setText(text); - if (speed != null) req.setSpeed(speed); - if (voiceType != null) req.setVoiceType(voiceType.longValue()); - if (volume != null) req.setVolume(volume); - if (codec != null && !codec.isEmpty()) { - req.setCodec(codec); - } else { - req.setCodec("wav"); - } - req.setModelType(1L); - req.setPrimaryLanguage(2L); - req.setSampleRate(16000L); + req.setText(normalized); + setCommonTtsParams(req, speed, voiceType, volume, codec); + // 启用时间戳字幕 try { req.getClass().getMethod("setEnableSubtitle", Boolean.class).invoke(req, Boolean.TRUE); @@ -387,7 +458,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { appletTtsCacheService.save(cache); log.info("长文本TTS任务创建成功,taskId: {},textHash: {}", taskId, textHash); - return taskId; + return CompletableFuture.completedFuture(taskId); } catch (Exception e) { log.error("创建长文本TTS任务失败: {}", e.getMessage(), e); throw new JeecgBootException("创建长文本TTS任务失败: " + e.getMessage()); @@ -397,12 +468,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { @Override public AppletTtsCache queryLongTextTtsTaskStatus(String taskId) { try { - Credential cred = new Credential(secretId, secretKey); - HttpProfile httpProfile = new HttpProfile(); - httpProfile.setEndpoint("tts.tencentcloudapi.com"); - ClientProfile clientProfile = new ClientProfile(); - clientProfile.setHttpProfile(httpProfile); - TtsClient client = new TtsClient(cred, "", clientProfile); + TtsClient client = createTtsClient(); DescribeTtsTaskStatusRequest req = new DescribeTtsTaskStatusRequest(); req.setTaskId(taskId); @@ -473,70 +539,10 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { if (text == null || text.trim().isEmpty()) { throw new JeecgBootException("页面内容为空,无法创建TTS任务"); } - try { - // 检查是否存在相同页面与音色的生成中任务 - LambdaQueryWrapper generatingWrapper = new LambdaQueryWrapper<>(); - generatingWrapper.eq(AppletTtsCache::getPageId, pageId) - .eq(AppletTtsCache::getVoiceType, voiceType) - .eq(AppletTtsCache::getState, 0); - AppletTtsCache generating = appletTtsCacheService.getOne(generatingWrapper); - if (generating != null && generating.getTaskId() != null) { - log.info("页面长文本TTS任务生成中,返回任务ID: {}", generating.getTaskId()); - return generating.getTaskId(); - } - - // 检查是否已有成功缓存 - LambdaQueryWrapper successWrapper = new LambdaQueryWrapper<>(); - successWrapper.eq(AppletTtsCache::getPageId, pageId) - .eq(AppletTtsCache::getVoiceType, voiceType) - .eq(AppletTtsCache::getSuccess, "Y") - .eq(AppletTtsCache::getState, 1); - AppletTtsCache successCache = appletTtsCacheService.getOne(successWrapper); - if (successCache != null && successCache.getTaskId() != null) { - log.info("页面长文本TTS已完成,返回任务ID: {}", successCache.getTaskId()); - return successCache.getTaskId(); - } - - // 创建腾讯云任务 - Credential cred = new Credential(secretId, secretKey); - HttpProfile httpProfile = new HttpProfile(); - httpProfile.setEndpoint("tts.tencentcloudapi.com"); - ClientProfile clientProfile = new ClientProfile(); - clientProfile.setHttpProfile(httpProfile); - TtsClient client = new TtsClient(cred, "", clientProfile); - - CreateTtsTaskRequest req = new CreateTtsTaskRequest(); - req.setText(text); - if (voiceType != null) req.setVoiceType(voiceType.longValue()); - req.setCodec("wav"); - req.setModelType(1L); - req.setPrimaryLanguage(2L); - req.setSampleRate(16000L); - req.setEnableSubtitle(true); - req.setCallbackUrl(TtscallbackUrl); - - CreateTtsTaskResponse resp = client.CreateTtsTask(req); - String taskId = resp.getData() != null ? resp.getData().getTaskId() : null; - if (taskId == null || taskId.isEmpty()) { - throw new JeecgBootException("创建页面长文本TTS任务失败,未返回任务ID"); - } - - // 写入缓存,标记生成中 - AppletTtsCache cache = new AppletTtsCache(); - cache.setTaskId(taskId); - cache.setText(text); - cache.setVoiceType(voiceType); - cache.setPageId(pageId); - cache.setSuccess("N"); - cache.setState(0); - cache.setCreateTime(new java.util.Date()); - appletTtsCacheService.save(cache); - - log.info("页面长文本TTS任务创建成功,taskId: {}", taskId); - return taskId; + return createLongTextTtsTask(text, null, voiceType, null, null, null).get(); } catch (Exception e) { - log.error("创建页面长文本TTS任务失败: {}", e.getMessage(), e); + log.error("创建页面长文本TTS任务失败,pageId: {}, voiceType: {}, error: {}", pageId, voiceType, e.getMessage(), e); throw new JeecgBootException("创建页面长文本TTS任务失败: " + e.getMessage()); } } @@ -546,32 +552,6 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { */ @Override public String extractTextByPageId(String pageId) { - // 旧实现(按标题、content纯文本及重点单词拼接)已注释: - // AppletCoursePage page = appletCoursePageService.getById(pageId); - // if (page == null) { - // throw new JeecgBootException("未找到页面: " + pageId); - // } - // StringBuilder oldSb = new StringBuilder(); - // if (page.getTitle() != null) { - // oldSb.append(page.getTitle()).append("\n"); - // } - // if (page.getContent() != null) { - // oldSb.append(stripHtml(page.getContent())).append("\n"); - // } - // List oldWords = appletCoursePageWordService - // .lambdaQuery() - // .eq(AppletCoursePageWord::getPageId, pageId) - // .list(); - // if (oldWords != null && !oldWords.isEmpty()) { - // for (AppletCoursePageWord w : oldWords) { - // if (w.getWord() != null && !w.getWord().isEmpty()) { - // oldSb.append(w.getWord()).append(". "); - // } - // } - // } - // return oldSb.toString().trim(); - - // 新实现:按页面编辑所定义的 JSON 结构解析页面内容,仅提取文本组件 AppletCoursePage page = appletCoursePageService.getById(pageId); if (page == null) { throw new JeecgBootException("未找到页面: " + pageId); @@ -612,17 +592,6 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { } } - private String stripHtml(String html) { - try { - String noTag = html.replaceAll("<[^>]*>", " "); - noTag = noTag.replace(" ", " "); - return noTag.replaceAll("\\s+", " ").trim(); - } catch (Exception e) { - return html; - } - } - - @Override public boolean handleTtsCallback(String taskId, String audioBase64, Integer sampleRate, String codec, boolean success, String message) { try { @@ -679,8 +648,7 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { public Map selectMapByHtml(String content) { HashMap map = new HashMap<>(); - int i = stripHtml(content).hashCode(); - String textHash = String.valueOf(i); + String textHash = HtmlUtils.calculateStringHash(HtmlUtils.stripHtml(content)); List list = appletTtsTimbreService .lambdaQuery() @@ -745,8 +713,10 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { } @Override - public void generateLongTextForArticleContentAllTimbres(String content) { + public String generateLongTextForArticleContentAllTimbres(String content) { try { + String value = HtmlUtils.stripHtml(content); + String contentHashcode = HtmlUtils.calculateStringHash(value); List timbres = appletTtsTimbreService .lambdaQuery() .eq(AppletTtsTimbre::getStatus, "Y") @@ -755,14 +725,49 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { String callbackUrl = TtscallbackUrl; for (AppletTtsTimbre timbre : timbres) { try { - createLongTextTtsTask(content, null, timbre.getVoiceType(), null, "wav", callbackUrl); + // 异步调用,不等待结果 + createLongTextTtsTask(value, null, timbre.getVoiceType(), null, "wav", callbackUrl); } catch (Exception ex) { log.warn("创建文章内容长文本TTS任务失败 voiceType {}: {}", timbre.getVoiceType(), ex.getMessage()); } } + return contentHashcode; } catch (Exception e) { log.error("遍历音色创建文章长文本TTS任务失败: {}", e.getMessage(), e); } + return null; + } + + @Override + public String generateLongTextForCoursePageContentAllTimbres(String pageId) { + try { + // 提取页面文本内容 + String content = extractTextByPageId(pageId); + + if (content == null || content.trim().isEmpty()) { + log.warn("课程页面内容为空,无法生成TTS,pageId: {}", pageId); + return null; + } + String contentHashcode = HtmlUtils.calculateStringHash(content); + List timbres = appletTtsTimbreService + .lambdaQuery() + .eq(AppletTtsTimbre::getStatus, "Y") + .select(AppletTtsTimbre::getVoiceType) + .list(); + String callbackUrl = TtscallbackUrl; + for (AppletTtsTimbre timbre : timbres) { + try { + // 异步调用,不等待结果 + createLongTextTtsTask(content, null, timbre.getVoiceType(), null, "wav", callbackUrl); + } catch (Exception ex) { + log.warn("创建课程页面内容长文本TTS任务失败 pageId {}, voiceType {}: {}", pageId, timbre.getVoiceType(), ex.getMessage()); + } + } + return contentHashcode; + } catch (Exception e) { + log.error("遍历音色创建课程页面长文本TTS任务失败,pageId {}: {}", pageId, e.getMessage(), e); + } + return null; } /** @@ -828,4 +833,45 @@ public class AppletApiTTServiceImpl implements AppletApiTTService { return duration; } } + + @Override + public Map getAudioBuildStatus(String contentHashcode) { + Map result = new HashMap<>(); + + try { + if (contentHashcode == null || contentHashcode.isEmpty()) { + result.put("total", 0L); + result.put("completed", 0L); + return result; + } + + // 获取所有启用的音色数量作为总数 + List enabledTimbres = appletTtsTimbreService + .lambdaQuery() + .eq(AppletTtsTimbre::getStatus, "Y") + .list(); + long total = enabledTimbres.size(); + + // 查询已完成的TTS任务数量 + long completed = appletTtsCacheService + .lambdaQuery() + .eq(AppletTtsCache::getText, contentHashcode) +// .eq(AppletTtsCache::getSuccess, "Y") +// .eq(AppletTtsCache::getState, 1) + .count(); + + result.put("total", total); + result.put("completed", completed); + + log.debug("获取音频构建状态成功,contentHashcode: {}, total: {}, completed: {}", + contentHashcode, total, completed); + + } catch (Exception e) { + log.error("获取音频构建状态失败,contentHashcode: {}, error: {}", contentHashcode, e.getMessage(), e); + result.put("total", 0L); + result.put("completed", 0L); + } + + return result; + } } \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/util/HtmlUtils.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/util/HtmlUtils.java new file mode 100644 index 0000000..286d339 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/applet/util/HtmlUtils.java @@ -0,0 +1,244 @@ +package org.jeecg.modules.applet.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.HashMap; +import java.util.Map; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.nio.charset.StandardCharsets; + +/** + * HTML工具类 + * 提供HTML转文本、清理HTML标签等功能 + * + * @author JeecgBoot + */ +public class HtmlUtils { + + /** + * HTML实体转义映射表 + */ + private static final Map HTML_ENTITIES = new HashMap<>(); + + static { + // 常见的HTML实体转义 + HTML_ENTITIES.put(" ", " "); + HTML_ENTITIES.put("&", "&"); + HTML_ENTITIES.put("<", "<"); + HTML_ENTITIES.put(">", ">"); + HTML_ENTITIES.put(""", "\""); + HTML_ENTITIES.put("'", "'"); + HTML_ENTITIES.put("'", "'"); + HTML_ENTITIES.put("¢", "¢"); + HTML_ENTITIES.put("£", "£"); + HTML_ENTITIES.put("¥", "¥"); + HTML_ENTITIES.put("€", "€"); + HTML_ENTITIES.put("©", "©"); + HTML_ENTITIES.put("®", "®"); + HTML_ENTITIES.put("™", "™"); + HTML_ENTITIES.put("×", "×"); + HTML_ENTITIES.put("÷", "÷"); + HTML_ENTITIES.put("…", "…"); + HTML_ENTITIES.put("—", "—"); + HTML_ENTITIES.put("–", "–"); + HTML_ENTITIES.put("‘", "'"); + HTML_ENTITIES.put("’", "'"); + } + + /** + * 计算字符串的安全哈希值 + * 使用SHA-256算法,避免哈希冲突问题 + * + * @param input 输入字符串 + * @return 哈希值的十六进制字符串表示 + */ + public static String calculateStringHash(String input) { + if (input == null) { + return ""; + } + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + + // 将字节数组转换为十六进制字符串 + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + // 如果SHA-256不可用,回退到使用字符串长度和简单哈希的组合 + return String.valueOf(input.length()) + "_" + Math.abs(input.hashCode()); + } + } + + /** + * 将HTML转换为纯文本 + * 支持过滤script、style标签和HTML实体转义 + * + * @param htmlStr HTML字符串 + * @return 转换后的纯文本 + */ + public static String html2Text(String htmlStr) { + if (htmlStr == null || htmlStr.trim().isEmpty()) { + return ""; + } + + String textStr = htmlStr; + Pattern p_script, p_style, p_html; + Matcher m_script, m_style, m_html; + + try { + // 过滤script标签及其内容 + String regEx_script = "<[\\s]*?script[^>]*?>[\\s\\S]*?<[\\s]*?/script[\\s]*?>"; + p_script = Pattern.compile(regEx_script, Pattern.CASE_INSENSITIVE); + m_script = p_script.matcher(textStr); + textStr = m_script.replaceAll(""); + + // 过滤style标签及其内容 + String regEx_style = "<[\\s]*?style[^>]*?>[\\s\\S]*?<[\\s]*?/style[\\s]*?>"; + p_style = Pattern.compile(regEx_style, Pattern.CASE_INSENSITIVE); + m_style = p_style.matcher(textStr); + textStr = m_style.replaceAll(""); + + // 过滤所有HTML标签 + String regEx_html = "<[^>]+>"; + p_html = Pattern.compile(regEx_html, Pattern.CASE_INSENSITIVE); + m_html = p_html.matcher(textStr); + textStr = m_html.replaceAll(""); + + // 处理HTML实体转义 + textStr = decodeHtmlEntities(textStr); + + // 处理数字实体转义(如   ​ 等) + textStr = decodeNumericEntities(textStr); + + // 清理多余的空白字符 + textStr = textStr.replaceAll("\\s+", " ").trim(); + + } catch (Exception e) { + System.err.println("Html2Text转换异常: " + e.getMessage()); + // 发生异常时返回原始字符串 + return htmlStr; + } + + return textStr; + } + + /** + * 解码HTML实体转义 + * + * @param text 包含HTML实体的文本 + * @return 解码后的文本 + */ + private static String decodeHtmlEntities(String text) { + String result = text; + for (Map.Entry entry : HTML_ENTITIES.entrySet()) { + result = result.replace(entry.getKey(), entry.getValue()); + } + return result; + } + + /** + * 解码数字HTML实体转义(如   ​ 等) + * + * @param text 包含数字HTML实体的文本 + * @return 解码后的文本 + */ + private static String decodeNumericEntities(String text) { + // 匹配十进制数字实体 &#数字; + Pattern decimalPattern = Pattern.compile("&#(\\d+);"); + Matcher decimalMatcher = decimalPattern.matcher(text); + StringBuffer sb = new StringBuffer(); + + while (decimalMatcher.find()) { + try { + int codePoint = Integer.parseInt(decimalMatcher.group(1)); + String replacement = ""; + + // 处理常见的不可见字符或空白字符 + if (codePoint == 160 || codePoint == 8203 || codePoint == 8204 || codePoint == 8205) { + replacement = " "; // 替换为普通空格 + } else if (codePoint >= 32 && codePoint <= 126) { + // ASCII可打印字符 + replacement = String.valueOf((char) codePoint); + } else if (codePoint > 126) { + // Unicode字符 + replacement = new String(Character.toChars(codePoint)); + } + + decimalMatcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } catch (Exception e) { + // 如果转换失败,保留原始内容 + decimalMatcher.appendReplacement(sb, Matcher.quoteReplacement(decimalMatcher.group(0))); + } + } + decimalMatcher.appendTail(sb); + + text = sb.toString(); + + // 匹配十六进制数字实体 &#x数字; + Pattern hexPattern = Pattern.compile("&#x([0-9a-fA-F]+);"); + Matcher hexMatcher = hexPattern.matcher(text); + sb = new StringBuffer(); + + while (hexMatcher.find()) { + try { + int codePoint = Integer.parseInt(hexMatcher.group(1), 16); + String replacement = ""; + + // 处理常见的不可见字符或空白字符 + if (codePoint == 160 || codePoint == 8203 || codePoint == 8204 || codePoint == 8205) { + replacement = " "; // 替换为普通空格 + } else if (codePoint >= 32 && codePoint <= 126) { + // ASCII可打印字符 + replacement = String.valueOf((char) codePoint); + } else if (codePoint > 126) { + // Unicode字符 + replacement = new String(Character.toChars(codePoint)); + } + + hexMatcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } catch (Exception e) { + // 如果转换失败,保留原始内容 + hexMatcher.appendReplacement(sb, Matcher.quoteReplacement(hexMatcher.group(0))); + } + } + hexMatcher.appendTail(sb); + + return sb.toString(); + } + + /** + * 简单的HTML标签清理方法(兼容原有的stripHtml方法) + * + * @param html HTML字符串 + * @return 清理后的文本 + */ + public static String stripHtml(String html) { + if (html == null || html.trim().isEmpty()) { + return ""; + } + + try { + // 移除HTML标签 + String noTag = html.replaceAll("<[^>]*>", ""); + // 处理常见的HTML实体 + noTag = noTag.replace(" ", " "); + noTag = noTag.replace("&", "&"); + noTag = noTag.replace("<", "<"); + noTag = noTag.replace(">", ">"); + noTag = noTag.replace(""", "\""); + // 清理多余空白 + return noTag.replaceAll("\\s+", " ").trim(); + } catch (Exception e) { + return html; + } + } +} \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/controller/AppletArticleController.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/controller/AppletArticleController.java index b9288aa..09fa0da 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/controller/AppletArticleController.java +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/controller/AppletArticleController.java @@ -77,6 +77,11 @@ public class AppletArticleController extends JeecgController queryWrapper = QueryGenerator.initQueryWrapper(appletArticle, req.getParameterMap()); Page page = new Page(pageNo, pageSize); IPage pageList = appletArticleService.page(page, queryWrapper); + + for (AppletArticle record : page.getRecords()) { + record.setAudioStatus(appletApiTTService.getAudioBuildStatus(record.getContentHashcode())); + } + return Result.OK(pageList); } @@ -94,14 +99,16 @@ public class AppletArticleController extends JeecgController edit(@RequestBody AppletArticle appletArticle) { - appletArticleService.updateById(appletArticle); - // 触发长文本TTS生成:遍历所有启用音色,对content进行转换 - try { - String content = appletArticle.getContent(); - if (content != null && !content.trim().isEmpty()) { - appletApiTTService.generateLongTextForArticleContentAllTimbres(content); - } - } catch (Exception e) { - log.warn("文章编辑后触发长文本TTS失败: {}", e.getMessage()); - } - +// try { +// String content = appletArticle.getContent(); +// if (content != null && !content.trim().isEmpty()) { +// String code = appletApiTTService.generateLongTextForArticleContentAllTimbres(content); +// appletArticle.setContentHashcode(code); +// } +// } catch (Exception e) { +// log.warn("文章编辑后触发长文本TTS失败: {}", e.getMessage()); +// } + appletArticleService.updateById(appletArticle); return Result.OK("编辑成功!"); } @@ -204,4 +210,38 @@ public class AppletArticleController extends JeecgController generateAudio(@RequestParam(name="id",required=true) String id) { + try { + AppletArticle appletArticle = appletArticleService.getById(id); + if (appletArticle == null) { + return Result.error("文章不存在"); + } + + String content = appletArticle.getContent(); + if (content == null || content.trim().isEmpty()) { + return Result.error("文章内容为空,无法生成音频"); + } + + // 触发长文本TTS生成:遍历所有启用音色,对content进行转换 + String code = appletApiTTService.generateLongTextForArticleContentAllTimbres(content); + appletArticle.setContentHashcode(code); + appletArticleService.updateById(appletArticle); + + return Result.OK("音频转换已开始,请稍后查看状态"); + } catch (Exception e) { + log.error("触发音频转换失败: {}", e.getMessage(), e); + return Result.error("音频转换失败: " + e.getMessage()); + } + } + } diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/entity/AppletArticle.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/entity/AppletArticle.java index 777c378..99a5910 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/entity/AppletArticle.java +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletArticle/entity/AppletArticle.java @@ -71,7 +71,14 @@ public class AppletArticle implements Serializable { @Excel(name = "内容", width = 15) @Schema(description = "内容") private java.lang.String content; + /**内容哈希码*/ + @Excel(name = "内容哈希码", width = 15) + @Schema(description = "内容哈希码") + private java.lang.String contentHashcode; @TableField(exist = false) private Map audios; + + @TableField(exist = false) + private Map audioStatus; } diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/controller/AppletCoursePageController.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/controller/AppletCoursePageController.java index 1cb0139..9e59001 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/controller/AppletCoursePageController.java +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/controller/AppletCoursePageController.java @@ -16,6 +16,7 @@ import org.jeecg.common.system.query.QueryRuleEnum; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.demo.appletCoursePage.entity.AppletCoursePage; import org.jeecg.modules.demo.appletCoursePage.service.IAppletCoursePageService; +import org.jeecg.modules.applet.service.AppletApiTTService; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; @@ -52,6 +53,9 @@ public class AppletCoursePageController extends JeecgController add(@RequestBody AppletCoursePage appletCoursePage) { appletCoursePageService.save(appletCoursePage); + + // 生成TTS音频 +// try { +// String code = appletApiTTService.generateLongTextForCoursePageContentAllTimbres(appletCoursePage.getId()); +// appletCoursePage.setContentHashcode(code); +// appletCoursePageService.updateById(appletCoursePage); +// } catch (Exception e) { +// log.warn("生成课程页面TTS音频失败,pageId: {}, error: {}", appletCoursePage.getId(), e.getMessage()); +// } + return Result.OK(appletCoursePage); } @@ -117,6 +131,17 @@ public class AppletCoursePageController extends JeecgController edit(@RequestBody AppletCoursePage appletCoursePage) { appletCoursePageService.updateById(appletCoursePage); + + // 生成TTS音频 +// try { +// String code = appletApiTTService.generateLongTextForCoursePageContentAllTimbres(appletCoursePage.getId()); +// appletCoursePage.setContentHashcode(code); +// } catch (Exception e) { +// log.warn("生成课程页面TTS音频失败,pageId: {}, error: {}", appletCoursePage.getId(), e.getMessage()); +// } +// +// appletCoursePageService.updateById(appletCoursePage); + return Result.OK("编辑成功!"); } @@ -167,6 +192,24 @@ public class AppletCoursePageController extends JeecgController> getTtsStatus(@RequestParam(name="pageId",required=true) String pageId) { +// try { +// Map statusInfo = appletApiTTService.getCoursePageTtsStatus(pageId); +// return Result.OK(statusInfo); +// } catch (Exception e) { +// log.error("获取课程页面音频构建状态失败: {}", e.getMessage(), e); +// return Result.error("获取音频构建状态失败"); +// } +// } + /** * 导出excel * diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/entity/AppletCoursePage.java b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/entity/AppletCoursePage.java index bc696ee..28e8229 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/entity/AppletCoursePage.java +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/entity/AppletCoursePage.java @@ -85,6 +85,10 @@ public class AppletCoursePage implements Serializable { @Excel(name = "上架", width = 15,replace = {"是_Y","否_N"} ) @Schema(description = "上架") private java.lang.String status; + /**内容哈希码*/ + @Excel(name = "内容哈希码", width = 15) + @Schema(description = "内容哈希码") + private java.lang.String contentHashcode; @TableField(exist = false) private List words; diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.api.ts b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.api.ts index 7eb3657..0da9a55 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.api.ts +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.api.ts @@ -11,6 +11,7 @@ enum Api { deleteBatch = '/appletCoursePage/appletCoursePage/deleteBatch', importExcel = '/appletCoursePage/appletCoursePage/importExcel', exportXls = '/appletCoursePage/appletCoursePage/exportXls', + getAudioBuildStatus = '/appletApi/tts/getAudioBuildStatus', } /** * 导出api @@ -62,3 +63,11 @@ export const saveOrUpdate = (params, isUpdate) => { let url = isUpdate ? Api.edit : Api.save; return defHttp.post({url: url, params}); } + +/** + * 获取音频构建状态 + * @param contentHashcode + */ +export const getAudioBuildStatus = (contentHashcode) => { + return defHttp.get({url: Api.getAudioBuildStatus, params: { contentHashcode }}); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.data.ts b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.data.ts index ef56170..5210c80 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.data.ts +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePage.data.ts @@ -3,6 +3,7 @@ import {FormSchema} from '/@/components/Table'; import { rules} from '/@/utils/helper/validator'; import { render } from '/@/utils/common/renderUtils'; import { getWeekMonthQuarterYear } from '/@/utils'; +import { getAudioBuildStatus } from './AppletCoursePage.api'; //列表数据 export const columns: BasicColumn[] = [ { @@ -41,6 +42,23 @@ export const columns: BasicColumn[] = [ return render.renderSwitch(text, [{text:'是',value:'Y'},{text:'否',value:'N'}]) }, }, + { + title: '音频构建状态', + align: "center", + dataIndex: 'audioStatus', + customRender: async ({ record }) => { + if (!record.contentHashcode) return '未开始'; + try { + const result = await getAudioBuildStatus(record.contentHashcode); + const { total, completed } = result; + if (completed === 0) return '未开始'; + if (completed === total) return '已完成'; + return `${completed}/${total}`; + } catch (error) { + return '未开始'; + } + } + }, ]; //查询数据 export const searchFormSchema: FormSchema[] = [ diff --git a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePageList.vue b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePageList.vue index 33bae94..99e82e3 100644 --- a/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePageList.vue +++ b/jeecg-boot/jeecg-boot-module/jeecgboot-boot-applet/src/main/java/org/jeecg/modules/demo/appletCoursePage/vue3/AppletCoursePageList.vue @@ -30,6 +30,12 @@ @@ -197,6 +203,28 @@ + /** + * 获取音频状态文本 + */ + function getAudioStatusText(status) { + if (!status) return '未开始'; + const { total, completed } = status; + if (completed === 0) return '未开始'; + if (completed === total) return '已完成'; + return `${completed}/${total}`; + } + + /** + * 获取音频状态颜色 + */ + function getAudioStatusColor(status) { + if (!status) return '#999'; + const { total, completed } = status; + if (completed === 0) return '#999'; + if (completed === total) return '#52c41a'; + return '#1890ff'; + } + \ No newline at end of file +:deep(.ant-picker), +:deep(.ant-input-number) { + width: 100%; +} +