Просмотр исходного кода

feat(articles): 发布后异步预生成朗读音频 + 正文变更作废旧音频

- 文章创建/发布/审核通过/家庭可见发布后,事务提交后异步生成 TTS 音频,不阻塞保存
- 文章更新时:朗读文本变化,或朗读文本为空时正文变化,均作废旧音频并异步重新生成
- 生成中并发保护:发布预生成与用户点听文章不重复合成,点播短暂等待缓存
- 新增单元测试:发布预生成 3 例 + 作废规则 8 例
OpenCode 5 дней назад
Родитель
Сommit
c575a3a5b7

+ 1 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -238,12 +238,9 @@ public class AdminArticleController {
         } else if (articleTypeVal instanceof String) {
             article.setArticleType((String) articleTypeVal);
         }
-        // 朗读文本(TTS 专用;留空则从正文生成)。变更时作废已生成音频,下次「听文章」重新合成
+        // 朗读文本(TTS 专用;留空则从正文生成)。是否作废旧音频由 ArticleService.update 统一判断
         if (body.get("audioText") != null) {
             article.setAudioText(body.get("audioText").toString());
-            if (existing != null && !Objects.equals(existing.getAudioText(), article.getAudioText())) {
-                article.setAudioUrl("");
-            }
         }
         articleService.update(article);
 

+ 76 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleAudioService.java

@@ -14,6 +14,8 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -21,7 +23,11 @@ import java.util.stream.Collectors;
 /**
  * 文章 AI 朗读音频服务。
  *
- * 按需生成:用户首次点击「听文章」时调用 {@link #getOrGenerateAudio(Long)},
+ * 两种入口:
+ * <ul>
+ *   <li>发布预生成:文章发布/审核通过后由 {@link #generateIfMissing(Long)} 异步生成;</li>
+ *   <li>按需生成:用户首次点击「听文章」时调用 {@link #getOrGenerateAudio(Long)}。</li>
+ * </ul>
  * 已生成(articles.audio_url 非空)直接返回缓存;未生成则剥离 HTML → 分段 →
  * 腾讯云 TTS 逐段合成 → 存储 → 回写 audio_url/audio_text → 返回。
  */
@@ -43,6 +49,39 @@ public class ArticleAudioService {
     @Resource
     private TencentTtsService tencentTtsService;
 
+    /** 正在生成中的文章,避免发布预生成与用户点播重复合成、浪费 TTS 额度 */
+    private final Set<Long> generating = ConcurrentHashMap.newKeySet();
+
+    /**
+     * 发布后预生成:已有缓存、TTS 未配置或已有生成任务时直接跳过。
+     * 供文章发布/审核通过后异步调用,失败不影响发布。
+     */
+    public void generateIfMissing(Long articleId) {
+        if (articleId == null) {
+            return;
+        }
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) {
+            return;
+        }
+        String cached = article.getAudioUrl();
+        if (cached != null && !cached.trim().isEmpty()) {
+            return;
+        }
+        if (!tencentTtsService.isConfigured()) {
+            log.warn("文章语音预生成跳过(TTS 未配置): articleId={}", articleId);
+            return;
+        }
+        if (!generating.add(articleId)) {
+            return;
+        }
+        try {
+            doGetOrGenerateAudio(articleId);
+        } finally {
+            generating.remove(articleId);
+        }
+    }
+
     /**
      * 获取文章的朗读音频。已缓存则直接返回;否则生成并缓存。
      *
@@ -50,6 +89,22 @@ public class ArticleAudioService {
      * @return audioUrls(可能多段)、audioText、durationSeconds(预计)、minSeconds(达标阈值)
      */
     public Map<String, Object> getOrGenerateAudio(Long articleId) {
+        if (!generating.add(articleId)) {
+            // 发布预生成进行中:短暂等待缓存,避免重复合成
+            Map<String, Object> cachedResult = waitForCache(articleId, 10_000L);
+            if (cachedResult != null) {
+                return cachedResult;
+            }
+            throw new IllegalStateException("语音正在生成中,请稍后再试");
+        }
+        try {
+            return doGetOrGenerateAudio(articleId);
+        } finally {
+            generating.remove(articleId);
+        }
+    }
+
+    private Map<String, Object> doGetOrGenerateAudio(Long articleId) {
         Article article = articleMapper.selectById(articleId);
         if (article == null) {
             throw new IllegalArgumentException("文章不存在");
@@ -108,6 +163,26 @@ public class ArticleAudioService {
         return buildResult(urls, plainText, article.getWordCount());
     }
 
+    /**
+     * 等待并读取已生成的音频缓存(发布预生成与用户点播并发时的兜底)。
+     */
+    private Map<String, Object> waitForCache(Long articleId, long timeoutMillis) {
+        long deadline = System.currentTimeMillis() + timeoutMillis;
+        while (System.currentTimeMillis() < deadline) {
+            Article article = articleMapper.selectById(articleId);
+            if (article != null && article.getAudioUrl() != null && !article.getAudioUrl().trim().isEmpty()) {
+                return buildResult(parseUrls(article.getAudioUrl()), article.getAudioText(), article.getWordCount());
+            }
+            try {
+                Thread.sleep(300L);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return null;
+            }
+        }
+        return null;
+    }
+
     /**
      * 回写音频缓存到 articles 表。
      */

+ 97 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -31,6 +31,8 @@ import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 import javax.annotation.Resource;
 import java.util.*;
 import java.util.stream.Collectors;
@@ -95,6 +97,9 @@ public class ArticleService {
     @Resource(name = "taskExecutor")
     private ThreadPoolTaskExecutor taskExecutor;
 
+    @Resource
+    private ArticleAudioService articleAudioService;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
@@ -753,6 +758,9 @@ public class ArticleService {
         }
         articleMapper.insert(article);
 
+        // 发布后异步预生成朗读音频(不阻塞创建响应)
+        ensureArticleAudioAfterPublish(article.getId());
+
         // AI auto-tagging (异步,不阻塞 HTTP 响应)
         final Long articleId = article.getId();
         final String content = article.getContent();
@@ -771,17 +779,29 @@ public class ArticleService {
 
     @Transactional
     public void update(Article article) {
+        Article existing = article.getId() != null ? articleMapper.selectById(article.getId()) : null;
         // Recalculate wordCount if content changed
         boolean contentChanged = false;
         if (article.getContent() != null) {
-            Article existing = articleMapper.selectById(article.getId());
             String oldContent = existing != null ? existing.getContent() : null;
             article.setWordCount(calculateWordCount(article.getContent()));
             contentChanged = oldContent == null || !oldContent.equals(article.getContent());
         }
+        // 朗读源变化(朗读文本变化,或朗读文本为空时正文变化)→ 作废旧音频,下次「听文章」重新合成
+        if (existing != null) {
+            String newAudioText = article.getAudioText() != null ? article.getAudioText() : existing.getAudioText();
+            String newContent = article.getContent() != null ? article.getContent() : existing.getContent();
+            if (shouldInvalidateAudio(existing.getContent(), newContent,
+                    existing.getAudioText(), newAudioText)) {
+                article.setAudioUrl("");
+            }
+        }
         article.setUpdatedAt(new Date());
         articleMapper.updateById(article);
 
+        // 已发布文章:朗读源变化导致音频被作废后,异步重新生成
+        ensureArticleAudioAfterPublish(article.getId());
+
         // AI auto-tagging only when content changed (异步,不阻塞 HTTP 响应)
         if (contentChanged && article.getContent() != null) {
             final Long articleId = article.getId();
@@ -801,6 +821,34 @@ public class ArticleService {
         }
     }
 
+    /**
+     * 判断文章更新是否应作废已生成音频。
+     * 朗读文本优先;朗读文本为空时以正文为朗读源,正文变化即作废。
+     */
+    static boolean shouldInvalidateAudio(String oldContent, String newContent,
+                                         String oldAudioText, String newAudioText) {
+        String oldText = trimToNull(oldAudioText);
+        String newText = trimToNull(newAudioText);
+        if (!Objects.equals(oldText, newText)) {
+            return true;
+        }
+        if (newText != null) {
+            return false; // 有朗读文本时,正文变化不影响音频
+        }
+        if (newContent == null) {
+            return false;
+        }
+        return !Objects.equals(oldContent, newContent);
+    }
+
+    private static String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+
     @Transactional
     public void delete(Long id) {
         articleMapper.deleteById(id);
@@ -819,6 +867,8 @@ public class ArticleService {
         // 发布时若标签为空则异步自动生成
         if ("published".equals(status)) {
             ensureAutoTagsIfEmpty(id);
+            // 发布后异步预生成朗读音频
+            ensureArticleAudioAfterPublish(id);
         }
     }
 
@@ -1155,6 +1205,8 @@ public class ArticleService {
         articleMapper.insert(article);
         // 家庭可见直接发布:发布时若标签为空则异步自动生成(公开文章待审核,内部守卫跳过)
         ensureAutoTagsIfEmpty(article.getId());
+        // 家庭可见直接发布:异步预生成朗读音频(公开文章待审核,内部守卫跳过)
+        ensureArticleAudioAfterPublish(article.getId());
         return article;
     }
 
@@ -1233,6 +1285,50 @@ public class ArticleService {
         // 审核通过发布:若标签为空则异步自动生成
         if ("approved".equals(auditStatus)) {
             ensureAutoTagsIfEmpty(articleId);
+            // 审核通过发布:异步预生成朗读音频
+            ensureArticleAudioAfterPublish(articleId);
+        }
+    }
+
+    /**
+     * 发布路径语音预生成入口:仅已发布且尚无音频时,事务提交后异步生成。
+     * 失败仅记录 warn,不阻塞发布流程。
+     */
+    private void ensureArticleAudioAfterPublish(Long articleId) {
+        if (articleId == null) {
+            return;
+        }
+        Article article = articleMapper.selectById(articleId);
+        if (article == null || !"published".equals(article.getStatus())) {
+            return;
+        }
+        if (article.getAudioUrl() != null && !article.getAudioUrl().trim().isEmpty()) {
+            return;
+        }
+        final Long id = articleId;
+        runAfterCommit(() -> {
+            try {
+                articleAudioService.generateIfMissing(id);
+            } catch (Exception e) {
+                log.warn("文章语音自动生成失败(不影响文章): articleId={}, error={}", id, e.getMessage());
+            }
+        });
+    }
+
+    /**
+     * 在当前事务提交后执行任务;无事务时立即提交到线程池。
+     * 避免异步任务读到未提交的文章数据。
+     */
+    private void runAfterCommit(Runnable task) {
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    taskExecutor.execute(task);
+                }
+            });
+        } else {
+            taskExecutor.execute(task);
         }
     }
 

+ 94 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ArticleAudioServiceAutoGenTest.java

@@ -0,0 +1,94 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.service.api.StorageServiceInterface;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 发布后预生成入口:已有缓存/未配置 TTS 时跳过,缺失时合成并回写。
+ */
+@ExtendWith(MockitoExtension.class)
+class ArticleAudioServiceAutoGenTest {
+
+    @Mock
+    private ArticleMapper articleMapper;
+
+    @Mock
+    private StorageServiceInterface storageService;
+
+    @Mock
+    private TencentTtsService tencentTtsService;
+
+    @InjectMocks
+    private ArticleAudioService articleAudioService;
+
+    @BeforeAll
+    static void initTableInfo() {
+        // LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据,纯单元测试需手动初始化
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Article.class);
+    }
+
+    @Test
+    void skipsWhenCached() {
+        Article article = new Article();
+        article.setId(1L);
+        article.setAudioUrl("https://cdn.example.com/a.mp3");
+        when(articleMapper.selectById(1L)).thenReturn(article);
+
+        articleAudioService.generateIfMissing(1L);
+
+        verifyNoInteractions(tencentTtsService);
+        verify(articleMapper, never()).update(any(), any());
+    }
+
+    @Test
+    void skipsWhenTtsNotConfigured() {
+        Article article = new Article();
+        article.setId(2L);
+        when(articleMapper.selectById(2L)).thenReturn(article);
+        when(tencentTtsService.isConfigured()).thenReturn(false);
+
+        articleAudioService.generateIfMissing(2L);
+
+        verify(tencentTtsService, never()).synthesize(anyString());
+        verify(articleMapper, never()).update(any(), any());
+    }
+
+    @Test
+    void generatesAndCachesWhenMissing() {
+        Article article = new Article();
+        article.setId(3L);
+        article.setContent("<p>你好世界。</p>");
+        article.setWordCount(5);
+        when(articleMapper.selectById(3L)).thenReturn(article);
+        when(tencentTtsService.isConfigured()).thenReturn(true);
+        when(tencentTtsService.synthesize(anyString())).thenReturn(new byte[]{1, 2});
+        when(storageService.storeBytes(any(byte[].class), eq("articles-audio"),
+                anyString(), eq("audio/mpeg"))).thenReturn("https://cdn.example.com/3.mp3");
+
+        articleAudioService.generateIfMissing(3L);
+
+        verify(tencentTtsService).synthesize("你好世界。");
+        verify(storageService).storeBytes(any(byte[].class), eq("articles-audio"),
+                eq("article_3_0.mp3"), eq("audio/mpeg"));
+        verify(articleMapper).update(isNull(), any());
+    }
+}

+ 54 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ArticleServiceAudioInvalidationTest.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 验证文章更新时旧音频作废规则:
+ * 朗读文本优先;朗读文本为空时以正文为朗读源。
+ */
+class ArticleServiceAudioInvalidationTest {
+
+    @Test
+    void invalidatesWhenAudioTextChanged() {
+        assertTrue(ArticleService.shouldInvalidateAudio("<p>正文</p>", "<p>正文</p>", "旧文本", "新文本"));
+    }
+
+    @Test
+    void invalidatesWhenAudioTextCleared() {
+        assertTrue(ArticleService.shouldInvalidateAudio("<p>正文</p>", "<p>正文</p>", "旧文本", ""));
+    }
+
+    @Test
+    void invalidatesWhenAudioTextAdded() {
+        assertTrue(ArticleService.shouldInvalidateAudio("<p>正文</p>", "<p>正文</p>", "", "新文本"));
+    }
+
+    @Test
+    void ignoresWhitespaceOnlyAudioTextDiff() {
+        assertFalse(ArticleService.shouldInvalidateAudio("<p>正文</p>", "<p>正文</p>", "同一文本", "同一文本 "));
+    }
+
+    @Test
+    void invalidatesWhenContentChangedAndAudioTextBlank() {
+        assertTrue(ArticleService.shouldInvalidateAudio("<p>旧正文</p>", "<p>新正文</p>", "", ""));
+        assertTrue(ArticleService.shouldInvalidateAudio("<p>旧正文</p>", "<p>新正文</p>", null, null));
+    }
+
+    @Test
+    void keepsAudioWhenContentChangedButAudioTextPresent() {
+        assertFalse(ArticleService.shouldInvalidateAudio("<p>旧正文</p>", "<p>新正文</p>", "朗读文本", "朗读文本"));
+    }
+
+    @Test
+    void keepsAudioWhenNothingChanged() {
+        assertFalse(ArticleService.shouldInvalidateAudio("<p>正文</p>", "<p>正文</p>", "", ""));
+    }
+
+    @Test
+    void keepsAudioForPartialUpdateWithoutContentAndAudioText() {
+        assertFalse(ArticleService.shouldInvalidateAudio("<p>正文</p>", null, "朗读文本", "朗读文本"));
+    }
+}