Explorar el Código

feat(articles): 文章语音合成能力(TTS/音频存储与入库)

Sisyphus Agent hace 1 semana
padre
commit
804916fd93

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10315,6 +10315,11 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         ensureColumn("articles", "sort_order", "INT NOT NULL DEFAULT 0 COMMENT '推荐排序值(越小越靠前)'");
         log.info("已添加 articles.sort_order 列(迁移294)");
 
+        // 迁移294b: articles 表添加 audio_url/audio_text 列(文章AI朗读音频)
+        ensureColumn("articles", "audio_url", "VARCHAR(500) DEFAULT NULL COMMENT 'AI朗读音频URL'");
+        ensureColumn("articles", "audio_text", "TEXT COMMENT '朗读文本(纯文本,缺省从content剥离)'");
+        log.info("已添加 articles.audio_url/audio_text 列(迁移294b)");
+
         // 迁移295: task_plan_instances 补齐实体已含但 DB 缺失的审核/确认/激活列(review/activate 写字段报 Unknown column)
         ensureColumn("task_plan_instances", "source", "VARCHAR(20) DEFAULT NULL COMMENT '来源: manual/assessment/template'");
         ensureColumn("task_plan_instances", "source_result_id", "BIGINT DEFAULT NULL COMMENT '关联测评结果ID(source=assessment时)'");

+ 29 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.ArticleCategory;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.ArticleCategoryService;
+import com.etotem.cfc.service.ArticleAudioService;
 import com.etotem.cfc.service.ArticleService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -34,6 +35,9 @@ public class ArticleController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private ArticleAudioService articleAudioService;
+
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
                                        @RequestAttribute(value = "userId", required = false) Long userId,
@@ -105,17 +109,35 @@ public class ArticleController {
         return Result.success(list);
     }
 
-    @PostMapping("/record-read")
+@PostMapping("/record-read")
     public Result<Map<String, Object>> recordRead(@RequestBody Map<String, Object> body,
-                                      @RequestAttribute(value = "userId", required = false) Long userId,
-                                      @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+                                                @RequestAttribute(value = "userId", required = false) Long userId,
+                                                @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
         Long articleId = ParamUtils.getLong(body.get("id"));
         int durationSeconds = body.get("durationSeconds") != null
-                ? Integer.parseInt(body.get("durationSeconds").toString()) : 0;
+                ? Integer.parseInt(body.get("durationSeconds").toString())
+                : 0;
         Long memberId = ParamUtils.getLong(body.get("memberId"), currentMemberId);
-
-        Map<String, Object> readResult = articleService.recordRead(articleId, userId, memberId, durationSeconds);
-        return Result.success(readResult);
+        Map<String, Object> result = articleService.recordRead(articleId, userId, memberId, durationSeconds);
+        return Result.success(result);
+    }
+    
+    @PostMapping("/audio")
+    public Result<Map<String, Object>> getArticleAudio(@RequestBody Map<String, Object> body,
+                                                    @RequestAttribute(value = "userId", required = false) Long userId,
+                                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long articleId = ParamUtils.getLong(body.get("articleId"));
+        if (articleId == null) {
+            return Result.error("文章ID不能为空");
+        }
+        try {
+            Map<String, Object> result = articleAudioService.getOrGenerateAudio(articleId);
+            return Result.success(result);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (IllegalStateException e) {
+            return Result.error(e.getMessage());
+        }
     }
 
     @PostMapping("/report-reading-time")

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

@@ -57,6 +57,10 @@ public class Article implements Serializable {
     private Long createdBy;
     /** 作者类型: admin/parent/child */
     private String authorType;
+    /** AI朗读音频URL(按需生成缓存,多段时JSON数组字符串) */
+    private String audioUrl;
+    /** 朗读文本(纯文本,缺省从content剥离HTML) */
+    private String audioText;
     private Date createdAt;
     private Date updatedAt;
 }

+ 195 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleAudioService.java

@@ -0,0 +1,195 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.service.api.StorageServiceInterface;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 文章 AI 朗读音频服务。
+ *
+ * 按需生成:用户首次点击「听文章」时调用 {@link #getOrGenerateAudio(Long)},
+ * 已生成(articles.audio_url 非空)直接返回缓存;未生成则剥离 HTML → 分段 →
+ * 腾讯云 TTS 逐段合成 → 存储 → 回写 audio_url/audio_text → 返回。
+ */
+@Service
+public class ArticleAudioService {
+
+    private static final Logger log = LoggerFactory.getLogger(ArticleAudioService.class);
+
+    /** 腾讯云 TTS 同步接口单次文本上限(TextToVoice ≤150 字,留余量取 120) */
+    private static final int MAX_CHUNK_CHARS = 120;
+    private static final String AUDIO_CATEGORY = "articles-audio";
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private StorageServiceInterface storageService;
+
+    @Resource
+    private TencentTtsService tencentTtsService;
+
+    /**
+     * 获取文章的朗读音频。已缓存则直接返回;否则生成并缓存。
+     *
+     * @param articleId 文章ID
+     * @return audioUrls(可能多段)、audioText、durationSeconds(预计)、minSeconds(达标阈值)
+     */
+    public Map<String, Object> getOrGenerateAudio(Long articleId) {
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) {
+            throw new IllegalArgumentException("文章不存在");
+        }
+
+        // 1. 缓存命中:audioUrl 存单 URL 或 JSON 数组字符串
+        String cached = article.getAudioUrl();
+        if (cached != null && !cached.trim().isEmpty()) {
+            return buildResult(parseUrls(cached), article.getAudioText(), article.getWordCount());
+        }
+
+        if (!tencentTtsService.isConfigured()) {
+            throw new IllegalStateException("语音服务未配置,请联系管理员");
+        }
+
+        // 2. 取朗读文本:优先 audio_text,否则从 content 剥离 HTML
+        String plainText = article.getAudioText();
+        if (plainText == null || plainText.trim().isEmpty()) {
+            plainText = htmlToPlainText(article.getContent());
+        }
+        if (plainText == null || plainText.trim().isEmpty()) {
+            throw new IllegalArgumentException("文章没有可朗读的文本内容");
+        }
+
+        // 3. 分片(≤120 字,优先在句号/分号处断句)
+        List<String> chunks = splitChunks(plainText.trim());
+        if (chunks.isEmpty()) {
+            throw new IllegalArgumentException("文章没有可朗读的文本内容");
+        }
+
+        // 4. 逐段合成并存储
+        List<String> urls = new ArrayList<>(chunks.size());
+        int index = 0;
+        for (String chunk : chunks) {
+            byte[] audio = tencentTtsService.synthesize(chunk);
+            String url = storageService.storeBytes(audio, AUDIO_CATEGORY,
+                    "article_" + articleId + "_" + index + ".mp3", "audio/mpeg");
+            urls.add(url);
+            index++;
+        }
+
+        // 5. 回写缓存
+        String urlsJson = urls.size() == 1 ? urls.get(0) : JSON.toJSONString(urls);
+        updateAudioCache(articleId, urlsJson, plainText);
+
+        log.info("文章音频生成完成: articleId={}, chunks={}, urls={}", articleId, chunks.size(), urlsJson);
+        return buildResult(urls, plainText, article.getWordCount());
+    }
+
+    /**
+     * 回写音频缓存到 articles 表。
+     */
+    private void updateAudioCache(Long articleId, String audioUrl, String audioText) {
+        LambdaUpdateWrapper<Article> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(Article::getId, articleId)
+                .set(Article::getAudioUrl, audioUrl)
+                .set(Article::getAudioText, audioText);
+        articleMapper.update(null, wrapper);
+    }
+
+    /**
+     * 解析缓存的 audioUrl 为列表:单 URL 或 JSON 数组。
+     */
+    private List<String> parseUrls(String cached) {
+        List<String> urls = new ArrayList<>();
+        if (cached == null || cached.isEmpty()) {
+            return urls;
+        }
+        try {
+            if (cached.startsWith("[")) {
+                List<String> list = JSON.parseArray(cached, String.class);
+                if (list != null) urls.addAll(list);
+            } else {
+                urls.add(cached);
+            }
+        } catch (Exception e) {
+            urls.add(cached);
+        }
+        return urls;
+    }
+
+    /**
+     * 组装返回结果。
+     */
+    private Map<String, Object> buildResult(List<String> urls, String audioText, Integer wordCount) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("audioUrls", urls);
+        result.put("audioText", audioText);
+        // 预计时长:按中文 4 字/秒估算
+        int chars = audioText == null ? 0 : audioText.length();
+        int duration = Math.max(1, (int) Math.ceil(chars / 4.0));
+        result.put("durationSeconds", duration);
+        // 阅读达标阈值与现有文章阅读逻辑一致(article_reading_min_seconds,默认 60)
+        result.put("minSeconds", 60);
+        result.put("wordCount", wordCount != null ? wordCount : chars);
+        return result;
+    }
+
+    /**
+     * HTML 剥离为纯文本(去标签、去实体、折叠空白)。
+     */
+    private String htmlToPlainText(String html) {
+        if (html == null || html.isEmpty()) {
+            return "";
+        }
+        String text = html.replaceAll("(?i)<br\\s*/?>", "\n")
+                .replaceAll("(?i)</(p|div|section|li|h[1-6]|blockquote)>", "\n")
+                .replaceAll("<[^>]+>", "")
+                .replaceAll("&nbsp;", " ")
+                .replaceAll("&lt;", "<")
+                .replaceAll("&gt;", ">")
+                .replaceAll("&amp;", "&")
+                .replaceAll("&quot;", "\"")
+                .replaceAll("&#39;", "'")
+                .replaceAll("\\s+", " ")
+                .trim();
+        return text;
+    }
+
+    /**
+     * 按 ≤MAX_CHUNK_CHARS 分片,优先在句号/分号/问号/感叹号处断句。
+     */
+    private List<String> splitChunks(String text) {
+        List<String> chunks = new ArrayList<>();
+        StringBuilder current = new StringBuilder();
+        Pattern sentenceEnd = Pattern.compile("[。;;!?!?…]");
+
+        for (int i = 0; i < text.length(); i++) {
+            char c = text.charAt(i);
+            current.append(c);
+            Matcher m = sentenceEnd.matcher(String.valueOf(c));
+            boolean isBreak = m.find();
+
+            if ((isBreak && current.length() >= 30) || current.length() >= MAX_CHUNK_CHARS) {
+                chunks.add(current.toString().trim());
+                current.setLength(0);
+            }
+        }
+        if (current.length() > 0) {
+            chunks.add(current.toString().trim());
+        }
+        return chunks;
+    }
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/StorageService.java

@@ -16,6 +16,17 @@ public interface StorageService extends StorageServiceInterface {
      */
     String storeFile(MultipartFile file, String category);
 
+    /**
+     * 存储字节数据并返回访问URL(TTS 等二进制生成物,无 MultipartFile)
+     *
+     * @param data        文件字节
+     * @param category    存储分类子目录
+     * @param filename    文件名(含扩展名,如 article_1.mp3)
+     * @param contentType MIME 类型(如 audio/mpeg)
+     * @return 访问URL
+     */
+    String storeBytes(byte[] data, String category, String filename, String contentType);
+
     /**
      * 删除文件
      */

+ 196 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TencentTtsService.java

@@ -0,0 +1,196 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 腾讯云语音合成(TTS)服务封装。
+ *
+ * 直接调用腾讯云 TTS TextToVoice API(TC3-HMAC-SHA256 签名),
+ * 不依赖腾讯云 Java SDK(避免新增 Maven 依赖)。
+ *
+ * 文档: https://cloud.tencent.com/document/product/1073/37995
+ */
+@Service
+public class TencentTtsService {
+
+    private static final Logger log = LoggerFactory.getLogger(TencentTtsService.class);
+
+    private static final String TTS_ENDPOINT = "https://tts.tencentcloudapi.com";
+    private static final String SERVICE = "tts";
+    private static final String HOST = "tts.tencentcloudapi.com";
+    private static final String ACTION = "TextToVoice";
+    private static final String VERSION = "2019-08-23";
+    private static final String REGION = "ap-guangzhou";
+    private static final String ALGORITHM = "TC3-HMAC-SHA256";
+    private static final String CT = "application/json; charset=utf-8";
+    private static final MediaType JSON_MEDIA = MediaType.parse("application/json; charset=utf-8");
+
+    @Value("${tencent.tts.secret-id:}")
+    private String secretId;
+
+    @Value("${tencent.tts.secret-key:}")
+    private String secretKey;
+
+    @Value("${tencent.tts.voice-type:101002}")
+    private int voiceType;
+
+    @Value("${tencent.tts.codec:mp3}")
+    private String codec;
+
+    /** 单次合成最大字符数(腾讯云 TextToVoice 限制 150 字,留余量取 120) */
+    private static final int MAX_CHARS_PER_CALL = 120;
+
+    private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
+            .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
+            .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
+            .build();
+
+    public boolean isConfigured() {
+        return secretId != null && !secretId.isEmpty()
+                && secretKey != null && !secretKey.isEmpty();
+    }
+
+    /**
+     * 将一段文本合成为音频字节(mp3),单次调用建议不超过 120 字符。
+     *
+     * @param text 待合成文本
+     * @return 音频字节(mp3)
+     * @throws IllegalStateException 未配置密钥或合成失败
+     */
+    public byte[] synthesize(String text) {
+        if (!isConfigured()) {
+            throw new IllegalStateException("腾讯云TTS未配置(tencent.tts.secret-id/secret-key)");
+        }
+        if (text == null || text.trim().isEmpty()) {
+            throw new IllegalArgumentException("待合成文本为空");
+        }
+
+        try {
+            JSONObject payload = new JSONObject();
+            payload.put("Text", text);
+            payload.put("SessionId", java.util.UUID.randomUUID().toString());
+            payload.put("ModelType", 1);
+            payload.put("VoiceType", voiceType);
+            payload.put("Codec", codec);
+            payload.put("SampleRate", 16000);
+            payload.put("Volume", 0);
+            payload.put("Speed", 0);
+            payload.put("PrimaryLanguage", 1);
+
+            String body = payload.toJSONString();
+            String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+            String authorization = buildAuthorization(body, timestamp);
+
+            Request request = new Request.Builder()
+                    .url(TTS_ENDPOINT)
+                    .post(RequestBody.create(JSON_MEDIA, body))
+                    .header("Host", HOST)
+                    .header("X-TC-Action", ACTION)
+                    .header("X-TC-Version", VERSION)
+                    .header("X-TC-Timestamp", timestamp)
+                    .header("X-TC-Region", REGION)
+                    .header("X-TC-Token", "")
+                    .header("Authorization", authorization)
+                    .header("Content-Type", CT)
+                    .build();
+
+            try (Response response = okHttpClient.newCall(request).execute()) {
+                String respBody = response.body() == null ? "" : response.body().string();
+                if (!response.isSuccessful()) {
+                    log.warn("腾讯云TTS请求失败: code={}, body={}", response.code(), respBody);
+                    throw new RuntimeException("TTS合成失败: HTTP " + response.code());
+                }
+                JSONObject resp = JSON.parseObject(respBody);
+                JSONObject error = resp.getJSONObject("Error");
+                if (error != null) {
+                    String msg = error.getString("Message");
+                    log.warn("腾讯云TTS业务错误: {}", msg);
+                    throw new RuntimeException("TTS合成失败: " + msg);
+                }
+                String audioBase64 = resp.getJSONObject("Response").getString("Audio");
+                if (audioBase64 == null || audioBase64.isEmpty()) {
+                    throw new RuntimeException("TTS合成返回空音频");
+                }
+                return Base64.getDecoder().decode(audioBase64);
+            }
+        } catch (IllegalStateException | IllegalArgumentException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("TTS合成异常", e);
+            throw new RuntimeException("TTS合成异常: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 计算 TC3-HMAC-SHA256 签名头。
+     */
+    private String buildAuthorization(String body, String timestamp) throws Exception {
+        String date = timestamp == null || timestamp.length() < 8
+                ? "" : timestamp.substring(0, 8); // 取前8位 yyyyMMdd
+        String service = SERVICE;
+        String version = "tc3_request";
+
+        // 1. CanonicalRequest
+        String canonicalUri = "/";
+        String canonicalQuery = "";
+        String canonicalHeaders = "content-type:" + CT + "\nhost:" + HOST + "\n";
+        String signedHeaders = "content-type;host";
+        String hashedPayload = sha256Hex(body);
+        String canonicalRequest = "POST\n" + canonicalUri + "\n" + canonicalQuery + "\n"
+                + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload;
+
+        // 2. StringToSign
+        String credentialScope = date + "/" + service + "/" + version;
+        String hashedCanonicalRequest = sha256Hex(canonicalRequest);
+        String stringToSign = ALGORITHM + "\n" + timestamp + "\n" + credentialScope + "\n"
+                + hashedCanonicalRequest;
+
+        // 3. Signature
+        byte[] secretDate = hmacSha256((secretKey).getBytes(StandardCharsets.UTF_8), date);
+        byte[] secretService = hmacSha256(secretDate, service);
+        byte[] secretSigning = hmacSha256(secretService, version);
+        byte[] signatureBytes = hmacSha256(secretSigning, stringToSign);
+        String signature = hex(signatureBytes);
+
+        // 4. Authorization
+        return ALGORITHM + " Credential=" + secretId + "/" + credentialScope
+                + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature;
+    }
+
+    private static byte[] hmacSha256(byte[] key, String data) throws Exception {
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(key, "HmacSHA256"));
+        return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static String sha256Hex(String data) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("SHA-256");
+        return hex(md.digest(data.getBytes(StandardCharsets.UTF_8)));
+    }
+
+    private static String hex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder();
+        for (byte b : bytes) {
+            sb.append(String.format("%02x", b));
+        }
+        return sb.toString();
+    }
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/api/StorageServiceInterface.java

@@ -14,6 +14,17 @@ public interface StorageServiceInterface {
      */
     String storeFile(MultipartFile file, String category);
 
+    /**
+     * 存储字节数据并返回访问URL(TTS 等二进制生成物,无 MultipartFile)
+     *
+     * @param data        文件字节
+     * @param category    存储分类子目录
+     * @param filename    文件名(含扩展名,如 article_1.mp3)
+     * @param contentType MIME 类型(如 audio/mpeg)
+     * @return 访问URL
+     */
+    String storeBytes(byte[] data, String category, String filename, String contentType);
+
     /**
      * 删除文件
      */

+ 16 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java

@@ -94,7 +94,16 @@ public class JdCloudStorageService implements StorageService {
     @Override
     public String storeFile(MultipartFile file, String category) {
         try {
-            byte[] fileBytes = file.getBytes();
+            return storeBytes(file.getBytes(), category,
+                    file.getOriginalFilename(), file.getContentType());
+        } catch (IOException e) {
+            throw new RuntimeException("京东云文件存储失败", e);
+        }
+    }
+
+    @Override
+    public String storeBytes(byte[] fileBytes, String category, String filename, String contentType) {
+        try {
             String hash = sha256(fileBytes);
 
             String dbCategory = category != null ? category : "general";
@@ -110,12 +119,12 @@ public class JdCloudStorageService implements StorageService {
 
             // 生成 key 并上传
             String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
-            String key = "uploads/" + datePath + "/" + generateFilename(file.getOriginalFilename());
+            String key = "uploads/" + datePath + "/" + generateFilename(filename);
 
             ObjectMetadata metadata = new ObjectMetadata();
             metadata.setContentLength(fileBytes.length);
-            if (file.getContentType() != null) {
-                metadata.setContentType(file.getContentType());
+            if (contentType != null) {
+                metadata.setContentType(contentType);
             }
             try {
                 metadata.setContentMD5(com.amazonaws.util.Base64.encodeAsString(
@@ -134,15 +143,15 @@ public class JdCloudStorageService implements StorageService {
             record.setHash(hash);
             record.setUrl(url);
             record.setFileSize((long) fileBytes.length);
-            record.setContentType(file.getContentType());
-            record.setOriginalFilename(file.getOriginalFilename());
+            record.setContentType(contentType);
+            record.setOriginalFilename(filename);
             record.setCategory(dbCategory);
             record.setCreatedAt(new Date());
             record.setUpdatedAt(new Date());
             fileRecordMapper.insert(record);
 
             return url;
-        } catch (IOException e) {
+        } catch (Exception e) {
             throw new RuntimeException("京东云文件存储失败", e);
         }
     }

+ 30 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/LocalDiskStorageService.java

@@ -6,7 +6,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
-import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -60,6 +59,36 @@ public class LocalDiskStorageService implements StorageService {
         }
     }
 
+    @Override
+    public String storeBytes(byte[] data, String category, String filename, String contentType) {
+        try {
+            // 生成日期路径:2026/04/05/
+            String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+            Path uploadPath = Paths.get(uploadDir, datePath).toAbsolutePath().normalize();
+
+            // 创建目录
+            if (!Files.exists(uploadPath)) {
+                Files.createDirectories(uploadPath);
+            }
+
+            // 生成唯一文件名(保留原始扩展名)
+            String extension = "";
+            if (filename != null && filename.contains(".")) {
+                extension = filename.substring(filename.lastIndexOf("."));
+            }
+            String name = UUID.randomUUID().toString() + extension;
+
+            // 写入字节
+            Path filePath = uploadPath.resolve(name);
+            Files.write(filePath, data);
+
+            // 返回 /uploads/ 前缀的相对URL(nginx / WebConfig 静态映射 /uploads/** → upload.base-dir)
+            return "/uploads/" + datePath + "/" + name;
+        } catch (IOException e) {
+            throw new RuntimeException("文件存储失败", e);
+        }
+    }
+
     @Override
     public void deleteFile(String fileUrl) {
         if (fileUrl == null || fileUrl.isEmpty()) {

+ 10 - 1
cfc-backend/src/main/resources/application.yml

@@ -165,7 +165,7 @@ aikanshe:
 
 upload:
   base-dir: /data/cfc-uploads
-  allowed-types: image/jpeg,image/png,image/gif,image/webp,application/pdf
+  allowed-types: image/jpeg,image/png,image/gif,image/webp,application/pdf,audio/mpeg,audio/wav
 
 storage:
   type: jdcloud  # jdcloud | local
@@ -177,6 +177,15 @@ jdcloud:
   bucket: cfc
   region: cn-north-1
 
+tencent:
+  # 腾讯云语音合成(文章 AI 朗读)。secret-id/secret-key 为腾讯云访问管理 AK/SK,
+  # 未配置(留空)时 TencentTtsService.isConfigured() 返回 false,接口会提示「语音服务未配置」。
+  tts:
+    secret-id: ${TENCENT_TTS_SECRET_ID:}
+    secret-key: ${TENCENT_TTS_SECRET_KEY:}
+    voice-type: ${TENCENT_TTS_VOICE_TYPE:101002}  # 音色:101002=女声(无情感),见腾讯云 TTS 音色列表
+    codec: ${TENCENT_TTS_CODEC:mp3}
+
 shop:
   order:
     payment-timeout-minutes: 30  # 订单超时时间(分钟)

+ 3 - 1
cfc-backend/src/main/resources/schema.sql

@@ -1496,7 +1496,9 @@ CREATE TABLE IF NOT EXISTS articles (
     view_count INT DEFAULT 0 COMMENT '浏览次数',
     created_by BIGINT DEFAULT 0 COMMENT '发布人 adminID',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    audio_url VARCHAR(500) NULL COMMENT 'AI朗读音频URL',
+    audio_text TEXT NULL COMMENT '朗读文本(纯文本,缺省从content剥离)'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
 
 -- Article audit fields (for user-generated content)