This commit is contained in:
parent
7a6d7814e0
commit
f1bead86f6
@ -20,9 +20,8 @@ OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
|||||||
OSS_BUCKET_NAME=your-bucket-name
|
OSS_BUCKET_NAME=your-bucket-name
|
||||||
OSS_CUSTOM_DOMAIN=
|
OSS_CUSTOM_DOMAIN=
|
||||||
|
|
||||||
# Volcengine / 火山引擎豆包 (Story Generation)
|
# Volcengine Ark SDK / 火山引擎豆包 (Story Generation)
|
||||||
VOLCENGINE_API_KEY=your-volcengine-api-key
|
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
|
VOLCENGINE_MODEL_NAME=doubao-seed-1-6-lite-251015
|
||||||
|
|
||||||
# CORS (production only)
|
# CORS (production only)
|
||||||
|
|||||||
0
apps/music/management/__init__.py
Normal file
0
apps/music/management/__init__.py
Normal file
0
apps/music/management/commands/__init__.py
Normal file
0
apps/music/management/commands/__init__.py
Normal file
381
apps/music/management/commands/migrate_historical_tracks.py
Normal file
381
apps/music/management/commands/migrate_historical_tracks.py
Normal 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} 首'
|
||||||
|
))
|
||||||
@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -9,11 +9,18 @@ class Track(models.Model):
|
|||||||
"""音乐曲目"""
|
"""音乐曲目"""
|
||||||
|
|
||||||
MOOD_CHOICES = [
|
MOOD_CHOICES = [
|
||||||
|
('chill', '放松'),
|
||||||
('happy', '开心'),
|
('happy', '开心'),
|
||||||
('sad', '悲伤'),
|
('sleepy', '助眠'),
|
||||||
('calm', '平静'),
|
('random', '随机'),
|
||||||
('energetic', '活力'),
|
('custom', '自定义'),
|
||||||
('romantic', '浪漫'),
|
]
|
||||||
|
|
||||||
|
GENERATION_STATUS_CHOICES = [
|
||||||
|
('pending', '待生成'),
|
||||||
|
('generating', '生成中'),
|
||||||
|
('completed', '已完成'),
|
||||||
|
('failed', '失败'),
|
||||||
]
|
]
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
@ -31,6 +38,11 @@ class Track(models.Model):
|
|||||||
duration = models.IntegerField('时长(秒)', default=0)
|
duration = models.IntegerField('时长(秒)', default=0)
|
||||||
prompt = models.TextField('生成提示词', blank=True, default='')
|
prompt = models.TextField('生成提示词', blank=True, default='')
|
||||||
is_favorite = models.BooleanField('是否收藏', default=False)
|
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)
|
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||||
|
|
||||||
@ -41,6 +53,7 @@ class Track(models.Model):
|
|||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['user', 'is_favorite']),
|
models.Index(fields=['user', 'is_favorite']),
|
||||||
|
models.Index(fields=['user', 'is_default']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@ -11,14 +11,15 @@ class TrackSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Track
|
model = Track
|
||||||
fields = ['id', 'title', 'lyrics', 'audio_url', 'cover_url',
|
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):
|
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(
|
mood = serializers.ChoiceField(
|
||||||
choices=['happy', 'sad', 'calm', 'energetic', 'romantic'],
|
choices=['chill', 'happy', 'sleepy', 'random', 'custom'],
|
||||||
required=False,
|
required=False,
|
||||||
default='calm'
|
default='custom'
|
||||||
)
|
)
|
||||||
|
|||||||
0
apps/music/services/__init__.py
Normal file
0
apps/music/services/__init__.py
Normal file
102
apps/music/services/music_director_prompt.md
Normal file
102
apps/music/services/music_director_prompt.md
Normal 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"]
|
||||||
|
```
|
||||||
317
apps/music/services/music_generation_service.py
Normal file
317
apps/music/services/music_generation_service.py
Normal 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}"
|
||||||
@ -9,5 +9,5 @@ router = DefaultRouter()
|
|||||||
router.register('', MusicViewSet, basename='music')
|
router.register('', MusicViewSet, basename='music')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('music/', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
70
apps/music/utils.py
Normal file
70
apps/music/utils.py
Normal 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)
|
||||||
@ -1,6 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
音乐模块视图 - App端
|
音乐模块视图 - App端
|
||||||
"""
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import StreamingHttpResponse
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
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.response import success, error
|
||||||
from utils.exceptions import ErrorCode
|
from utils.exceptions import ErrorCode
|
||||||
from apps.admins.authentication import AppJWTAuthentication
|
from apps.admins.authentication import AppJWTAuthentication
|
||||||
|
from apps.users.models import PointsRecord
|
||||||
from .models import Track
|
from .models import Track
|
||||||
from .serializers import TrackSerializer, GenerateMusicSerializer
|
from .serializers import TrackSerializer, GenerateMusicSerializer
|
||||||
|
from .utils import ensure_default_tracks
|
||||||
|
|
||||||
|
GENERATE_COST = 100
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(tags=['音乐'])
|
@extend_schema(tags=['音乐'])
|
||||||
@ -25,11 +31,22 @@ class MusicViewSet(viewsets.ViewSet):
|
|||||||
"""
|
"""
|
||||||
获取播放列表
|
获取播放列表
|
||||||
GET /api/v1/music/playlist/
|
GET /api/v1/music/playlist/
|
||||||
|
自动初始化默认曲目,返回用户歌曲(时间倒序)+ 默认歌曲(末尾)
|
||||||
"""
|
"""
|
||||||
tracks = Track.objects.filter(user=request.user)
|
ensure_default_tracks(request.user)
|
||||||
return success(data={
|
|
||||||
'playlist': TrackSerializer(tracks, many=True).data,
|
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):
|
def destroy(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
@ -40,6 +57,13 @@ class MusicViewSet(viewsets.ViewSet):
|
|||||||
track = Track.objects.get(id=pk, user=request.user)
|
track = Track.objects.get(id=pk, user=request.user)
|
||||||
except Track.DoesNotExist:
|
except Track.DoesNotExist:
|
||||||
return error(code=ErrorCode.TRACK_NOT_FOUND, message='曲目不存在')
|
return error(code=ErrorCode.TRACK_NOT_FOUND, message='曲目不存在')
|
||||||
|
|
||||||
|
if track.is_default:
|
||||||
|
return error(
|
||||||
|
code=ErrorCode.MUSIC_DEFAULT_UNDELETABLE,
|
||||||
|
message='默认曲目不可删除'
|
||||||
|
)
|
||||||
|
|
||||||
track.delete()
|
track.delete()
|
||||||
return success(message='删除成功')
|
return success(message='删除成功')
|
||||||
|
|
||||||
@ -65,22 +89,51 @@ class MusicViewSet(viewsets.ViewSet):
|
|||||||
@action(detail=False, methods=['post'], url_path='generate')
|
@action(detail=False, methods=['post'], url_path='generate')
|
||||||
def generate(self, request):
|
def generate(self, request):
|
||||||
"""
|
"""
|
||||||
生成音乐 (SSE 流式 - 占位)
|
生成音乐 (SSE 流式)
|
||||||
POST /api/v1/music/generate/
|
POST /api/v1/music/generate/
|
||||||
|
消耗 100 积分,先扣后退(失败退还)
|
||||||
"""
|
"""
|
||||||
serializer = GenerateMusicSerializer(data=request.data)
|
serializer = GenerateMusicSerializer(data=request.data)
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return error(message=str(serializer.errors))
|
return error(message=str(serializer.errors))
|
||||||
|
|
||||||
# TODO: 接入 MiniMax API 实现 SSE 流式生成
|
text = serializer.validated_data.get('text', '')
|
||||||
track = Track.objects.create(
|
mood = serializer.validated_data.get('mood', 'custom')
|
||||||
user=request.user,
|
user = request.user
|
||||||
title='生成中...',
|
|
||||||
mood=serializer.validated_data.get('mood', 'calm'),
|
# 积分校验
|
||||||
prompt=serializer.validated_data.get('text', ''),
|
if user.points < GENERATE_COST:
|
||||||
|
return error(
|
||||||
|
code=ErrorCode.POINTS_NOT_ENOUGH,
|
||||||
|
message=f'积分不足,需要 {GENERATE_COST} 积分,当前 {user.points} 积分'
|
||||||
)
|
)
|
||||||
|
|
||||||
return success(data={
|
# 原子操作:扣积分 + 创建 Track 占位
|
||||||
'id': track.id,
|
with transaction.atomic():
|
||||||
'message': '音乐生成功能待接入 MiniMax API',
|
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
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import logging
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI
|
from volcenginesdkarkruntime import Ark
|
||||||
OPENAI_AVAILABLE = True
|
ARK_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
OPENAI_AVAILABLE = False
|
ARK_AVAILABLE = False
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ def build_user_prompt(characters, scenes, props):
|
|||||||
def generate_story_stream(characters, scenes, props):
|
def generate_story_stream(characters, scenes, props):
|
||||||
"""
|
"""
|
||||||
流式生成故事,yield SSE 事件字符串。
|
流式生成故事,yield SSE 事件字符串。
|
||||||
使用火山引擎豆包大模型(OpenAI 兼容接口)。
|
使用火山引擎豆包大模型(Ark SDK)。
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
str: SSE 格式的事件数据行
|
str: SSE 格式的事件数据行
|
||||||
@ -66,8 +66,8 @@ def generate_story_stream(characters, scenes, props):
|
|||||||
yield _sse_event('error', {'message': 'Volcengine API Key 未配置'})
|
yield _sse_event('error', {'message': 'Volcengine API Key 未配置'})
|
||||||
return
|
return
|
||||||
|
|
||||||
if not OPENAI_AVAILABLE:
|
if not ARK_AVAILABLE:
|
||||||
yield _sse_event('error', {'message': 'openai 库未安装,请运行 pip install openai'})
|
yield _sse_event('error', {'message': 'volcengine SDK 未安装,请运行 pip install "volcengine-python-sdk[ark]"'})
|
||||||
return
|
return
|
||||||
|
|
||||||
yield _sse_event('stage', {
|
yield _sse_event('stage', {
|
||||||
@ -76,10 +76,7 @@ def generate_story_stream(characters, scenes, props):
|
|||||||
'message': '正在收集灵感碎片...',
|
'message': '正在收集灵感碎片...',
|
||||||
})
|
})
|
||||||
|
|
||||||
client = OpenAI(
|
client = Ark(api_key=config['API_KEY'])
|
||||||
api_key=config['API_KEY'],
|
|
||||||
base_url=config['API_BASE_URL'],
|
|
||||||
)
|
|
||||||
|
|
||||||
user_prompt = build_user_prompt(characters, scenes, props)
|
user_prompt = build_user_prompt(characters, scenes, props)
|
||||||
|
|
||||||
|
|||||||
28
apps/users/migrations/0004_alter_pointsrecord_type.py
Normal file
28
apps/users/migrations/0004_alter_pointsrecord_type.py
Normal 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="类型",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -65,6 +65,8 @@ class PointsRecord(models.Model):
|
|||||||
('unlock_shelf', '解锁书架'),
|
('unlock_shelf', '解锁书架'),
|
||||||
('reward', '奖励'),
|
('reward', '奖励'),
|
||||||
('admin_adjust', '管理员调整'),
|
('admin_adjust', '管理员调整'),
|
||||||
|
('generate_music', '生成音乐'),
|
||||||
|
('refund_music', '音乐生成退款'),
|
||||||
]
|
]
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
|
|||||||
@ -194,10 +194,9 @@ ALIYUN_PHONE_AUTH = {
|
|||||||
'ACCESS_KEY_SECRET': os.environ.get('PHONE_AUTH_ACCESS_KEY_SECRET', ALIYUN_ACCESS_KEY_SECRET),
|
'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 = {
|
LLM_CONFIG = {
|
||||||
'API_KEY': os.environ.get('VOLCENGINE_API_KEY', ''),
|
'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'),
|
'MODEL_NAME': os.environ.get('VOLCENGINE_MODEL_NAME', 'doubao-seed-1-6-lite-251015'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,5 +29,5 @@ urllib3==2.6.3
|
|||||||
drf-spectacular==0.27.1
|
drf-spectacular==0.27.1
|
||||||
alibabacloud_dysmsapi20170525>=4.4.0
|
alibabacloud_dysmsapi20170525>=4.4.0
|
||||||
alibabacloud_dypnsapi20170525>=3.0.0
|
alibabacloud_dypnsapi20170525>=3.0.0
|
||||||
openai>=1.0.0
|
volcengine-python-sdk[ark]>=5.0.9
|
||||||
edge-tts>=6.1.0
|
edge-tts>=6.1.0
|
||||||
|
|||||||
502
tests.py
502
tests.py
@ -5,6 +5,7 @@ RTC_DEMO API 完整测试用例
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase, APIClient
|
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.spirits.models import Spirit
|
||||||
from apps.devices.models import DeviceType, DeviceBatch, Device, UserDevice
|
from apps.devices.models import DeviceType, DeviceBatch, Device, UserDevice
|
||||||
from apps.stories.models import StoryShelf, Story
|
from apps.stories.models import StoryShelf, Story
|
||||||
|
from apps.music.models import Track
|
||||||
from apps.users.views import get_app_tokens
|
from apps.users.views import get_app_tokens
|
||||||
|
|
||||||
|
|
||||||
@ -1566,3 +1568,503 @@ class LLMServiceTests(TestCase):
|
|||||||
self.assertIn('error', events[0])
|
self.assertIn('error', events[0])
|
||||||
self.assertIn('未配置', 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):
|
||||||
|
"""测试生成音乐 - 空 text(random 模式允许)"""
|
||||||
|
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/'))
|
||||||
|
|
||||||
|
|||||||
@ -123,6 +123,8 @@ class ErrorCode:
|
|||||||
# 音乐模块 700-799
|
# 音乐模块 700-799
|
||||||
TRACK_NOT_FOUND = 700
|
TRACK_NOT_FOUND = 700
|
||||||
MUSIC_GENERATE_FAILED = 701
|
MUSIC_GENERATE_FAILED = 701
|
||||||
|
MUSIC_GENERATING = 702
|
||||||
|
MUSIC_DEFAULT_UNDELETABLE = 703
|
||||||
|
|
||||||
# 通知模块 800-899
|
# 通知模块 800-899
|
||||||
NOTIFICATION_NOT_FOUND = 800
|
NOTIFICATION_NOT_FOUND = 800
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user