feat: 添加默认故事功能及修复故事模块依赖

- Story 模型新增 is_default 字段及迁移 0004
- 新增 utils.py:ensure_default_stories 懒初始化默认故事(含视频绘本)
- StoryViewSet/ShelfViewSet list 接口调用 ensure_default_stories
- 新增 upload_default_story_media 管理命令,上传视频/封面到 OSS
- 安装缺失依赖:edge-tts 7.2.7、volcengine-python-sdk[ark] 5.0.12

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
repair-agent 2026-03-02 15:16:38 +08:00
parent 4736f63040
commit 487b258bbe
7 changed files with 186 additions and 1 deletions

View File

View File

@ -0,0 +1,106 @@
"""
上传默认故事媒体资源到 OSS
使用方法:
python manage.py upload_default_story_media
python manage.py upload_default_story_media --dry-run # 仅检查,不上传
上传内容:
- 视频: rtc_prd/动态绘本/失控的魔法扫帚.mp4 stories/defaults/失控的魔法扫帚.mp4
- 封面: rtc_prd/故事书封面图/卡皮巴拉的奇幻漂流.png stories/defaults/失控的魔法扫帚_cover.png
"""
import os
from django.conf import settings
from django.core.management.base import BaseCommand
try:
import oss2
OSS_AVAILABLE = True
except ImportError:
OSS_AVAILABLE = False
# 上传目标 OSS key固定路径与 utils.py 中的 URL 对应)
_PRD_ROOT = os.path.expanduser("~/Desktop/zyc/qiyuan_gitea/rtc_prd")
UPLOAD_ITEMS = [
{
"desc": "绘本视频",
"local": os.path.join(_PRD_ROOT, "动态绘本/失控的魔法扫帚.mp4"),
"oss_key": "stories/defaults/失控的魔法扫帚.mp4",
"content_type": "video/mp4",
},
{
"desc": "故事封面",
"local": os.path.join(_PRD_ROOT, "故事书封面图/卡皮巴拉的奇幻漂流.png"),
"oss_key": "stories/defaults/失控的魔法扫帚_cover.png",
"content_type": "image/png",
},
]
class Command(BaseCommand):
help = "上传默认故事的视频和封面到 OSS"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="仅打印计划,不实际上传",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
if not OSS_AVAILABLE:
self.stderr.write(self.style.ERROR("oss2 未安装,请先 pip install oss2"))
return
oss_config = settings.ALIYUN_OSS
if not oss_config.get("ACCESS_KEY_ID"):
self.stderr.write(self.style.ERROR("OSS 未配置,请检查 .env 中的 ALIYUN_OSS 设置"))
return
auth = oss2.Auth(oss_config["ACCESS_KEY_ID"], oss_config["ACCESS_KEY_SECRET"])
bucket = oss2.Bucket(auth, oss_config["ENDPOINT"], oss_config["BUCKET_NAME"])
oss_base = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}"
for item in UPLOAD_ITEMS:
local_path = os.path.normpath(item["local"])
oss_key = item["oss_key"]
target_url = f"{oss_base}/{oss_key}"
self.stdout.write(f"\n[{item['desc']}]")
self.stdout.write(f" 本地文件: {local_path}")
self.stdout.write(f" OSS 目标: {oss_key}")
self.stdout.write(f" 访问 URL: {target_url}")
if not os.path.isfile(local_path):
self.stderr.write(self.style.WARNING(f" ⚠ 本地文件不存在,跳过"))
continue
file_size = os.path.getsize(local_path)
self.stdout.write(f" 文件大小: {file_size / 1024 / 1024:.1f} MB")
# 检查 OSS 是否已存在
try:
bucket.get_object_meta(oss_key)
self.stdout.write(self.style.WARNING(" ✓ OSS 已存在,跳过上传"))
continue
except oss2.exceptions.NoSuchKey:
pass # 不存在,需要上传
if dry_run:
self.stdout.write(self.style.NOTICE(" [dry-run] 将会上传此文件"))
continue
self.stdout.write(" 上传中...")
with open(local_path, "rb") as f:
bucket.put_object(
oss_key,
f,
headers={"Content-Type": item["content_type"]},
)
self.stdout.write(self.style.SUCCESS(f" ✓ 上传成功: {target_url}"))
self.stdout.write(self.style.SUCCESS("\n完成。请确认 utils.py 中的 URL 与上述 OSS URL 一致。"))

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("stories", "0003_story_shelf_nullable"),
]
operations = [
migrations.AddField(
model_name="story",
name="is_default",
field=models.BooleanField(default=False, verbose_name="是否默认故事"),
),
]

View File

@ -61,6 +61,7 @@ class Story(models.Model):
choices=GENERATION_MODE_CHOICES, default='ai' choices=GENERATION_MODE_CHOICES, default='ai'
) )
prompt = models.TextField('生成提示词', blank=True, default='') prompt = models.TextField('生成提示词', blank=True, default='')
is_default = models.BooleanField('是否默认故事', default=False)
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)

60
apps/stories/utils.py Normal file
View File

@ -0,0 +1,60 @@
"""
故事模块工具函数
"""
OSS_BASE = "https://qy-rtc.oss-cn-beijing.aliyuncs.com"
DEFAULT_STORIES = [
{
"title": "失控的魔法扫帚",
"content": (
"魔法学院的期末考试正在进行中小女巫艾米紧张地握着她的新扫帚「光轮2026」。"
"考试题目是:平稳飞越学校的钟楼并且不撞到任何一只鸽子。\n\n"
"「起飞!」艾米念出咒语。可是,扫帚似乎有了自己的想法,它没有飞向钟楼,"
"而是像火箭一样冲向了食堂的窗户!\n\n"
"「糟糕!那是校长的草莓蛋糕!」艾米惊呼。就在千钧一发之际,扫帚突然一个急转弯,"
"稳稳地停在了蛋糕前——原来它只是饿了。\n\n"
"虽然考试不及格,但艾米发明了全校最快的「外卖配送术」。"
"从此以后,魔法学院的学生们再也不用担心吃不到热乎乎的披萨了。"
),
"cover_url": f"{OSS_BASE}/stories/defaults/失控的魔法扫帚_cover.png",
"has_video": True,
"video_url": f"{OSS_BASE}/stories/defaults/失控的魔法扫帚.mp4",
"generation_mode": "ai",
"prompt": "角色=[小女巫],场景=[魔法学院],道具=[魔法扫帚]",
},
]
def ensure_default_stories(user):
"""确保用户书架有默认故事,没有则创建。
逻辑与 music.utils.ensure_default_tracks 保持一致
- 若用户已有默认故事则跳过
- 先确保默认书架存在再批量写入
"""
from .models import Story, StoryShelf
if Story.objects.filter(user=user, is_default=True).exists():
return
# 确保默认书架存在
shelf, _ = StoryShelf.objects.get_or_create(
user=user,
defaults={"name": "我的书架"},
)
stories = []
for item in DEFAULT_STORIES:
stories.append(Story(
user=user,
shelf=shelf,
title=item["title"],
content=item["content"],
cover_url=item["cover_url"],
has_video=item["has_video"],
video_url=item["video_url"],
generation_mode=item["generation_mode"],
prompt=item["prompt"],
is_default=True,
))
Story.objects.bulk_create(stories)

View File

@ -13,6 +13,7 @@ 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 .models import StoryShelf, Story from .models import StoryShelf, Story
from .utils import ensure_default_stories
from .serializers import ( from .serializers import (
StoryShelfSerializer, StoryShelfSerializer,
CreateShelfSerializer, CreateShelfSerializer,
@ -41,6 +42,7 @@ class StoryViewSet(viewsets.ViewSet):
获取故事列表 获取故事列表
GET /api/v1/stories/?shelf_id=1&page=1&page_size=20 GET /api/v1/stories/?shelf_id=1&page=1&page_size=20
""" """
ensure_default_stories(request.user)
queryset = Story.objects.filter(user=request.user) queryset = Story.objects.filter(user=request.user)
shelf_id = request.query_params.get('shelf_id') shelf_id = request.query_params.get('shelf_id')
@ -172,7 +174,7 @@ class ShelfViewSet(viewsets.ViewSet):
书架列表 书架列表
GET /api/v1/stories/shelves/ GET /api/v1/stories/shelves/
""" """
ensure_default_shelf(request.user) ensure_default_stories(request.user)
shelves = StoryShelf.objects.filter( shelves = StoryShelf.objects.filter(
user=request.user user=request.user