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:
parent
4736f63040
commit
487b258bbe
0
apps/stories/management/__init__.py
Normal file
0
apps/stories/management/__init__.py
Normal file
0
apps/stories/management/commands/__init__.py
Normal file
0
apps/stories/management/commands/__init__.py
Normal file
106
apps/stories/management/commands/upload_default_story_media.py
Normal file
106
apps/stories/management/commands/upload_default_story_media.py
Normal 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 一致。"))
|
||||
16
apps/stories/migrations/0004_story_is_default.py
Normal file
16
apps/stories/migrations/0004_story_is_default.py
Normal 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="是否默认故事"),
|
||||
),
|
||||
]
|
||||
@ -61,6 +61,7 @@ class Story(models.Model):
|
||||
choices=GENERATION_MODE_CHOICES, default='ai'
|
||||
)
|
||||
prompt = models.TextField('生成提示词', blank=True, default='')
|
||||
is_default = models.BooleanField('是否默认故事', default=False)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
|
||||
60
apps/stories/utils.py
Normal file
60
apps/stories/utils.py
Normal 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)
|
||||
@ -13,6 +13,7 @@ from utils.response import success, error
|
||||
from utils.exceptions import ErrorCode
|
||||
from apps.admins.authentication import AppJWTAuthentication
|
||||
from .models import StoryShelf, Story
|
||||
from .utils import ensure_default_stories
|
||||
from .serializers import (
|
||||
StoryShelfSerializer,
|
||||
CreateShelfSerializer,
|
||||
@ -41,6 +42,7 @@ class StoryViewSet(viewsets.ViewSet):
|
||||
获取故事列表
|
||||
GET /api/v1/stories/?shelf_id=1&page=1&page_size=20
|
||||
"""
|
||||
ensure_default_stories(request.user)
|
||||
queryset = Story.objects.filter(user=request.user)
|
||||
|
||||
shelf_id = request.query_params.get('shelf_id')
|
||||
@ -172,7 +174,7 @@ class ShelfViewSet(viewsets.ViewSet):
|
||||
书架列表
|
||||
GET /api/v1/stories/shelves/
|
||||
"""
|
||||
ensure_default_shelf(request.user)
|
||||
ensure_default_stories(request.user)
|
||||
|
||||
shelves = StoryShelf.objects.filter(
|
||||
user=request.user
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user