feat: 预置音色 preview_url 动态生成,解决签名 URL 过期问题
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 37s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
AI Code Review / AI Code Review (pull_request) Successful in 4m12s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 5s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 40s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 41s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1073h14m42s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1073h14m44s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1073h14m43s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1073h14m45s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 1073h18m36s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1073h18m36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1073h19m8s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1073h19m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1073h19m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1073h19m12s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1073h19m13s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1073h19m13s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1073h47m58s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1073h48m0s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1073h51m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1073h52m24s

- 新增 _resolve_preset_preview_url():缓存优先,失效时调 CosyVoice TTS 重新合成,失败降级到硬编码 URL
- 新增 _resolve_all_preset_preview_urls():ThreadPoolExecutor(max_workers=4) 并行生成 8 个音色的 preview_url
- 修改 list_preset_voices() 和 list_voices_unified():注入 CosyVoiceService,动态解析 preview_url
- 修改 _preset_to_unified_response():支持 preview_url_map 参数
- 新增 13 个单元测试覆盖缓存命中/失效/合成失败/并行解析等场景

117 个相关测试全部通过,ruff + black 检查通过
This commit is contained in:
xiaoxia
2026-08-02 21:56:56 +08:00
parent 0f73df242e
commit 4e36aebabb
2 changed files with 317 additions and 9 deletions
+87 -9
View File
@@ -5,6 +5,9 @@
from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan
@@ -42,6 +45,7 @@ from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
from packages.ports.user_repository import UserRepository
router = APIRouter()
logger = logging.getLogger(__name__)
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
# key: voice_id, value: (audio_url, timestamp)
@@ -51,6 +55,68 @@ PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
def _resolve_preset_preview_url(
voice_id: str,
fallback_url: str,
cosyvoice: CosyVoiceService,
) -> str:
"""为预置音色获取有效的 preview_url.
优先从内存缓存读取;缓存失效时调用 CosyVoice 重新合成;
合成失败时降级返回硬编码 URL(可能已过期,但不会报错)。
"""
# 检查缓存
if voice_id in _preset_preview_cache:
audio_url, cached_at = _preset_preview_cache[voice_id]
if time.time() - cached_at < PREVIEW_CACHE_TTL:
return audio_url
# 缓存失效,调用 CosyVoice 合成
preset = get_preset_voice_by_id(voice_id)
if preset is None:
return fallback_url
preview_text = PREVIEW_TEMPLATE.format(name=preset.name)
try:
result = cosyvoice.synthesize_speech(
text=preview_text,
voice_id=voice_id,
format="mp3",
speed=1.0,
)
audio_url = result.audio_url
_preset_preview_cache[voice_id] = (audio_url, time.time())
logger.info("Preset voice preview generated: %s", voice_id)
return audio_url
except Exception as e:
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
return fallback_url
def _resolve_all_preset_preview_urls(
presets: list,
cosyvoice: CosyVoiceService,
) -> dict[str, str]:
"""并行解析所有预置音色的 preview_url.
Returns:
voice_id -> preview_url 映射
"""
result_map: dict[str, str] = {}
with ThreadPoolExecutor(max_workers=4) as executor:
future_to_voice = {
executor.submit(_resolve_preset_preview_url, p.voice_id, p.preview_url, cosyvoice): p.voice_id
for p in presets
}
for future in as_completed(future_to_voice):
vid = future_to_voice[future]
try:
result_map[vid] = future.result()
except Exception:
result_map[vid] = ""
return result_map
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
return SQLAlchemyVoiceLibraryRepository(session)
@@ -118,8 +184,16 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
)
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
"""将预置音色转换为统一响应格式。"""
def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None = None) -> UnifiedVoiceItemResponse:
"""将预置音色转换为统一响应格式。
Args:
preset: 预置音色对象
preview_url_map: voice_id -> preview_url 动态映射,优先使用
"""
preview_url = preset.preview_url
if preview_url_map and preset.voice_id in preview_url_map:
preview_url = preview_url_map[preset.voice_id]
return UnifiedVoiceItemResponse(
id=preset.voice_id,
type="preset",
@@ -129,7 +203,7 @@ def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
language=preset.language,
voice_id=preset.voice_id,
voice_provider="cosyvoice",
preview_url=preset.preview_url,
preview_url=preview_url,
tags=preset.tags or [],
)
@@ -179,6 +253,7 @@ def list_voices_unified(
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
sign_url=Depends(get_audio_url_signer),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> UnifiedVoiceListResponse:
"""获取配音列表(预置音色 + 用户克隆音色)。
@@ -194,9 +269,10 @@ def list_voices_unified(
has_preset = type is None or type == "preset"
has_clone = type is None or type == "clone"
# 获取预置音色
# 获取预置音色(动态生成 preview_url)
if has_preset:
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
preset_items = [_preset_to_unified_response(p, preview_url_map) for p in PRESET_VOICES]
preset_count = len(preset_items)
# 获取克隆音色(从 voice_clone_profile 读取,ready 状态的克隆音色)
@@ -243,11 +319,15 @@ def list_voices_unified(
@router.get("/presets", response_model=PresetVoiceListResponse)
def list_preset_voices() -> PresetVoiceListResponse:
def list_preset_voices(
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> PresetVoiceListResponse:
"""获取预置音色列表。
不需要认证,返回所有系统预置的 CosyVoice 音色。
preview_url 通过 CosyVoice 动态生成,不依赖硬编码的过期 URL。
"""
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
items = [
PresetVoiceItemResponse(
voice_id=p.voice_id,
@@ -255,7 +335,7 @@ def list_preset_voices() -> PresetVoiceListResponse:
description=p.description,
gender=p.gender,
language=p.language,
preview_url=p.preview_url,
preview_url=preview_url_map.get(p.voice_id, p.preview_url),
tags=p.tags or [],
)
for p in PRESET_VOICES
@@ -275,8 +355,6 @@ def get_preset_voice_preview(
- 相同 voice_id 重复调用直接返回缓存的音频URL
- 可传入自定义 text 参数试听不同文本
"""
import time
preset = get_preset_voice_by_id(voice_id)
if preset is None:
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")