|
|
@@ -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);
|
|
|
}
|
|
|
}
|
|
|
|