feat: 配音列表 API 增强 — 预置音色 + 克隆音色统一接口
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 170h24m47s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 170h24m52s
Deploy / Deploy Staging (push) Failing after 170h27m34s
CI/CD Pipeline / Frontend Lint (push) Failing after 170h28m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 170h28m14s

- 新增预置音色配置(8 个 CosyVoice 真实音色)
- 增强 GET /api/v1/voices 接口,支持 type 查询参数(preset/clone/all)
- 新增 GET /api/v1/voices/presets 端点
- 统一响应格式,包含 preset_count 和 clone_count
- 保留原有 CRUD 端点向后兼容
- 添加 18 个预置音色单元测试(全部通过)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
灵应
2026-07-02 10:26:14 +08:00
parent 815dd6fe5e
commit 44dc89360a
4 changed files with 607 additions and 4 deletions
+160 -4
View File
@@ -1,11 +1,20 @@
"""Voice library CRUD routes."""
"""Voice library CRUD routes — Phase 3 增强版.
支持预置音色和克隆音色的统一列表。
"""
from __future__ import annotations
from typing import Optional
from typing import Literal, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.voice import (
PresetVoiceItemResponse,
PresetVoiceListResponse,
UnifiedVoiceItemResponse,
UnifiedVoiceListResponse,
)
from app.schemas.voice_library import (
CreateVoiceLibraryRequest,
ListVoiceLibraryResponse,
@@ -26,6 +35,7 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError,
UpdateVoiceLibraryUseCase,
)
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
router = APIRouter()
@@ -55,6 +65,45 @@ def _to_response(item) -> VoiceLibraryItemResponse:
)
def _to_unified_response(item) -> UnifiedVoiceItemResponse:
"""将数据库音色转换为统一响应格式。"""
return UnifiedVoiceItemResponse(
id=item.id,
type="clone",
name=item.name,
description=item.text,
gender="unknown",
language="zh-CN",
voice_id=item.voice_id,
voice_provider=item.voice_provider or "cosyvoice",
audio_url=item.audio_url,
duration=item.duration,
file_size=item.file_size,
status=item.status,
tags=item.tags,
user_id=item.user_id,
project_id=item.project_id,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
"""将预置音色转换为统一响应格式。"""
return UnifiedVoiceItemResponse(
id=preset.voice_id,
type="preset",
name=preset.name,
description=preset.description,
gender=preset.gender,
language=preset.language,
voice_id=preset.voice_id,
voice_provider="cosyvoice",
preview_url=preset.preview_url,
tags=preset.tags or [],
)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
@@ -62,14 +111,121 @@ def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
return getattr(user, "subscription_plan", "free") or "free"
@router.get("", response_model=ListVoiceLibraryResponse)
def list_voices(
# ==================== 统一配音列表(预置 + 克隆)====================
@router.get("", response_model=UnifiedVoiceListResponse)
def list_voices_unified(
type: Optional[Literal["preset", "clone"]] = Query(
None,
description="音色类型过滤:preset=仅预置,clone=仅克隆,不传=全部",
),
status_filter: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
) -> UnifiedVoiceListResponse:
"""获取配音列表(预置音色 + 用户克隆音色)。
- 不传 type:返回预置音色 + 用户克隆音色,预置音色在前
- type=preset:仅返回预置音色
- type=clone:仅返回用户克隆音色
"""
user_id = authenticated_user.user.id
items: list[UnifiedVoiceItemResponse] = []
preset_count = 0
clone_count = 0
# 获取预置音色
if type is None or type == "preset":
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
preset_count = len(preset_items)
if type == "preset":
# 仅预置:应用分页
items = preset_items[skip : skip + limit]
else:
items.extend(preset_items)
# 获取克隆音色
if type is None or type == "clone":
use_case = ListVoiceLibraryUseCase(voice_repository)
clone_items_raw = use_case.execute(user_id, status=status_filter, skip=0, limit=1000)
clone_items = [_to_unified_response(i) for i in clone_items_raw]
clone_count = len(clone_items)
if type == "clone":
# 仅克隆:应用分页
items = clone_items[skip : skip + limit]
else:
items.extend(clone_items)
# 全量模式:应用分页
if type is None:
total = preset_count + clone_count
items = items[skip : skip + limit]
elif type == "preset":
total = preset_count
else:
total = clone_repository_count(voice_repository, user_id, status_filter)
return UnifiedVoiceListResponse(
items=items,
total=total,
preset_count=preset_count if type != "clone" else 0,
clone_count=clone_count if type != "preset" else 0,
)
def clone_repository_count(
voice_repository: SQLAlchemyVoiceLibraryRepository,
user_id: str,
status_filter: Optional[str],
) -> int:
"""获取克隆音色数量。"""
if status_filter:
return voice_repository.count_by_user(user_id, status=status_filter)
return voice_repository.count_by_user(user_id)
# ==================== 预置音色专用端点 ====================
@router.get("/presets", response_model=PresetVoiceListResponse)
def list_preset_voices() -> PresetVoiceListResponse:
"""获取预置音色列表。
不需要认证,返回所有系统预置的 CosyVoice 音色。
"""
items = [
PresetVoiceItemResponse(
voice_id=p.voice_id,
name=p.name,
description=p.description,
gender=p.gender,
language=p.language,
preview_url=p.preview_url,
tags=p.tags or [],
)
for p in PRESET_VOICES
]
return PresetVoiceListResponse(items=items, total=len(items))
# ==================== 原有 CRUD 端点(保持向后兼容)====================
@router.get("/legacy", response_model=ListVoiceLibraryResponse)
def list_voices_legacy(
status_filter: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
) -> ListVoiceLibraryResponse:
"""原有配音列表接口(仅返回用户克隆音色)。
保留用于向后兼容,新客户端请使用 GET /api/v1/voices。
"""
user_id = authenticated_user.user.id
use_case = ListVoiceLibraryUseCase(voice_repository)
items = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)