fd90d7730c
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Failing after 135h4m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 135h4m5s
1. 前端node镜像去掉--pull=never,缺失时自动拉取 - ci-cd.yml: 5处前端lint/test步骤 - deploy.yml: 2处web构建步骤 2. 修复from __future__ import annotations导致的204路由断言失败 - FastAPI 0.115 把字符串"None"当作有效response_model - 给9个路由文件的204 DELETE路由显式加response_model=None 3. 修复phash测试用例:纯色图pHash必然相同(DC分量被排除) - 改用随机噪声图测试差异性
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""Voice library CRUD routes — Phase 3 增强版.
|
|
|
|
支持预置音色和克隆音色的统一列表。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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,
|
|
UpdateVoiceLibraryRequest,
|
|
VoiceLibraryItemResponse,
|
|
)
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
|
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
|
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
|
|
from packages.application.voice_library.use_cases import (
|
|
CreateVoiceLibraryUseCase,
|
|
DeleteVoiceLibraryUseCase,
|
|
GetVoiceLibraryUseCase,
|
|
ListVoiceLibraryUseCase,
|
|
NotFoundError,
|
|
QuotaExceededError,
|
|
UpdateVoiceLibraryUseCase,
|
|
)
|
|
from packages.domain.preset_voices import PRESET_VOICES
|
|
from packages.ports.user_repository import UserRepository
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
|
return SQLAlchemyVoiceLibraryRepository(session)
|
|
|
|
|
|
def _get_clone_profile_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceCloneProfileRepository:
|
|
return SQLAlchemyVoiceCloneProfileRepository(session)
|
|
|
|
|
|
def _to_response(item) -> VoiceLibraryItemResponse:
|
|
return VoiceLibraryItemResponse(
|
|
id=item.id,
|
|
user_id=item.user_id,
|
|
name=item.name,
|
|
text=item.text,
|
|
voice_provider=item.voice_provider,
|
|
voice_id=item.voice_id,
|
|
voice_name=item.voice_name,
|
|
audio_url=item.audio_url,
|
|
duration=item.duration,
|
|
file_size=item.file_size,
|
|
status=item.status,
|
|
project_id=item.project_id,
|
|
tags=item.tags,
|
|
created_at=item.created_at,
|
|
updated_at=item.updated_at,
|
|
)
|
|
|
|
|
|
def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse:
|
|
"""将数据库音色转换为统一响应格式。
|
|
|
|
Args:
|
|
item: VoiceLibraryItem
|
|
profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id
|
|
"""
|
|
profile_id = None
|
|
if profile_id_map and item.voice_id:
|
|
profile_id = profile_id_map.get(item.voice_id)
|
|
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,
|
|
voice_clone_profile_id=profile_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:
|
|
return "free"
|
|
return getattr(user, "subscription_plan", "free") or "free"
|
|
|
|
|
|
# ==================== 统一配音列表(预置 + 克隆)====================
|
|
|
|
|
|
@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),
|
|
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
|
) -> UnifiedVoiceListResponse:
|
|
"""获取配音列表(预置音色 + 用户克隆音色)。
|
|
|
|
- 不传 type:返回预置音色 + 用户克隆音色,预置音色在前
|
|
- type=preset:仅返回预置音色
|
|
- type=clone:仅返回用户克隆音色
|
|
"""
|
|
user_id = authenticated_user.user.id
|
|
items: list[UnifiedVoiceItemResponse] = []
|
|
preset_count = 0
|
|
clone_count = 0
|
|
|
|
has_preset = type is None or type == "preset"
|
|
has_clone = type is None or type == "clone"
|
|
|
|
# 获取预置音色
|
|
if has_preset:
|
|
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
|
|
preset_count = len(preset_items)
|
|
|
|
# 获取克隆音色
|
|
if has_clone:
|
|
use_case = ListVoiceLibraryUseCase(voice_repository)
|
|
clone_items_raw, clone_count = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
|
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
|
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
|
|
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
|
|
clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw]
|
|
|
|
# 组装结果
|
|
if type == "preset":
|
|
items = preset_items[skip : skip + limit]
|
|
total = preset_count
|
|
elif type == "clone":
|
|
items = clone_items
|
|
total = clone_count
|
|
else:
|
|
# 全量模式:预置在前,克隆补位
|
|
all_items = preset_items + clone_items
|
|
total = preset_count + clone_count
|
|
items = all_items[skip : skip + limit]
|
|
|
|
return UnifiedVoiceListResponse(
|
|
items=items,
|
|
total=total,
|
|
preset_count=preset_count if has_preset else 0,
|
|
clone_count=clone_count if has_clone else 0,
|
|
)
|
|
|
|
|
|
# ==================== 预置音色专用端点 ====================
|
|
|
|
|
|
@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, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
|
return ListVoiceLibraryResponse(
|
|
items=[_to_response(i) for i in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
@router.get("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
|
def get_voice(
|
|
voice_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
|
) -> VoiceLibraryItemResponse:
|
|
user_id = authenticated_user.user.id
|
|
use_case = GetVoiceLibraryUseCase(voice_repository)
|
|
item = use_case.execute(voice_id, user_id)
|
|
if item is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
|
return _to_response(item)
|
|
|
|
|
|
@router.post("", response_model=VoiceLibraryItemResponse, status_code=status.HTTP_201_CREATED)
|
|
def create_voice(
|
|
request: CreateVoiceLibraryRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
|
user_repository: UserRepository = Depends(get_user_repository),
|
|
) -> VoiceLibraryItemResponse:
|
|
user_id = authenticated_user.user.id
|
|
plan_name = _get_user_plan(user_id, user_repository)
|
|
command = CreateVoiceLibraryCommand(
|
|
user_id=user_id,
|
|
name=request.name,
|
|
text=request.text,
|
|
voice_provider=request.voice_provider,
|
|
voice_id=request.voice_id,
|
|
voice_name=request.voice_name,
|
|
audio_url=request.audio_url,
|
|
duration=request.duration,
|
|
file_size=request.file_size,
|
|
status=request.status,
|
|
project_id=request.project_id,
|
|
tags=request.tags,
|
|
)
|
|
use_case = CreateVoiceLibraryUseCase(voice_repository)
|
|
try:
|
|
item = use_case.execute(command, plan_name=plan_name)
|
|
except QuotaExceededError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
|
)
|
|
return _to_response(item)
|
|
|
|
|
|
@router.put("/{voice_id}", response_model=VoiceLibraryItemResponse)
|
|
def update_voice(
|
|
voice_id: str,
|
|
request: UpdateVoiceLibraryRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
|
) -> VoiceLibraryItemResponse:
|
|
user_id = authenticated_user.user.id
|
|
command = UpdateVoiceLibraryCommand(
|
|
id=voice_id,
|
|
user_id=user_id,
|
|
name=request.name,
|
|
text=request.text,
|
|
voice_provider=request.voice_provider,
|
|
voice_id=request.voice_id,
|
|
voice_name=request.voice_name,
|
|
audio_url=request.audio_url,
|
|
duration=request.duration,
|
|
file_size=request.file_size,
|
|
status=request.status,
|
|
tags=request.tags,
|
|
)
|
|
use_case = UpdateVoiceLibraryUseCase(voice_repository)
|
|
try:
|
|
item = use_case.execute(command)
|
|
except NotFoundError:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
|
return _to_response(item)
|
|
|
|
|
|
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
|
def delete_voice(
|
|
voice_id: str,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
|
) -> Response:
|
|
user_id = authenticated_user.user.id
|
|
use_case = DeleteVoiceLibraryUseCase(voice_repository)
|
|
deleted = use_case.execute(voice_id, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
|
return Response(status_code=204)
|