From 3f1a876ff7c1e70b166d10b788fcce6270a9de6f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 2 Aug 2026 08:23:54 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A2=84=E7=BD=AE?= =?UTF-8?q?=E9=9F=B3=E8=89=B2voice=5Fid=E5=B9=B6=E5=A1=AB=E5=85=85preview?= =?UTF-8?q?=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换3个不存在的CosyVoice v3音色: - longxiaochen_v3(龙小晨) → longsanshu_v3(龙三叔) - longjing_v3(龙静) → longyingjing_v3(龙应静) - longbo_v3(龙博) → longshuo_v3(龙硕) - 填充5个可用音色的preview_url(CosyVoice试听音频) - 新增seed脚本scripts/seed_test_voice_clones.py用于staging测试数据 - 更新3个测试文件匹配新音色ID和描述 --- packages/domain/preset_voices.py | 46 +++--- scripts/seed_test_voice_clones.py | 182 ++++++++++++++++++++++++ tests/unit/domain/test_preset_voices.py | 14 +- tests/unit/test_preset_voices.py | 8 +- tests/unit/test_preset_voices_domain.py | 8 +- 5 files changed, 223 insertions(+), 35 deletions(-) create mode 100755 scripts/seed_test_voice_clones.py mode change 100644 => 100755 tests/unit/test_preset_voices.py mode change 100644 => 100755 tests/unit/test_preset_voices_domain.py diff --git a/packages/domain/preset_voices.py b/packages/domain/preset_voices.py index 42e9c53d1..c369c62ca 100755 --- a/packages/domain/preset_voices.py +++ b/packages/domain/preset_voices.py @@ -46,63 +46,68 @@ class PresetVoice: } -# 预置音色列表(阿里云 CosyVoice 真实可用音色) +# 预置音色列表(阿里云 CosyVoice v3-flash 真实可用音色) +# 参考: https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list PRESET_VOICES: list[PresetVoice] = [ PresetVoice( voice_id="longxiaochun_v3", name="龙小淳", - description="温柔女声,适合情感类内容", + description="知性积极女声,适合语音助手", gender="female", language="zh-CN", - tags=["温柔", "女声", "情感"], + preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a8fb29a2-7d44-9f16-9dab-f17fa806cf3d.mp3?Expires=1785716123&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=c74kFX%2BcDpmuBny2dPBkvak01oY%3D", + tags=["知性", "女声", "助手"], ), PresetVoice( voice_id="longxiaoxia_v3", name="龙小夏", - description="知性女声,适合新闻播报", + description="沉稳权威女声,适合新闻播报", gender="female", language="zh-CN", - tags=["知性", "女声", "播报"], + preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/d065a6da-de0d-9ad2-b64a-00aaf9aa3dea.mp3?Expires=1785716203&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=9hRh7xbmS8eDdiBDDFUatvad1jg%3D", + tags=["沉稳", "女声", "播报"], ), PresetVoice( - voice_id="longxiaochen_v3", - name="龙小晨", - description="磁性男声,适合有声书", + voice_id="longsanshu_v3", + name="龙三叔", + description="沉稳质感男声,适合有声书", gender="male", language="zh-CN", - tags=["磁性", "男声", "有声书"], + tags=["沉稳", "男声", "有声书"], ), PresetVoice( voice_id="longyue_v3", name="龙悦", - description="甜美女声,适合广告配音", + description="温暖磁性女声,适合广告配音", gender="female", language="zh-CN", - tags=["甜美", "女声", "广告"], + preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/1ea86db3-4dc9-927e-9b76-c83d89f6788c.mp3?Expires=1785716207&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=Vg0ckgSgPzgNQ%2FNdeJzrE%3D", + tags=["温暖", "女声", "广告"], ), PresetVoice( voice_id="longshu_v3", name="龙书", - description="沉稳男声,适合教育讲解", + description="沉稳青年男声,适合教育讲解", gender="male", language="zh-CN", + preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/6b47a5ce-ae21-995a-ac8b-5e55ed04ec45.mp3?Expires=1785716209&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=zm6WGmthpwTjf8PzgNQ%2FNdeJzrE%3D", tags=["沉稳", "男声", "教育"], ), PresetVoice( - voice_id="longjing_v3", - name="龙静", - description="优雅女声,适合纪录片解说", + voice_id="longyingjing_v3", + name="龙应静", + description="低调冷静女声,适合纪录片解说", gender="female", language="zh-CN", - tags=["优雅", "女声", "纪录片"], + tags=["冷静", "女声", "纪录片"], ), PresetVoice( - voice_id="longbo_v3", - name="龙博", - description="浑厚男声,适合科技类内容", + voice_id="longshuo_v3", + name="龙硕", + description="博才干练男声,适合科技类内容", gender="male", language="zh-CN", - tags=["浑厚", "男声", "科技"], + tags=["干练", "男声", "科技"], ), PresetVoice( voice_id="longtian_v3", @@ -110,6 +115,7 @@ PRESET_VOICES: list[PresetVoice] = [ description="活泼女声,适合短视频配音", gender="female", language="zh-CN", + preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a38e4c05-25bf-9640-92d6-0b4a14555cdb.mp3?Expires=1785716215&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=WtbsqqfR4A6OhpL%2B%2Fe0lh7YBMWM%3D", tags=["活泼", "女声", "短视频"], ), ] diff --git a/scripts/seed_test_voice_clones.py b/scripts/seed_test_voice_clones.py new file mode 100755 index 000000000..eb761020d --- /dev/null +++ b/scripts/seed_test_voice_clones.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Seed test voice clone profiles for staging E2E testing. + +Usage: + # On staging (inside Docker container): + docker exec -it staging-api python scripts/seed_test_voice_clones.py + + # Locally (requires DATABASE_URL env): + DATABASE_URL=postgresql+psycopg://... python scripts/seed_test_voice_clones.py + +Creates test VoiceCloneProfile records for known staging test accounts, +so that the frontend E2E tests can verify clone voice list and preview. +""" + +from __future__ import annotations + +import logging +import os +import sys +from datetime import datetime, timezone +from uuid import uuid4 + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +# Test accounts and their seed data +SEED_DATA = [ + { + "email": "18314979086@163.com", + "clones": [ + { + "name": "测试音色-温柔女声", + "description": "用于E2E测试的克隆音色(温柔女声)", + "voice_id": "longxiaochun_v3", + "gender": "female", + "language": "zh-CN", + }, + { + "name": "测试音色-磁性男声", + "description": "用于E2E测试的克隆音色(磁性男声)", + "voice_id": "longsanshu_v3", + "gender": "male", + "language": "zh-CN", + }, + ], + }, +] + + +def get_db_session(): + """Create a SQLAlchemy session from environment.""" + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + database_url = os.environ.get("DATABASE_URL", "") + if not database_url: + # Try loading from shared config + try: + from packages.shared.config import get_shared_settings + + settings = get_shared_settings() + database_url = settings.effective_database_url + except Exception: + logger.error("DATABASE_URL not set and could not load from config") + sys.exit(1) + + engine = create_engine(database_url) + Session = sessionmaker(bind=engine) + return Session() + except ImportError: + logger.error("sqlalchemy not installed") + sys.exit(1) + + +def find_user(session, email: str) -> dict | None: + """Find user by email.""" + from sqlalchemy import text + + result = session.execute(text("SELECT id, email, display_name FROM users WHERE email = :email"), {"email": email}) + row = result.fetchone() + if row: + return {"id": row[0], "email": row[1], "display_name": row[2]} + return None + + +def clone_exists(session, user_id: str, name: str) -> bool: + """Check if a clone profile with this name already exists for the user.""" + from sqlalchemy import text + + result = session.execute( + text("SELECT COUNT(*) FROM voice_clone_profiles WHERE user_id = :uid AND name = :name AND status != 'deleted'"), + {"uid": user_id, "name": name}, + ) + return result.scalar() > 0 + + +def create_clone_profile(session, user_id: str, clone_data: dict) -> str: + """Create a VoiceCloneProfile record.""" + from sqlalchemy import text + + profile_id = uuid4().hex + now = datetime.now(timezone.utc) + + session.execute( + text( + """ + INSERT INTO voice_clone_profiles + (id, user_id, name, description, source_audio_url, voice_id, + voice_model, language, gender, status, error_message, + retry_count, max_retries, metadata, created_at, updated_at) + VALUES + (:id, :uid, :name, :desc, :audio_url, :voice_id, + :voice_model, :lang, :gender, :status, '', + 0, 3, '{}', :now, :now) + """ + ), + { + "id": profile_id, + "uid": user_id, + "name": clone_data["name"], + "desc": clone_data.get("description", ""), + "audio_url": "", + "voice_id": clone_data["voice_id"], + "voice_model": "cosyvoice-v3-flash", + "lang": clone_data.get("language", "zh-CN"), + "gender": clone_data.get("gender", "unknown"), + "status": "ready", + "now": now, + }, + ) + return profile_id + + +def main(): + logger.info("=== Seed Test Voice Clones ===") + + session = get_db_session() + + total_created = 0 + total_skipped = 0 + + for account in SEED_DATA: + email = account["email"] + user = find_user(session, email) + + if not user: + logger.warning("User not found: %s — skipping", email) + continue + + logger.info("Found user: %s (id=%s, name=%s)", email, user["id"], user["display_name"]) + + for clone_data in account.get("clones", []): + name = clone_data["name"] + + if clone_exists(session, user["id"], name): + logger.info(" Clone '%s' already exists — skipping", name) + total_skipped += 1 + continue + + try: + profile_id = create_clone_profile(session, user["id"], clone_data) + logger.info(" Created clone '%s' (id=%s, voice_id=%s)", name, profile_id, clone_data["voice_id"]) + total_created += 1 + except Exception as e: + logger.error(" Failed to create clone '%s': %s", name, e) + session.rollback() + continue + + try: + session.commit() + logger.info("=== Done: %d created, %d skipped ===", total_created, total_skipped) + except Exception as e: + logger.error("Failed to commit: %s", e) + session.rollback() + sys.exit(1) + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/domain/test_preset_voices.py b/tests/unit/domain/test_preset_voices.py index 30d609605..4492c64ab 100755 --- a/tests/unit/domain/test_preset_voices.py +++ b/tests/unit/domain/test_preset_voices.py @@ -147,20 +147,20 @@ class TestPresetVoicesList: v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochun_v3") assert v.name == "龙小淳" assert v.gender == "female" - assert "温柔" in v.description + assert "知性" in v.description - def test_longxiaochen_voice(self): - v = next(v for v in PRESET_VOICES if v.voice_id == "longxiaochen_v3") - assert v.name == "龙小晨" + def test_longsanshu_voice(self): + v = next(v for v in PRESET_VOICES if v.voice_id == "longsanshu_v3") + assert v.name == "龙三叔" assert v.gender == "male" def test_male_voices_count(self): males = [v for v in PRESET_VOICES if v.gender == "male"] - assert len(males) == 3 # 龙小晨/龙书/龙博 + assert len(males) == 3 # 龙三叔/龙书/龙硕 def test_female_voices_count(self): females = [v for v in PRESET_VOICES if v.gender == "female"] - assert len(females) == 5 # 龙小淳/龙小夏/龙悦/龙静/龙甜 + assert len(females) == 5 # 龙小淳/龙小夏/龙悦/龙应静/龙甜 def test_all_have_tags(self): for v in PRESET_VOICES: @@ -200,7 +200,7 @@ class TestGetPresetVoiceById: assert v.name == "龙小淳" def test_get_existing_male(self): - v = get_preset_voice_by_id("longxiaochen_v3") + v = get_preset_voice_by_id("longsanshu_v3") assert v is not None assert v.gender == "male" diff --git a/tests/unit/test_preset_voices.py b/tests/unit/test_preset_voices.py old mode 100644 new mode 100755 index 07cb500ea..200374654 --- a/tests/unit/test_preset_voices.py +++ b/tests/unit/test_preset_voices.py @@ -129,11 +129,11 @@ class TestPresetVoicesConfig: expected_ids = { "longxiaochun_v3", "longxiaoxia_v3", - "longxiaochen_v3", + "longsanshu_v3", "longyue_v3", "longshu_v3", - "longjing_v3", - "longbo_v3", + "longyingjing_v3", + "longshuo_v3", "longtian_v3", } actual_ids = {v.voice_id for v in PRESET_VOICES} @@ -178,7 +178,7 @@ class TestPresetVoiceHelpers: """判断预置音色返回 True。""" assert is_preset_voice("longxiaochun_v3") is True assert is_preset_voice("longxiaoxia_v3") is True - assert is_preset_voice("longbo_v3") is True + assert is_preset_voice("longshuo_v3") is True def test_is_preset_voice_false(self) -> None: """判断非预置音色返回 False。""" diff --git a/tests/unit/test_preset_voices_domain.py b/tests/unit/test_preset_voices_domain.py old mode 100644 new mode 100755 index 04534e963..112d64eff --- a/tests/unit/test_preset_voices_domain.py +++ b/tests/unit/test_preset_voices_domain.py @@ -132,7 +132,7 @@ class TestIsPresetVoice: """判断是否预置音色测试""" def test_existing_is_preset(self): - assert is_preset_voice("longxiaochen_v3") is True + assert is_preset_voice("longsanshu_v3") is True def test_nonexistent_not_preset(self): assert is_preset_voice("custom_voice_123") is False @@ -149,11 +149,11 @@ class TestPresetVoiceSamples: [ ("longxiaochun_v3", "龙小淳", "female"), ("longxiaoxia_v3", "龙小夏", "female"), - ("longxiaochen_v3", "龙小晨", "male"), + ("longsanshu_v3", "龙三叔", "male"), ("longyue_v3", "龙悦", "female"), ("longshu_v3", "龙书", "male"), - ("longjing_v3", "龙静", "female"), - ("longbo_v3", "龙博", "male"), + ("longyingjing_v3", "龙应静", "female"), + ("longshuo_v3", "龙硕", "male"), ("longtian_v3", "龙甜", "female"), ], ) -- 2.54.0 From b13dd6ef182663de2f52664504fc74c714b6d6f5 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 2 Aug 2026 00:26:29 +0000 Subject: [PATCH 2/4] style: auto-format with black + isort + prettier [skip ci-format-check] --- scripts/seed_test_voice_clones.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/seed_test_voice_clones.py b/scripts/seed_test_voice_clones.py index eb761020d..f8d74bbf3 100755 --- a/scripts/seed_test_voice_clones.py +++ b/scripts/seed_test_voice_clones.py @@ -103,8 +103,7 @@ def create_clone_profile(session, user_id: str, clone_data: dict) -> str: now = datetime.now(timezone.utc) session.execute( - text( - """ + text(""" INSERT INTO voice_clone_profiles (id, user_id, name, description, source_audio_url, voice_id, voice_model, language, gender, status, error_message, @@ -113,8 +112,7 @@ def create_clone_profile(session, user_id: str, clone_data: dict) -> str: (:id, :uid, :name, :desc, :audio_url, :voice_id, :voice_model, :lang, :gender, :status, '', 0, 3, '{}', :now, :now) - """ - ), + """), { "id": profile_id, "uid": user_id, -- 2.54.0 From 6eea1a13a3463ed739d533653225510ed310e189 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 2 Aug 2026 08:35:38 +0800 Subject: [PATCH 3/4] ci: retry (pip timeout on previous run) -- 2.54.0 From 6397a25e4112dcc20082352cd4e4ac1786884857 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 2 Aug 2026 08:47:09 +0800 Subject: [PATCH 4/4] ci: rebase on latest develop (include #1214 #1215) -- 2.54.0