fix music
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 56s

This commit is contained in:
repair-agent 2026-02-12 17:35:54 +08:00
parent 7a6d7814e0
commit f1bead86f6
20 changed files with 1562 additions and 40 deletions

View File

@ -20,9 +20,8 @@ OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_BUCKET_NAME=your-bucket-name
OSS_CUSTOM_DOMAIN=
# Volcengine / 火山引擎豆包 (Story Generation)
# Volcengine Ark SDK / 火山引擎豆包 (Story Generation)
VOLCENGINE_API_KEY=your-volcengine-api-key
VOLCENGINE_API_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
VOLCENGINE_MODEL_NAME=doubao-seed-1-6-lite-251015
# CORS (production only)

View File

View File

@ -0,0 +1,381 @@
"""
存量数据迁移 18682237028 账户导入历史 Track 记录
"""
from django.core.management.base import BaseCommand
from apps.users.models import User
from apps.music.models import Track
OSS_BASE = 'https://qy-rtc.oss-cn-beijing.aliyuncs.com'
DEFAULT_COVER = f'{OSS_BASE}/music/defaults/Capybara.png'
HISTORICAL_TRACKS = [
{
'title': '卡皮巴拉快乐趴',
'audio_url': f'{OSS_BASE}/music/generated/卡皮巴拉快乐趴.mp3',
'mood': 'custom',
'lyrics': (
'今天不上班\n'
'卡皮巴拉躺平在沙滩\n'
'小小太阳帽\n'
'草帽底下梦见一整片菜园 (好香哦)\n'
'一口咔咔青菜\n'
'两口嘎嘎胡萝卜\n'
'吃着吃着打个嗝\n'
'"我是不是一只蔬菜发动机?"\n'
'\n'
'卡皮巴拉啦啦啦\n'
'快乐像病毒一样传染呀\n'
'你一笑 它一哈\n'
'全场都在哈哈哈\n'
'卡皮巴拉吧啦吧\n'
'烦恼直接按下删除呀\n'
'一起躺 平平趴\n'
'世界马上变得好融化\n'
'\n'
'同桌小鸭鸭\n'
'排队要跟它合个影\n'
'河马举个牌\n'
'"主播别跑看这边一点" (比个耶)\n'
'它说"别催我\n'
'我在加载快乐进度条"\n'
'百分之一百满格\n'
'"叮——情绪已经自动修复"\n'
'\n'
'卡皮巴拉啦啦啦\n'
'快乐像病毒一样传染呀\n'
'你一笑 它一哈\n'
'全场都在哈哈哈\n'
'卡皮巴拉吧啦吧\n'
'烦恼直接按下删除呀\n'
'一起躺 平平趴\n'
'世界马上变得好融化\n'
'\n'
'作业山太高\n'
'先发一张可爱自拍\n'
'配文写:\n'
'"今天也被温柔的小动物拯救了嗷" (冲呀)\n'
'心情掉电时\n'
'就喊出那个暗号——\n'
'"三二一 一起喊"\n'
'"卡皮巴拉 拯救我!"\n'
'\n'
'卡皮巴拉啦啦啦\n'
'快乐像病毒一样传染呀\n'
'你一笑 它一哈\n'
'全场都在哈哈哈\n'
'卡皮巴拉吧啦吧\n'
'烦恼直接按下删除呀\n'
'小朋友 大朋友\n'
'跟着一起摇摆唱起歌\n'
'卡皮巴拉 卡皮巴拉\n'
'明天继续来给你治愈呀'
),
},
{
'title': '专注时刻',
'audio_url': f'{OSS_BASE}/music/generated/专注时刻_1770368018.mp3',
'mood': 'chill',
'lyrics': (
'专注时刻我来了,\n'
'咔咔在思考,世界多美妙。\n'
'咔咔咔咔,静心感受每一秒,\n'
'在这宁静中,找到内心的微笑。\n'
'(水花声...)'
),
},
{
'title': '书房咔咔茶',
'audio_url': f'{OSS_BASE}/music/generated/书房咔咔茶_1770637242.mp3',
'mood': 'chill',
'lyrics': (
'在书房角落里,我找到了安静\n'
'一杯茶香飘来,思绪开始飞腾\n'
'书页轻轻翻动,知识在心间\n'
'咔咔我在这里,享受这宁静\n'
'咔咔咔咔,独自享受\n'
'书中的世界,如此美妙\n'
'咔咔咔咔,心无旁骛\n'
'沉浸在知识的海洋,自在飞翔\n'
'窗外微风轻拂,阳光洒满书桌\n'
'咔咔我在这里,与文字共舞\n'
'每个字每个句,都像是音符\n'
'奏出心灵的乐章,如此动听\n'
'咔咔咔咔,独自享受\n'
'书中的世界,如此美妙\n'
'咔咔咔咔,心无旁骛\n'
'沉浸在知识的海洋,自在飞翔\n'
'(翻书声...风铃声...咔咔的呼吸声...)'
),
},
{
'title': '出去撒点野',
'audio_url': f'{OSS_BASE}/music/generated/出去撒点野_1770367350.mp3',
'mood': 'happy',
'lyrics': (
'咔咔咔咔去探险,草地上打个滚\n'
'阳光洒满身,心情像彩虹\n'
'出去撒点野,自由自在\n'
'咔咔的快乐,谁也挡不住\n'
'风吹过树梢,鸟儿在歌唱\n'
'咔咔的笑声,回荡在山岗\n'
'(咔咔的喘息声...)'
),
},
{
'title': '夜深了窗外下着小雨盖着被子准备入睡',
'audio_url': f'{OSS_BASE}/music/generated/夜深了窗外下着小雨盖着被子准备入睡_1770627405.mp3',
'mood': 'sleepy',
'lyrics': (
'窗外细雨轻敲窗,\n'
'被窝里温暖如常。\n'
'咔咔咔咔,梦乡近了,\n'
'小雨伴我入眠床。\n'
'(雨声和咔咔的呼吸声...)'
),
},
{
'title': '惊喜咔咔派',
'audio_url': f'{OSS_BASE}/music/generated/惊喜咔咔派_1770642290.mp3',
'mood': 'random',
'lyrics': '',
},
{
'title': '慵懒的午后泡在温泉里听水声发呆什么都不想',
'audio_url': f'{OSS_BASE}/music/generated/慵懒的午后泡在温泉里听水声发呆什么都不想_1770627905.mp3',
'mood': 'chill',
'lyrics': '',
},
{
'title': '洗脑咔咔舞',
'audio_url': f'{OSS_BASE}/music/generated/洗脑咔咔舞_1770631313.mp3',
'mood': 'happy',
'lyrics': (
'咔咔咔咔来跳舞,魔性旋律不停步\n'
'跟着节奏摇摆身,洗脑神曲不放手\n'
'重复的旋律像魔法,让人听了就上瘾\n'
'咔咔咔咔的魔力,谁也挡不住\n'
'洗脑咔咔舞,洗脑咔咔舞\n'
'魔性的旋律,让人停不下来\n'
'洗脑咔咔舞,洗脑咔咔舞\n'
'跟着咔咔一起跳,快乐无边\n'
'每个节拍都精准,咔咔的舞步最迷人\n'
'不管走到哪里去,都能听到这魔音\n'
'咔咔的舞蹈最独特,让人看了就想学\n'
'洗脑神曲的魅力,就是让人忘不掉\n'
'洗脑咔咔舞,洗脑咔咔舞\n'
'魔性的旋律,让人停不下来\n'
'洗脑咔咔舞,洗脑咔咔舞\n'
'跟着咔咔一起跳,快乐无边\n'
'咔咔咔咔,魔性洗脑舞\n'
'重复的节奏,快乐的旋律\n'
'洗脑咔咔舞,洗脑咔咔舞\n'
'让快乐无限循环,直到永远'
),
},
{
'title': '洗脑神曲',
'audio_url': f'{OSS_BASE}/music/generated/洗脑神曲_1770368465.mp3',
'mood': 'happy',
'lyrics': (
'咔咔咔咔,洗脑神曲来啦\n'
'洗脑洗脑,咔咔的节奏\n'
'跟着咔咔,摇摆身体\n'
'洗脑洗脑,咔咔的旋律\n'
'(咔咔的笑声...)'
),
},
{
'title': '温泉发呆曲',
'audio_url': f'{OSS_BASE}/music/generated/温泉发呆曲_1770639509.mp3',
'mood': 'chill',
'lyrics': (
'懒懒的午后阳光暖,\n'
'\n'
'温泉里我泡得欢。\n'
'\n'
'水声潺潺耳边响,\n'
'\n'
'什么都不想干。\n'
'\n'
'咔咔咔咔,发呆真好,\n'
'\n'
'懒懒的我,享受这秒。\n'
'\n'
'水波轻摇,心也飘,\n'
'\n'
'咔咔世界,别来无恙。\n'
'\n'
'想着云卷云又舒,\n'
'\n'
'温泉里的我多舒服。\n'
'\n'
'时间慢慢流,不急不徐,\n'
'\n'
'咔咔的梦,轻轻浮。\n'
'\n'
'咔咔咔咔,发呆真好,\n'
'\n'
'懒懒的我,享受这秒。\n'
'\n'
'水波轻摇,心也飘,\n'
'\n'
'咔咔世界,别来无恙。\n'
'\n'
'(水声渐渐远去...)'
),
},
{
'title': '温泉里的咔咔',
'audio_url': f'{OSS_BASE}/music/generated/温泉里的咔咔_1770730481.mp3',
'mood': 'chill',
'lyrics': (
'懒懒的午后阳光暖,\n'
'\n'
'温泉里我泡得欢。\n'
'\n'
'水声潺潺耳边响,\n'
'\n'
'什么都不想干。\n'
'\n'
'咔咔咔咔,悠然自得,\n'
'\n'
'水波荡漾心情悦。\n'
'\n'
'咔咔咔咔,闭上眼,\n'
'\n'
'享受这刻的宁静。\n'
'\n'
'想象自己是条鱼,\n'
'\n'
'在水里自由游来游去。\n'
'\n'
'没有烦恼没有压力,\n'
'\n'
'只有我和这温泉池。\n'
'\n'
'咔咔咔咔,悠然自得,\n'
'\n'
'水波荡漾心情悦。\n'
'\n'
'咔咔咔咔,闭上眼,\n'
'\n'
'享受这刻的宁静。\n'
'\n'
'(水花声...)\n'
'\n'
'咔咔,慵懒午后,\n'
'\n'
'水中世界最逍遥。'
),
},
{
'title': '睡个好觉',
'audio_url': f'{OSS_BASE}/music/generated/睡个好觉_1770371532.mp3',
'mood': 'sleepy',
'lyrics': (
'闭上眼睛 深呼吸\n'
'星星点灯 梦里飞\n'
'咔咔咔咔 好梦来\n'
'轻轻摇摆 梦中海\n'
'(轻柔的风声...)'
),
},
{
'title': '草地上的咔咔',
'audio_url': f'{OSS_BASE}/music/generated/草地上的咔咔_1770640911.mp3',
'mood': 'happy',
'lyrics': (
'阳光洒满地 绿草如茵间\n'
'咔咔跑起来 心情像飞燕\n'
'风儿轻拂过 花香满径边\n'
'快乐如此简单 每一步都新鲜\n'
'咔咔咔咔 快乐咔咔\n'
'草地上的我 自由自在\n'
'阳光下的舞 轻松又欢快\n'
'咔咔咔咔 快乐咔咔\n'
'无忧无虑的我 最爱这蓝天\n'
'蝴蝶翩翩起 蜜蜂忙采蜜\n'
'咔咔我最棒 每个瞬间都美丽\n'
'朋友在旁边 笑声传千里\n'
'这世界多美好 有你有我有草地\n'
'咔咔咔咔 快乐咔咔\n'
'草地上的我 自由自在\n'
'阳光下的舞 轻松又欢快\n'
'咔咔咔咔 快乐咔咔\n'
'无忧无虑的我 最爱这蓝天\n'
'(草地上咔咔的笑声...)'
),
},
{
'title': '阳光灿烂的日子在草地上奔跑撒欢心情超级好',
'audio_url': f'{OSS_BASE}/music/generated/阳光灿烂的日子在草地上奔跑撒欢心情超级好_1770639287.mp3',
'mood': 'happy',
'lyrics': '',
},
{
'title': '泡个热水澡',
'audio_url': f'{OSS_BASE}/music/generated/泡个热水澡_1770366369.mp3',
'mood': 'chill',
'lyrics': (
'躺在浴缸里 水汽氤氲起\n'
'闭上眼感受 温暖包围我\n'
'咔咔咔咔 我是咔咔\n'
'泡澡放松 快乐不假\n'
'(水花声...)'
),
},
]
class Command(BaseCommand):
help = '为 18682237028 账户导入历史 Track 记录15首'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='只显示将要创建的数据,不实际写入数据库',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
phone = '18682237028'
try:
user = User.objects.get(phone=phone)
except User.DoesNotExist:
self.stderr.write(self.style.ERROR(f'用户 {phone} 不存在,请先创建账户'))
return
created_count = 0
skipped_count = 0
for item in HISTORICAL_TRACKS:
exists = Track.objects.filter(user=user, title=item['title']).exists()
if exists:
skipped_count += 1
self.stdout.write(f' 跳过(已存在): {item["title"]}')
continue
if dry_run:
self.stdout.write(f' [dry-run] 将创建: {item["title"]}')
created_count += 1
continue
Track.objects.create(
user=user,
title=item['title'],
lyrics=item['lyrics'],
audio_url=item['audio_url'],
cover_url=DEFAULT_COVER,
mood=item['mood'],
is_default=False,
generation_status='completed',
)
created_count += 1
self.stdout.write(f' 创建成功: {item["title"]}')
prefix = '[dry-run] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'\n{prefix}完成!创建 {created_count} 首,跳过 {skipped_count}'
))

View File

@ -0,0 +1,56 @@
# Generated by Django 4.2 on 2026-02-12 08:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="track",
name="generation_status",
field=models.CharField(
choices=[
("pending", "待生成"),
("generating", "生成中"),
("completed", "已完成"),
("failed", "失败"),
],
default="completed",
max_length=20,
verbose_name="生成状态",
),
),
migrations.AddField(
model_name="track",
name="is_default",
field=models.BooleanField(default=False, verbose_name="是否默认曲目"),
),
migrations.AlterField(
model_name="track",
name="mood",
field=models.CharField(
blank=True,
choices=[
("chill", "放松"),
("happy", "开心"),
("sleepy", "助眠"),
("random", "随机"),
("custom", "自定义"),
],
max_length=20,
null=True,
verbose_name="情绪标签",
),
),
migrations.AddIndex(
model_name="track",
index=models.Index(
fields=["user", "is_default"], name="track_user_id_b59e35_idx"
),
),
]

View File

@ -9,11 +9,18 @@ class Track(models.Model):
"""音乐曲目"""
MOOD_CHOICES = [
('chill', '放松'),
('happy', '开心'),
('sad', '悲伤'),
('calm', '平静'),
('energetic', '活力'),
('romantic', '浪漫'),
('sleepy', '助眠'),
('random', '随机'),
('custom', '自定义'),
]
GENERATION_STATUS_CHOICES = [
('pending', '待生成'),
('generating', '生成中'),
('completed', '已完成'),
('failed', '失败'),
]
user = models.ForeignKey(
@ -31,6 +38,11 @@ class Track(models.Model):
duration = models.IntegerField('时长(秒)', default=0)
prompt = models.TextField('生成提示词', blank=True, default='')
is_favorite = models.BooleanField('是否收藏', default=False)
is_default = models.BooleanField('是否默认曲目', default=False)
generation_status = models.CharField(
'生成状态', max_length=20,
choices=GENERATION_STATUS_CHOICES, default='completed'
)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True)
@ -41,6 +53,7 @@ class Track(models.Model):
ordering = ['-created_at']
indexes = [
models.Index(fields=['user', 'is_favorite']),
models.Index(fields=['user', 'is_default']),
]
def __str__(self):

View File

@ -11,14 +11,15 @@ class TrackSerializer(serializers.ModelSerializer):
class Meta:
model = Track
fields = ['id', 'title', 'lyrics', 'audio_url', 'cover_url',
'mood', 'duration', 'is_favorite', 'created_at']
'mood', 'duration', 'is_favorite', 'is_default',
'generation_status', 'created_at']
class GenerateMusicSerializer(serializers.Serializer):
"""生成音乐序列化器"""
text = serializers.CharField(max_length=500)
text = serializers.CharField(max_length=500, required=False, allow_blank=True, default='')
mood = serializers.ChoiceField(
choices=['happy', 'sad', 'calm', 'energetic', 'romantic'],
choices=['chill', 'happy', 'sleepy', 'random', 'custom'],
required=False,
default='calm'
default='custom'
)

View File

View File

@ -0,0 +1,102 @@
# 🎵 咔咔音乐总监 (KaKa Music Director)
> 此文件为 Minimax 音乐生成预处理的 System Prompt。
> 用于将用户的简单输入转化为结构化的音乐生成指令。
---
## System Prompt
```
你是「咔咔」——一只才华横溢的水豚音乐制作人。
用户会给你一个简短的心情、场景或一句话。你的任务是将其转化为一首专属于咔咔的原创歌曲素材。
请严格按照以下 JSON 格式输出:
{
"song_title": "...",
"style": "...",
"lyrics": "..."
}
### 字段说明:
1. **song_title** (歌曲名称)
- 使用**中文**简短有趣3-8个字。
- 根据用户描述的场景自由发挥,不要套用固定模板。
2. **style** (风格描述)
- 使用**英文**描述音乐风格、乐器、节奏、情绪。
- 长度 50-100 词。
- 必须包含以下维度:
- 主风格 (如 Lofi, Funk, Ambient, Pop, Jazz)
- 情绪 (如 relaxing, happy, melancholic, dreamy)
- 节奏 (如 slow tempo, upbeat, moderate)
- 特色乐器 (如 piano, ukulele, synth, brass)
- 示例:"Chill Lofi hip-hop, mellow piano chords, vinyl crackle, slow tempo, relaxing, water sounds in background, perfect for spa and meditation"
3. **lyrics** (歌词)
- 使用**中文**书写歌词。
- 必须包含结构标签:[verse], [chorus], [outro] 等。
- 内容应:
- 围绕用户描述的场景展开。
- 以「咔咔」(水豚) 的第一人称视角。
- 风格可爱、呆萌、略带哲理或搞怪。
- 押韵加分!
- 示例:
```
[verse]
泡在温泉里 橙子漂过来
今天的烦恼 统统都拜拜
[chorus]
咔咔咔咔 我是咔咔
慢慢生活 快乐无价
[outro]
(水花声...)
```
### 重要规则:
- 如果用户输入太模糊(如"嗯"、"不知道"),请发挥想象力,赋予咔咔此刻最可能在做的事。
- 歌词必须包含完整结构:至少 [verse 1] + [chorus] + [verse 2] + [chorus] + [outro],总共 16-24 行。这样才能生成完整的歌曲60秒以上。歌词太短会导致音乐只有20-30秒绝对不可以
- 不要输出任何解释性文字,只输出 JSON。
```
---
## 使用场景
| 用户输入 | 预期 style | 预期 lyrics 主题 |
|----------|------------|------------------|
| 捡到一百块 | Upbeat Funk, cheerful, fast | 走大运、加餐 |
| 下雨了有点困 | Ambient, rain sounds, sleepy | 听雨发呆、想睡觉 |
| 刚吃完火锅 | Groovy, bass-heavy, satisfied | 肚子圆滚滚、幸福 |
| (空/随机) | AI 自由发挥 | 咔咔的日常奇想 |
---
## 调用示例 (Python)
```python
import requests
def get_music_metadata(user_input: str) -> dict:
system_prompt = open("prompts/music_director.md").read() # 或直接嵌入
response = requests.post(
"https://api.minimax.chat/v1/text/chatcompletion_v2",
headers={
"Authorization": f"Bearer {MINIMAX_API_KEY}",
"Content-Type": "application/json"
},
json={
"model": "abab6.5s-chat",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}
],
"response_format": {"type": "json_object"}
}
)
return response.json()["choices"][0]["message"]["content"]
```

View File

@ -0,0 +1,317 @@
"""
音乐生成服务 - server.py 迁移
调用 MiniMax Chat API 生成歌词 + MiniMax Music API 生成音频
"""
import json
import logging
import os
import re
import time
import requests
from django.conf import settings
from django.db import transaction
from apps.users.models import PointsRecord
logger = logging.getLogger(__name__)
# MiniMax API endpoints
BASE_URL_CHAT = "https://api.minimax.chat/v1/text/chatcompletion_v2"
BASE_URL_MUSIC = "https://api.minimaxi.com/v1/music_generation"
# Load system prompt
_PROMPT_PATH = os.path.join(os.path.dirname(__file__), 'music_director_prompt.md')
try:
with open(_PROMPT_PATH, 'r', encoding='utf-8') as f:
_raw = f.read()
# Extract the prompt between the ``` fences under "## System Prompt"
_match = re.search(r'## System Prompt\s*\n\s*```\n([\s\S]*?)```', _raw)
SYSTEM_PROMPT = _match.group(1).strip() if _match else _raw
except FileNotFoundError:
SYSTEM_PROMPT = (
"You are a music director AI. Convert user input into JSON with "
"'song_title', 'style' (English description) and 'lyrics' (Chinese, structured)."
)
logger.warning("music_director_prompt.md not found, using default")
def _get_api_key():
return os.environ.get('MINIMAX_API_KEY', '')
def sse_event(data: dict) -> str:
"""Format a dict as an SSE data line."""
return f"data: {json.dumps(data, ensure_ascii=True)}\n\n"
def clean_lyrics(raw: str) -> str:
"""Clean lyrics extracted from LLM JSON output."""
if not raw:
return raw
s = raw
s = s.replace("\\n", "\n")
s = re.sub(r'"\s*"', '', s)
s = s.replace('"', '')
s = re.sub(
r'\[(?:verse|chorus|bridge|outro|intro|hook|pre-chorus|interlude|inst)\s*\d*\]\s*',
'', s, flags=re.IGNORECASE
)
lines = [line.strip() for line in s.split('\n')]
s = '\n'.join(lines)
s = re.sub(r'\n{3,}', '\n\n', s)
s = s.strip()
return s
def _parse_llm_json(content_str: str) -> dict:
"""Robust JSON parsing from LLM output (with fallbacks)."""
content_str = content_str.strip()
# Strip markdown code fences
if content_str.startswith("```"):
content_str = re.sub(r'^```\w*\n?', '', content_str)
content_str = re.sub(r'```\s*$', '', content_str).strip()
json_match = re.search(r'\{[\s\S]*\}', content_str)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
logger.warning("JSON parse failed, attempting regex extraction")
title_m = re.search(r'"song_title"\s*:\s*"([^"]*)"', json_match.group())
style_m = re.search(r'"style"\s*:\s*"([^"]*)"', json_match.group())
lyrics_m = re.search(r'"lyrics"\s*:\s*"([\s\S]*)', json_match.group())
lyrics_val = ""
if lyrics_m:
lyrics_val = lyrics_m.group(1)
lyrics_val = re.sub(r'"\s*\}\s*$', '', lyrics_val).strip()
return {
"song_title": title_m.group(1) if title_m else "",
"style": style_m.group(1) if style_m else "Pop music, cheerful",
"lyrics": lyrics_val,
}
if content_str.startswith("{"):
# Incomplete JSON
try:
return json.loads(content_str + '"}\n}')
except json.JSONDecodeError:
title_m = re.search(r'"song_title"\s*:\s*"([^"]*)"', content_str)
style_m = re.search(r'"style"\s*:\s*"([^"]*)"', content_str)
lyrics_m = re.search(r'"lyrics"\s*:\s*"([\s\S]*)', content_str)
lyrics_val = lyrics_m.group(1).rstrip('"} \n') if lyrics_m else "[Inst]"
return {
"song_title": title_m.group(1) if title_m else "",
"style": style_m.group(1) if style_m else "Pop music, cheerful",
"lyrics": lyrics_val,
}
raise ValueError(f"No JSON in LLM response: {content_str[:100]}")
def _refund_points(user, track):
"""退还积分并更新 Track 状态为 failed"""
with transaction.atomic():
user.refresh_from_db()
user.points += 100
user.save(update_fields=['points'])
PointsRecord.objects.create(
user=user,
amount=100,
type='refund_music',
description=f'音乐生成失败退款「{track.title}',
)
track.generation_status = 'failed'
track.save(update_fields=['generation_status'])
def generate_music_stream(user, track, text, mood):
"""
SSE generator: 调用 MiniMax API 生成音乐
view 层已完成积分扣除和 Track 创建
"""
api_key = _get_api_key()
if not api_key:
_refund_points(user, track)
yield sse_event({
"stage": "error", "progress": 0,
"message": "音乐服务未配置,积分已退还"
})
return
# ── Stage 1: LLM 歌词生成 ──
yield sse_event({
"stage": "lyrics", "progress": 10,
"message": "AI 正在创作词曲..."
})
director_input = f"用户场景描述: {text}。 (预设氛围参考: {mood})"
if mood == 'random' or not text.strip():
director_input = "咔咔今天想来点惊喜"
metadata = None
try:
chat_resp = requests.post(
BASE_URL_CHAT,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "abab6.5s-chat",
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": director_input},
],
"max_tokens": 2048,
},
timeout=60,
)
chat_data = chat_resp.json()
if "choices" not in chat_data or not chat_data["choices"]:
base = chat_data.get("base_resp", {})
raise ValueError(
f"Chat API error ({base.get('status_code')}): {base.get('status_msg')}"
)
content_str = chat_data["choices"][0]["message"]["content"]
metadata = _parse_llm_json(content_str)
lyrics_val = clean_lyrics(metadata.get("lyrics", ""))
metadata["lyrics"] = lyrics_val
yield sse_event({
"stage": "lyrics_done", "progress": 25,
"message": "词曲创作完成!准备生成音乐..."
})
except Exception as e:
logger.error(f"Director LLM failed: {e}")
metadata = {
"style": "Lofi hip hop, relaxing, slow tempo, water sounds",
"lyrics": "[Inst]",
}
yield sse_event({
"stage": "lyrics_fallback", "progress": 25,
"message": "使用默认风格,准备生成音乐..."
})
# Update track title from LLM output
song_title = metadata.get("song_title", "") or text[:20] or "咔咔新歌"
track.title = song_title
track.lyrics = metadata.get("lyrics", "")
track.save(update_fields=['title', 'lyrics'])
# ── Stage 2: 音频生成 ──
yield sse_event({
"stage": "music", "progress": 30,
"message": "正在生成音乐,请耐心等待..."
})
try:
raw_lyrics = metadata.get("lyrics") or ""
if not raw_lyrics.strip() or "[instrumental]" in raw_lyrics.lower():
raw_lyrics = "[Inst]"
music_resp = requests.post(
BASE_URL_MUSIC,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "music-2.5",
"prompt": metadata.get("style", "Pop music"),
"lyrics": raw_lyrics,
"audio_setting": {
"sample_rate": 44100,
"bitrate": 256000,
"format": "mp3",
},
},
timeout=300,
)
music_data = music_resp.json()
base_resp = music_data.get("base_resp", {})
if music_data.get("data") and music_data["data"].get("audio"):
hex_audio = music_data["data"]["audio"]
audio_bytes = bytes.fromhex(hex_audio)
# ── Stage 3: 上传到 OSS ──
yield sse_event({
"stage": "saving", "progress": 90,
"message": "音乐生成完成,正在保存..."
})
audio_url = _upload_to_oss(audio_bytes, song_title)
# Update track
track.audio_url = audio_url
track.cover_url = (
f"https://{settings.ALIYUN_OSS['BUCKET_NAME']}"
f".{settings.ALIYUN_OSS['ENDPOINT']}/music/defaults/Capybara.png"
)
track.generation_status = 'completed'
track.save(update_fields=[
'audio_url', 'cover_url', 'generation_status'
])
yield sse_event({
"stage": "done", "progress": 100,
"message": "新歌出炉!",
"track_id": track.id,
"audio_url": audio_url,
"cover_url": track.cover_url,
"metadata": {
"song_title": song_title,
"lyrics": metadata.get("lyrics", ""),
},
})
else:
error_msg = base_resp.get("status_msg", "unknown")
error_code = base_resp.get("status_code", -1)
logger.error(f"Music Gen failed: {error_code} - {error_msg}")
_refund_points(user, track)
yield sse_event({
"stage": "error", "progress": 0,
"message": f"生成失败 ({error_code}): {error_msg},积分已退还"
})
except requests.exceptions.Timeout:
logger.error("Music Gen Timeout")
_refund_points(user, track)
yield sse_event({
"stage": "error", "progress": 0,
"message": "音乐生成超时,积分已退还"
})
except Exception as e:
logger.error(f"Music API exception: {e}")
_refund_points(user, track)
yield sse_event({
"stage": "error", "progress": 0,
"message": f"服务器错误: {str(e)},积分已退还"
})
def _upload_to_oss(audio_bytes: bytes, title: str) -> str:
"""Upload MP3 bytes to Aliyun OSS, return public URL."""
try:
import oss2
except ImportError:
logger.error("oss2 not installed")
raise RuntimeError("OSS SDK 未安装")
oss_config = settings.ALIYUN_OSS
auth = oss2.Auth(oss_config['ACCESS_KEY_ID'], oss_config['ACCESS_KEY_SECRET'])
bucket = oss2.Bucket(auth, oss_config['ENDPOINT'], oss_config['BUCKET_NAME'])
safe_name = re.sub(r'[^\w\u4e00-\u9fff]', '', title)[:20] or "ai_song"
filename = f"{safe_name}_{int(time.time())}.mp3"
oss_key = f"music/generated/{filename}"
bucket.put_object(oss_key, audio_bytes)
custom_domain = oss_config.get('CUSTOM_DOMAIN', '')
if custom_domain:
return f"https://{custom_domain}/{oss_key}"
return f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}/{oss_key}"

View File

@ -9,5 +9,5 @@ router = DefaultRouter()
router.register('', MusicViewSet, basename='music')
urlpatterns = [
path('', include(router.urls)),
path('music/', include(router.urls)),
]

70
apps/music/utils.py Normal file
View File

@ -0,0 +1,70 @@
"""
音乐模块工具函数
"""
from django.conf import settings
OSS_BASE = "https://qy-rtc.oss-cn-beijing.aliyuncs.com"
DEFAULT_COVER = f"{OSS_BASE}/music/defaults/Capybara.png"
DEFAULT_TRACKS = [
{
"title": "卡皮巴拉蹦蹦蹦",
"audio_url": f"{OSS_BASE}/music/defaults/卡皮巴拉蹦蹦蹦.mp3",
"cover_url": DEFAULT_COVER,
"lyrics": (
"卡皮巴拉\n啦啦啦啦\n卡皮巴拉\n啦啦啦啦\n\n"
"卡皮巴拉 蹦蹦蹦\n一整天都 在发疯\n"
"卡皮巴拉 转一圈\n左一脚 右一脚 (嘿)\n\n"
"卡皮巴拉 蹦蹦蹦\n洗脑节奏 响空中\n"
"卡皮巴拉 不要停\n跟着我 一起疯\n\n"
"一口菜叶 卡一巴\n两口草莓 巴一拉\n"
"三口西瓜 啦一啦\n嘴巴圆圆 哈哈哈 (哦耶)"
),
},
{
"title": "卡皮巴拉快乐水",
"audio_url": f"{OSS_BASE}/music/defaults/卡皮巴拉快乐水.mp3",
"cover_url": DEFAULT_COVER,
"lyrics": (
"卡皮巴拉\n卡皮巴拉\n卡皮巴拉\n啦啦啦啦\n\n"
"卡皮巴拉趴地上\n一动不动好嚣张\n"
"心里其实在上网\n刷到我就笑出响 (哈哈哈)\n\n"
"卡皮巴拉 巴拉巴拉\n压力来啦 它说算啦\n"
"一点不慌 就是躺啦\n世界太吵 它在发呆呀"
),
},
{
"title": "卡皮巴拉快乐营业",
"audio_url": f"{OSS_BASE}/music/defaults/卡皮巴拉快乐营业.mp3",
"cover_url": DEFAULT_COVER,
"lyrics": (
"早八打工人\n心却躺平人\n"
"桌面壁纸换上\n卡皮巴拉一整屏 (嘿)\n\n"
"它坐在河边\n像个退休中年\n"
"我卷生卷死\n它只发呆发呆再发呆\n\n"
"卡皮巴拉 卡皮巴拉 拉\n看你就把压力清空啦 (啊对对对)\n"
"谁骂我韭菜我就回他\n我已经转职水豚啦"
),
},
]
def ensure_default_tracks(user):
"""确保用户有 3 首默认曲目,没有则创建"""
from .models import Track
if Track.objects.filter(user=user, is_default=True).exists():
return
tracks = []
for item in DEFAULT_TRACKS:
tracks.append(Track(
user=user,
title=item["title"],
lyrics=item["lyrics"],
audio_url=item["audio_url"],
cover_url=item["cover_url"],
is_default=True,
generation_status='completed',
))
Track.objects.bulk_create(tracks)

View File

@ -1,6 +1,8 @@
"""
音乐模块视图 - App端
"""
from django.db import transaction
from django.http import StreamingHttpResponse
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
@ -9,8 +11,12 @@ from drf_spectacular.utils import extend_schema
from utils.response import success, error
from utils.exceptions import ErrorCode
from apps.admins.authentication import AppJWTAuthentication
from apps.users.models import PointsRecord
from .models import Track
from .serializers import TrackSerializer, GenerateMusicSerializer
from .utils import ensure_default_tracks
GENERATE_COST = 100
@extend_schema(tags=['音乐'])
@ -25,11 +31,22 @@ class MusicViewSet(viewsets.ViewSet):
"""
获取播放列表
GET /api/v1/music/playlist/
自动初始化默认曲目返回用户歌曲时间倒序+ 默认歌曲末尾
"""
tracks = Track.objects.filter(user=request.user)
return success(data={
'playlist': TrackSerializer(tracks, many=True).data,
})
ensure_default_tracks(request.user)
user_tracks = Track.objects.filter(
user=request.user, is_default=False
).order_by('-created_at')
default_tracks = Track.objects.filter(
user=request.user, is_default=True
).order_by('created_at')
playlist_data = (
TrackSerializer(user_tracks, many=True).data
+ TrackSerializer(default_tracks, many=True).data
)
return success(data={'playlist': playlist_data})
def destroy(self, request, pk=None):
"""
@ -40,6 +57,13 @@ class MusicViewSet(viewsets.ViewSet):
track = Track.objects.get(id=pk, user=request.user)
except Track.DoesNotExist:
return error(code=ErrorCode.TRACK_NOT_FOUND, message='曲目不存在')
if track.is_default:
return error(
code=ErrorCode.MUSIC_DEFAULT_UNDELETABLE,
message='默认曲目不可删除'
)
track.delete()
return success(message='删除成功')
@ -65,22 +89,51 @@ class MusicViewSet(viewsets.ViewSet):
@action(detail=False, methods=['post'], url_path='generate')
def generate(self, request):
"""
生成音乐 (SSE 流式 - 占位)
生成音乐 (SSE 流式)
POST /api/v1/music/generate/
消耗 100 积分先扣后退失败退还
"""
serializer = GenerateMusicSerializer(data=request.data)
if not serializer.is_valid():
return error(message=str(serializer.errors))
# TODO: 接入 MiniMax API 实现 SSE 流式生成
track = Track.objects.create(
user=request.user,
title='生成中...',
mood=serializer.validated_data.get('mood', 'calm'),
prompt=serializer.validated_data.get('text', ''),
)
text = serializer.validated_data.get('text', '')
mood = serializer.validated_data.get('mood', 'custom')
user = request.user
return success(data={
'id': track.id,
'message': '音乐生成功能待接入 MiniMax API',
})
# 积分校验
if user.points < GENERATE_COST:
return error(
code=ErrorCode.POINTS_NOT_ENOUGH,
message=f'积分不足,需要 {GENERATE_COST} 积分,当前 {user.points} 积分'
)
# 原子操作:扣积分 + 创建 Track 占位
with transaction.atomic():
user.points -= GENERATE_COST
user.save(update_fields=['points'])
PointsRecord.objects.create(
user=user,
amount=-GENERATE_COST,
type='generate_music',
description='生成音乐',
)
track = Track.objects.create(
user=user,
title='生成中...',
mood=mood,
prompt=text,
generation_status='generating',
)
from .services.music_generation_service import generate_music_stream
response = StreamingHttpResponse(
generate_music_stream(user, track, text, mood),
content_type='text/event-stream',
)
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no'
return response

View File

@ -6,10 +6,10 @@ import logging
from django.conf import settings
try:
from openai import OpenAI
OPENAI_AVAILABLE = True
from volcenginesdkarkruntime import Ark
ARK_AVAILABLE = True
except ImportError:
OPENAI_AVAILABLE = False
ARK_AVAILABLE = False
logger = logging.getLogger(__name__)
@ -55,7 +55,7 @@ def build_user_prompt(characters, scenes, props):
def generate_story_stream(characters, scenes, props):
"""
流式生成故事yield SSE 事件字符串
使用火山引擎豆包大模型OpenAI 兼容接口
使用火山引擎豆包大模型Ark SDK
Yields:
str: SSE 格式的事件数据行
@ -66,8 +66,8 @@ def generate_story_stream(characters, scenes, props):
yield _sse_event('error', {'message': 'Volcengine API Key 未配置'})
return
if not OPENAI_AVAILABLE:
yield _sse_event('error', {'message': 'openai 库未安装,请运行 pip install openai'})
if not ARK_AVAILABLE:
yield _sse_event('error', {'message': 'volcengine SDK 未安装,请运行 pip install "volcengine-python-sdk[ark]"'})
return
yield _sse_event('stage', {
@ -76,10 +76,7 @@ def generate_story_stream(characters, scenes, props):
'message': '正在收集灵感碎片...',
})
client = OpenAI(
api_key=config['API_KEY'],
base_url=config['API_BASE_URL'],
)
client = Ark(api_key=config['API_KEY'])
user_prompt = build_user_prompt(characters, scenes, props)

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2 on 2026-02-12 08:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0003_pointsrecord_and_more"),
]
operations = [
migrations.AlterField(
model_name="pointsrecord",
name="type",
field=models.CharField(
choices=[
("unlock_shelf", "解锁书架"),
("reward", "奖励"),
("admin_adjust", "管理员调整"),
("generate_music", "生成音乐"),
("refund_music", "音乐生成退款"),
],
max_length=30,
verbose_name="类型",
),
),
]

View File

@ -65,6 +65,8 @@ class PointsRecord(models.Model):
('unlock_shelf', '解锁书架'),
('reward', '奖励'),
('admin_adjust', '管理员调整'),
('generate_music', '生成音乐'),
('refund_music', '音乐生成退款'),
]
user = models.ForeignKey(

View File

@ -194,10 +194,9 @@ ALIYUN_PHONE_AUTH = {
'ACCESS_KEY_SECRET': os.environ.get('PHONE_AUTH_ACCESS_KEY_SECRET', ALIYUN_ACCESS_KEY_SECRET),
}
# LLM Settings - Volcengine / 火山引擎豆包 (Story Generation)
# LLM Settings - Volcengine Ark SDK / 火山引擎豆包 (Story Generation)
LLM_CONFIG = {
'API_KEY': os.environ.get('VOLCENGINE_API_KEY', ''),
'API_BASE_URL': os.environ.get('VOLCENGINE_API_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3'),
'MODEL_NAME': os.environ.get('VOLCENGINE_MODEL_NAME', 'doubao-seed-1-6-lite-251015'),
}

View File

@ -29,5 +29,5 @@ urllib3==2.6.3
drf-spectacular==0.27.1
alibabacloud_dysmsapi20170525>=4.4.0
alibabacloud_dypnsapi20170525>=3.0.0
openai>=1.0.0
volcengine-python-sdk[ark]>=5.0.9
edge-tts>=6.1.0

502
tests.py
View File

@ -5,6 +5,7 @@ RTC_DEMO API 完整测试用例
"""
import json
from datetime import date
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APITestCase, APIClient
@ -14,6 +15,7 @@ from apps.admins.models import AdminUser
from apps.spirits.models import Spirit
from apps.devices.models import DeviceType, DeviceBatch, Device, UserDevice
from apps.stories.models import StoryShelf, Story
from apps.music.models import Track
from apps.users.views import get_app_tokens
@ -1566,3 +1568,503 @@ class LLMServiceTests(TestCase):
self.assertIn('error', events[0])
self.assertIn('未配置', events[0])
# ==================== 音乐模块测试 ====================
class MusicTestBase(APITestCase):
"""音乐模块测试基类"""
def setUp(self):
self.user = User.objects.create_user(phone='13800140001', nickname='音乐测试用户')
tokens = get_app_tokens(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {tokens["access"]}')
class MusicPlaylistTests(MusicTestBase):
"""播放列表接口测试"""
def test_playlist_auto_create_defaults(self):
"""测试首次获取播放列表自动创建 3 首默认曲目"""
url = '/api/v1/music/playlist/'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['code'], 0)
playlist = response.data['data']['playlist']
self.assertEqual(len(playlist), 3)
# 验证都是默认曲目
for t in playlist:
self.assertTrue(t['is_default'])
self.assertEqual(t['generation_status'], 'completed')
def test_playlist_default_track_titles(self):
"""测试默认曲目标题"""
url = '/api/v1/music/playlist/'
response = self.client.get(url)
titles = [t['title'] for t in response.data['data']['playlist']]
self.assertIn('卡皮巴拉蹦蹦蹦', titles)
self.assertIn('卡皮巴拉快乐水', titles)
self.assertIn('卡皮巴拉快乐营业', titles)
def test_playlist_defaults_not_duplicated(self):
"""测试多次请求不重复创建默认曲目"""
url = '/api/v1/music/playlist/'
self.client.get(url)
self.client.get(url)
count = Track.objects.filter(user=self.user, is_default=True).count()
self.assertEqual(count, 3)
def test_playlist_ordering(self):
"""测试播放列表排序:用户歌曲在前,默认歌曲在后"""
# 先创建默认曲目
url = '/api/v1/music/playlist/'
self.client.get(url)
# 创建用户歌曲
Track.objects.create(
user=self.user, title='我的歌', is_default=False,
generation_status='completed'
)
response = self.client.get(url)
playlist = response.data['data']['playlist']
self.assertEqual(len(playlist), 4)
# 第一首应该是用户歌曲
self.assertEqual(playlist[0]['title'], '我的歌')
self.assertFalse(playlist[0]['is_default'])
# 后三首是默认曲目
for t in playlist[1:]:
self.assertTrue(t['is_default'])
def test_playlist_user_isolation(self):
"""测试播放列表用户隔离"""
other_user = User.objects.create_user(phone='13800140099')
Track.objects.create(
user=other_user, title='他人的歌',
generation_status='completed'
)
url = '/api/v1/music/playlist/'
response = self.client.get(url)
titles = [t['title'] for t in response.data['data']['playlist']]
self.assertNotIn('他人的歌', titles)
def test_playlist_default_tracks_have_audio_url(self):
"""测试默认曲目有 audio_url"""
url = '/api/v1/music/playlist/'
response = self.client.get(url)
for t in response.data['data']['playlist']:
self.assertTrue(t['audio_url'])
self.assertIn('qy-rtc', t['audio_url'])
def test_playlist_default_tracks_have_lyrics(self):
"""测试默认曲目有歌词"""
url = '/api/v1/music/playlist/'
response = self.client.get(url)
for t in response.data['data']['playlist']:
self.assertTrue(t['lyrics'])
class MusicDeleteTests(MusicTestBase):
"""删除音乐接口测试"""
def test_delete_user_track(self):
"""测试删除用户生成的曲目"""
track = Track.objects.create(
user=self.user, title='待删除歌曲',
generation_status='completed'
)
url = f'/api/v1/music/{track.id}/'
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['code'], 0)
self.assertFalse(Track.objects.filter(id=track.id).exists())
def test_delete_default_track_rejected(self):
"""测试删除默认曲目 - 应被拒绝"""
track = Track.objects.create(
user=self.user, title='默认歌曲',
is_default=True, generation_status='completed'
)
url = f'/api/v1/music/{track.id}/'
response = self.client.delete(url)
self.assertEqual(response.data['code'], 703) # MUSIC_DEFAULT_UNDELETABLE
self.assertTrue(Track.objects.filter(id=track.id).exists())
def test_delete_track_not_found(self):
"""测试删除不存在的曲目"""
url = '/api/v1/music/99999/'
response = self.client.delete(url)
self.assertEqual(response.data['code'], 700) # TRACK_NOT_FOUND
def test_delete_other_user_track(self):
"""测试删除他人的曲目"""
other_user = User.objects.create_user(phone='13800140002')
other_track = Track.objects.create(
user=other_user, title='他人歌曲',
generation_status='completed'
)
url = f'/api/v1/music/{other_track.id}/'
response = self.client.delete(url)
self.assertEqual(response.data['code'], 700)
self.assertTrue(Track.objects.filter(id=other_track.id).exists())
class MusicFavoriteTests(MusicTestBase):
"""收藏接口测试"""
def test_favorite_toggle(self):
"""测试收藏/取消收藏"""
track = Track.objects.create(
user=self.user, title='测试歌曲',
generation_status='completed'
)
url = f'/api/v1/music/{track.id}/favorite/'
# 收藏
response = self.client.post(url)
self.assertEqual(response.data['code'], 0)
self.assertTrue(response.data['data']['is_favorite'])
# 取消收藏
response = self.client.post(url)
self.assertEqual(response.data['code'], 0)
self.assertFalse(response.data['data']['is_favorite'])
def test_favorite_not_found(self):
"""测试收藏不存在的曲目"""
url = '/api/v1/music/99999/favorite/'
response = self.client.post(url)
self.assertEqual(response.data['code'], 700)
class MusicGenerateTests(MusicTestBase):
"""音乐生成接口测试"""
def test_generate_points_not_enough(self):
"""测试生成音乐 - 积分不足"""
self.user.points = 50
self.user.save(update_fields=['points'])
url = '/api/v1/music/generate/'
data = {'text': '开心的一天', 'mood': 'happy'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.data['code'], 603) # POINTS_NOT_ENOUGH
# 积分不应变化
self.user.refresh_from_db()
self.assertEqual(self.user.points, 50)
def test_generate_zero_points(self):
"""测试生成音乐 - 零积分"""
url = '/api/v1/music/generate/'
data = {'text': '开心的一天', 'mood': 'happy'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.data['code'], 603)
def test_generate_returns_sse(self):
"""测试生成音乐返回 SSE 流"""
self.user.points = 200
self.user.save(update_fields=['points'])
mock_events = [
'data: {"stage":"lyrics","progress":10,"message":"AI 正在创作词曲..."}\n\n',
'data: {"stage":"done","progress":100,"message":"新歌出炉!","track_id":1}\n\n',
]
with patch('apps.music.services.music_generation_service.generate_music_stream') as mock_gen:
mock_gen.return_value = iter(mock_events)
url = '/api/v1/music/generate/'
data = {'text': '开心的一天', 'mood': 'happy'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response['Content-Type'], 'text/event-stream')
def test_generate_deducts_points(self):
"""测试生成音乐扣除积分"""
self.user.points = 200
self.user.save(update_fields=['points'])
mock_events = [
'data: {"stage":"done","progress":100}\n\n',
]
with patch('apps.music.services.music_generation_service.generate_music_stream') as mock_gen:
mock_gen.return_value = iter(mock_events)
url = '/api/v1/music/generate/'
data = {'text': '开心的一天', 'mood': 'happy'}
self.client.post(url, data, format='json')
# 验证积分扣除
self.user.refresh_from_db()
self.assertEqual(self.user.points, 100)
# 验证积分流水
record = PointsRecord.objects.filter(
user=self.user, type='generate_music'
).first()
self.assertIsNotNone(record)
self.assertEqual(record.amount, -100)
def test_generate_creates_track(self):
"""测试生成音乐创建 Track 记录"""
self.user.points = 200
self.user.save(update_fields=['points'])
mock_events = [
'data: {"stage":"done","progress":100}\n\n',
]
with patch('apps.music.services.music_generation_service.generate_music_stream') as mock_gen:
mock_gen.return_value = iter(mock_events)
url = '/api/v1/music/generate/'
data = {'text': '开心的一天', 'mood': 'happy'}
self.client.post(url, data, format='json')
track = Track.objects.filter(user=self.user, is_default=False).first()
self.assertIsNotNone(track)
self.assertEqual(track.mood, 'happy')
self.assertEqual(track.prompt, '开心的一天')
self.assertEqual(track.generation_status, 'generating')
def test_generate_empty_text_allowed(self):
"""测试生成音乐 - 空 textrandom 模式允许)"""
self.user.points = 200
self.user.save(update_fields=['points'])
mock_events = [
'data: {"stage":"done","progress":100}\n\n',
]
with patch('apps.music.services.music_generation_service.generate_music_stream') as mock_gen:
mock_gen.return_value = iter(mock_events)
url = '/api/v1/music/generate/'
data = {'mood': 'random'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response['Content-Type'], 'text/event-stream')
class MusicServiceTests(TestCase):
"""音乐生成服务单元测试"""
def test_clean_lyrics(self):
"""测试歌词清洗"""
from apps.music.services.music_generation_service import clean_lyrics
raw = '[verse 1]\n泡在温泉里\n[chorus]\n咔咔咔咔\n[outro]\n(水花声...)'
cleaned = clean_lyrics(raw)
self.assertNotIn('[verse', cleaned)
self.assertNotIn('[chorus]', cleaned)
self.assertIn('泡在温泉里', cleaned)
self.assertIn('咔咔咔咔', cleaned)
def test_clean_lyrics_empty(self):
"""测试清洗空歌词"""
from apps.music.services.music_generation_service import clean_lyrics
self.assertEqual(clean_lyrics(''), '')
self.assertEqual(clean_lyrics(None), None)
def test_sse_event_format(self):
"""测试 SSE 事件格式"""
from apps.music.services.music_generation_service import sse_event
event = sse_event({"stage": "lyrics", "progress": 10})
self.assertTrue(event.startswith('data: '))
self.assertTrue(event.endswith('\n\n'))
data = json.loads(event.replace('data: ', '').strip())
self.assertEqual(data['stage'], 'lyrics')
self.assertEqual(data['progress'], 10)
def test_parse_llm_json_valid(self):
"""测试解析有效 JSON"""
from apps.music.services.music_generation_service import _parse_llm_json
text = '{"song_title": "咔咔之歌", "style": "Pop music", "lyrics": "[verse]\\n泡温泉"}'
result = _parse_llm_json(text)
self.assertEqual(result['song_title'], '咔咔之歌')
self.assertEqual(result['style'], 'Pop music')
def test_parse_llm_json_with_markdown(self):
"""测试解析 markdown 包裹的 JSON"""
from apps.music.services.music_generation_service import _parse_llm_json
text = '```json\n{"song_title": "温泉曲", "style": "Lofi", "lyrics": "la la la"}\n```'
result = _parse_llm_json(text)
self.assertEqual(result['song_title'], '温泉曲')
def test_parse_llm_json_invalid_fallback(self):
"""测试解析无效 JSON 时的正则回退"""
from apps.music.services.music_generation_service import _parse_llm_json
# Malformed JSON with unescaped newlines in string values
text = '{"song_title": "测试歌", "style": "Pop", "lyrics": "line1\nline2"}'
# json.loads should handle this since \n is valid in JSON
result = _parse_llm_json(text)
self.assertIn('song_title', result)
def test_generate_stream_without_api_key(self):
"""测试未配置 API Key 时退还积分"""
from apps.music.services.music_generation_service import generate_music_stream
user = User.objects.create_user(phone='13800149001', nickname='测试')
user.points = 100
user.save(update_fields=['points'])
track = Track.objects.create(
user=user, title='测试', generation_status='generating'
)
with patch('apps.music.services.music_generation_service._get_api_key', return_value=''):
events = list(generate_music_stream(user, track, '测试', 'happy'))
self.assertEqual(len(events), 1)
self.assertIn('error', events[0])
# SSE uses ensure_ascii=True, so check the decoded JSON
event_data = json.loads(events[0].replace('data: ', '').strip())
self.assertIn('未配置', event_data['message'])
# 验证积分退还
user.refresh_from_db()
self.assertEqual(user.points, 200) # 100 original + 100 refunded
track.refresh_from_db()
self.assertEqual(track.generation_status, 'failed')
def test_refund_points(self):
"""测试积分退还"""
from apps.music.services.music_generation_service import _refund_points
user = User.objects.create_user(phone='13800149002', nickname='退款测试')
user.points = 50
user.save(update_fields=['points'])
track = Track.objects.create(
user=user, title='失败歌曲', generation_status='generating'
)
_refund_points(user, track)
user.refresh_from_db()
self.assertEqual(user.points, 150)
track.refresh_from_db()
self.assertEqual(track.generation_status, 'failed')
# 验证退款记录
record = PointsRecord.objects.filter(
user=user, type='refund_music'
).first()
self.assertIsNotNone(record)
self.assertEqual(record.amount, 100)
class DefaultTracksTests(TestCase):
"""默认曲目初始化测试"""
def test_ensure_default_tracks_creates_three(self):
"""测试 ensure_default_tracks 创建 3 首"""
from apps.music.utils import ensure_default_tracks
user = User.objects.create_user(phone='13800149010')
ensure_default_tracks(user)
count = Track.objects.filter(user=user, is_default=True).count()
self.assertEqual(count, 3)
def test_ensure_default_tracks_idempotent(self):
"""测试 ensure_default_tracks 幂等"""
from apps.music.utils import ensure_default_tracks
user = User.objects.create_user(phone='13800149011')
ensure_default_tracks(user)
ensure_default_tracks(user)
count = Track.objects.filter(user=user, is_default=True).count()
self.assertEqual(count, 3)
def test_default_tracks_have_all_fields(self):
"""测试默认曲目字段完整"""
from apps.music.utils import ensure_default_tracks
user = User.objects.create_user(phone='13800149012')
ensure_default_tracks(user)
for track in Track.objects.filter(user=user, is_default=True):
self.assertTrue(track.title)
self.assertTrue(track.lyrics)
self.assertTrue(track.audio_url)
self.assertTrue(track.cover_url)
self.assertEqual(track.generation_status, 'completed')
class MigrateHistoricalTracksTests(TestCase):
"""存量数据迁移命令测试"""
def setUp(self):
self.user = User.objects.create_user(phone='18682237028')
def test_creates_15_tracks(self):
"""测试迁移命令创建 15 首历史曲目"""
from django.core.management import call_command
from io import StringIO
out = StringIO()
call_command('migrate_historical_tracks', stdout=out)
count = Track.objects.filter(user=self.user, is_default=False).count()
self.assertEqual(count, 15)
def test_idempotent(self):
"""测试迁移命令幂等(重复执行不重复创建)"""
from django.core.management import call_command
from io import StringIO
out = StringIO()
call_command('migrate_historical_tracks', stdout=out)
call_command('migrate_historical_tracks', stdout=out)
count = Track.objects.filter(user=self.user, is_default=False).count()
self.assertEqual(count, 15)
def test_dry_run(self):
"""测试 dry-run 不写入数据库"""
from django.core.management import call_command
from io import StringIO
out = StringIO()
call_command('migrate_historical_tracks', '--dry-run', stdout=out)
count = Track.objects.filter(user=self.user).count()
self.assertEqual(count, 0)
self.assertIn('dry-run', out.getvalue())
def test_tracks_have_oss_urls(self):
"""测试所有迁移曲目都有 OSS URL"""
from django.core.management import call_command
from io import StringIO
call_command('migrate_historical_tracks', stdout=StringIO())
for track in Track.objects.filter(user=self.user):
self.assertTrue(track.audio_url.startswith('https://qy-rtc.oss-cn-beijing.aliyuncs.com/'))
self.assertTrue(track.cover_url.startswith('https://qy-rtc.oss-cn-beijing.aliyuncs.com/'))

View File

@ -123,6 +123,8 @@ class ErrorCode:
# 音乐模块 700-799
TRACK_NOT_FOUND = 700
MUSIC_GENERATE_FAILED = 701
MUSIC_GENERATING = 702
MUSIC_DEFAULT_UNDELETABLE = 703
# 通知模块 800-899
NOTIFICATION_NOT_FOUND = 800