From f1bead86f66ec560ffd5f7867273b71ad3b26c19 Mon Sep 17 00:00:00 2001 From: repair-agent Date: Thu, 12 Feb 2026 17:35:54 +0800 Subject: [PATCH] fix music --- .env.example | 3 +- apps/music/management/__init__.py | 0 apps/music/management/commands/__init__.py | 0 .../commands/migrate_historical_tracks.py | 381 +++++++++++++ ...ration_status_track_is_default_and_more.py | 56 ++ apps/music/models.py | 21 +- apps/music/serializers.py | 9 +- apps/music/services/__init__.py | 0 apps/music/services/music_director_prompt.md | 102 ++++ .../services/music_generation_service.py | 317 +++++++++++ apps/music/urls.py | 2 +- apps/music/utils.py | 70 +++ apps/music/views.py | 85 ++- apps/stories/services/llm_service.py | 17 +- .../0004_alter_pointsrecord_type.py | 28 + apps/users/models.py | 2 + config/settings.py | 3 +- requirements.txt | 2 +- tests.py | 502 ++++++++++++++++++ utils/exceptions.py | 2 + 20 files changed, 1562 insertions(+), 40 deletions(-) create mode 100644 apps/music/management/__init__.py create mode 100644 apps/music/management/commands/__init__.py create mode 100644 apps/music/management/commands/migrate_historical_tracks.py create mode 100644 apps/music/migrations/0002_track_generation_status_track_is_default_and_more.py create mode 100644 apps/music/services/__init__.py create mode 100644 apps/music/services/music_director_prompt.md create mode 100644 apps/music/services/music_generation_service.py create mode 100644 apps/music/utils.py create mode 100644 apps/users/migrations/0004_alter_pointsrecord_type.py diff --git a/.env.example b/.env.example index 54f97b6..011b88d 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/apps/music/management/__init__.py b/apps/music/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/music/management/commands/__init__.py b/apps/music/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/music/management/commands/migrate_historical_tracks.py b/apps/music/management/commands/migrate_historical_tracks.py new file mode 100644 index 0000000..85e6abd --- /dev/null +++ b/apps/music/management/commands/migrate_historical_tracks.py @@ -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} 首' + )) diff --git a/apps/music/migrations/0002_track_generation_status_track_is_default_and_more.py b/apps/music/migrations/0002_track_generation_status_track_is_default_and_more.py new file mode 100644 index 0000000..ca11759 --- /dev/null +++ b/apps/music/migrations/0002_track_generation_status_track_is_default_and_more.py @@ -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" + ), + ), + ] diff --git a/apps/music/models.py b/apps/music/models.py index 213aeaa..2a6e9e8 100644 --- a/apps/music/models.py +++ b/apps/music/models.py @@ -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): diff --git a/apps/music/serializers.py b/apps/music/serializers.py index f392632..d2d8e2f 100644 --- a/apps/music/serializers.py +++ b/apps/music/serializers.py @@ -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' ) diff --git a/apps/music/services/__init__.py b/apps/music/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/music/services/music_director_prompt.md b/apps/music/services/music_director_prompt.md new file mode 100644 index 0000000..6c63a22 --- /dev/null +++ b/apps/music/services/music_director_prompt.md @@ -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"] +``` diff --git a/apps/music/services/music_generation_service.py b/apps/music/services/music_generation_service.py new file mode 100644 index 0000000..3c9f5e0 --- /dev/null +++ b/apps/music/services/music_generation_service.py @@ -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}" diff --git a/apps/music/urls.py b/apps/music/urls.py index b4c5b7f..12f66d6 100644 --- a/apps/music/urls.py +++ b/apps/music/urls.py @@ -9,5 +9,5 @@ router = DefaultRouter() router.register('', MusicViewSet, basename='music') urlpatterns = [ - path('', include(router.urls)), + path('music/', include(router.urls)), ] diff --git a/apps/music/utils.py b/apps/music/utils.py new file mode 100644 index 0000000..5596f4a --- /dev/null +++ b/apps/music/utils.py @@ -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) diff --git a/apps/music/views.py b/apps/music/views.py index cc0acda..ac81d05 100644 --- a/apps/music/views.py +++ b/apps/music/views.py @@ -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 diff --git a/apps/stories/services/llm_service.py b/apps/stories/services/llm_service.py index d9ca74a..f9b5fdc 100644 --- a/apps/stories/services/llm_service.py +++ b/apps/stories/services/llm_service.py @@ -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) diff --git a/apps/users/migrations/0004_alter_pointsrecord_type.py b/apps/users/migrations/0004_alter_pointsrecord_type.py new file mode 100644 index 0000000..8b7b4b1 --- /dev/null +++ b/apps/users/migrations/0004_alter_pointsrecord_type.py @@ -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="类型", + ), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 9a71c5a..f16a2f1 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -65,6 +65,8 @@ class PointsRecord(models.Model): ('unlock_shelf', '解锁书架'), ('reward', '奖励'), ('admin_adjust', '管理员调整'), + ('generate_music', '生成音乐'), + ('refund_music', '音乐生成退款'), ] user = models.ForeignKey( diff --git a/config/settings.py b/config/settings.py index df5a494..e2920fe 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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'), } diff --git a/requirements.txt b/requirements.txt index dfbf347..69b47d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests.py b/tests.py index d6b0efd..cf9464a 100644 --- a/tests.py +++ b/tests.py @@ -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): + """测试生成音乐 - 空 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/')) + diff --git a/utils/exceptions.py b/utils/exceptions.py index e6217d9..a6bbbab 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -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