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
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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user