- 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>
263 lines
8.5 KiB
Python
263 lines
8.5 KiB
Python
"""
|
||
故事模块视图 - App端
|
||
"""
|
||
from django.db import transaction
|
||
from django.db.models import Count
|
||
from django.http import StreamingHttpResponse
|
||
from rest_framework import viewsets
|
||
from rest_framework.decorators import action
|
||
from rest_framework.permissions import IsAuthenticated
|
||
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 .models import StoryShelf, Story
|
||
from .utils import ensure_default_stories
|
||
from .serializers import (
|
||
StoryShelfSerializer,
|
||
CreateShelfSerializer,
|
||
StoryListSerializer,
|
||
StoryDetailSerializer,
|
||
CreateStorySerializer,
|
||
GenerateStorySerializer,
|
||
)
|
||
|
||
|
||
def ensure_default_shelf(user):
|
||
"""确保用户有默认书架,没有则创建"""
|
||
if not StoryShelf.objects.filter(user=user).exists():
|
||
StoryShelf.objects.create(user=user, name='我的书架')
|
||
|
||
|
||
@extend_schema(tags=['故事'])
|
||
class StoryViewSet(viewsets.ViewSet):
|
||
"""故事视图集(App端)"""
|
||
|
||
authentication_classes = [AppJWTAuthentication]
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def list(self, request):
|
||
"""
|
||
获取故事列表
|
||
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')
|
||
if shelf_id:
|
||
queryset = queryset.filter(shelf_id=shelf_id)
|
||
|
||
# 分页
|
||
page = int(request.query_params.get('page', 1))
|
||
page_size = int(request.query_params.get('page_size', 20))
|
||
start = (page - 1) * page_size
|
||
total = queryset.count()
|
||
items = queryset[start:start + page_size]
|
||
|
||
return success(data={
|
||
'total': total,
|
||
'items': StoryListSerializer(items, many=True).data,
|
||
})
|
||
|
||
def create(self, request):
|
||
"""
|
||
保存故事
|
||
POST /api/v1/stories/
|
||
"""
|
||
serializer = CreateStorySerializer(data=request.data)
|
||
if not serializer.is_valid():
|
||
return error(message=str(serializer.errors))
|
||
|
||
shelf_id = serializer.validated_data['shelf_id']
|
||
try:
|
||
shelf = StoryShelf.objects.get(
|
||
id=shelf_id, user=request.user, is_locked=False
|
||
)
|
||
except StoryShelf.DoesNotExist:
|
||
return error(code=ErrorCode.SHELF_NOT_FOUND, message='书架不存在或未解锁')
|
||
|
||
if shelf.is_full:
|
||
return error(code=ErrorCode.SHELF_FULL, message='书架已满,请解锁新书架')
|
||
|
||
story = Story.objects.create(
|
||
user=request.user,
|
||
shelf=shelf,
|
||
title=serializer.validated_data['title'],
|
||
content=serializer.validated_data['content'],
|
||
cover_url=serializer.validated_data.get('cover_url', ''),
|
||
generation_mode=serializer.validated_data.get('generation_mode', 'ai'),
|
||
prompt=serializer.validated_data.get('prompt', ''),
|
||
)
|
||
|
||
return success(
|
||
data=StoryDetailSerializer(story).data,
|
||
message='保存成功'
|
||
)
|
||
|
||
def destroy(self, request, pk=None):
|
||
"""
|
||
删除故事
|
||
DELETE /api/v1/stories/{id}/
|
||
"""
|
||
try:
|
||
story = Story.objects.get(id=pk, user=request.user)
|
||
except Story.DoesNotExist:
|
||
return error(code=ErrorCode.STORY_NOT_FOUND, message='故事不存在')
|
||
story.delete()
|
||
return success(message='删除成功')
|
||
|
||
@action(detail=False, methods=['post'], url_path='generate')
|
||
def generate(self, request):
|
||
"""
|
||
生成故事 (SSE 流式)
|
||
POST /api/v1/stories/generate/
|
||
"""
|
||
serializer = GenerateStorySerializer(data=request.data)
|
||
if not serializer.is_valid():
|
||
return error(message=str(serializer.errors))
|
||
|
||
characters = serializer.validated_data.get('characters', [])
|
||
scenes = serializer.validated_data.get('scenes', [])
|
||
props = serializer.validated_data.get('props', [])
|
||
|
||
from .services.llm_service import generate_story_stream
|
||
|
||
response = StreamingHttpResponse(
|
||
generate_story_stream(characters, scenes, props),
|
||
content_type='text/event-stream',
|
||
)
|
||
response['Cache-Control'] = 'no-cache'
|
||
response['X-Accel-Buffering'] = 'no'
|
||
return response
|
||
|
||
@action(detail=True, methods=['get', 'post'], url_path='tts')
|
||
def tts(self, request, pk=None):
|
||
"""
|
||
TTS 音频接口
|
||
GET /api/v1/stories/{id}/tts/ - 查询音频状态
|
||
POST /api/v1/stories/{id}/tts/ - 生成 TTS 音频 (SSE 流式)
|
||
"""
|
||
try:
|
||
story = Story.objects.get(id=pk, user=request.user)
|
||
except Story.DoesNotExist:
|
||
return error(code=ErrorCode.STORY_NOT_FOUND, message='故事不存在')
|
||
|
||
if request.method == 'GET':
|
||
return success(data={
|
||
'exists': bool(story.audio_url),
|
||
'audio_url': story.audio_url,
|
||
})
|
||
|
||
# POST: 生成音频
|
||
from .services.tts_service import generate_tts_stream
|
||
|
||
response = StreamingHttpResponse(
|
||
generate_tts_stream(story),
|
||
content_type='text/event-stream',
|
||
)
|
||
response['Cache-Control'] = 'no-cache'
|
||
response['X-Accel-Buffering'] = 'no'
|
||
return response
|
||
|
||
|
||
@extend_schema(tags=['故事'])
|
||
class ShelfViewSet(viewsets.ViewSet):
|
||
"""书架视图集(App端)"""
|
||
|
||
authentication_classes = [AppJWTAuthentication]
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def list(self, request):
|
||
"""
|
||
书架列表
|
||
GET /api/v1/stories/shelves/
|
||
"""
|
||
ensure_default_stories(request.user)
|
||
|
||
shelves = StoryShelf.objects.filter(
|
||
user=request.user
|
||
).annotate(story_count=Count('stories'))
|
||
return success(data=StoryShelfSerializer(shelves, many=True).data)
|
||
|
||
def create(self, request):
|
||
"""
|
||
创建书架
|
||
POST /api/v1/stories/shelves/
|
||
"""
|
||
serializer = CreateShelfSerializer(data=request.data)
|
||
if not serializer.is_valid():
|
||
return error(message=str(serializer.errors))
|
||
|
||
shelf = StoryShelf.objects.create(
|
||
user=request.user,
|
||
name=serializer.validated_data['name'],
|
||
)
|
||
return success(data=StoryShelfSerializer(shelf).data, message='创建成功')
|
||
|
||
def destroy(self, request, pk=None):
|
||
"""
|
||
删除书架(故事保留,shelf_id 置 null)
|
||
DELETE /api/v1/stories/shelves/{id}/
|
||
"""
|
||
try:
|
||
shelf = StoryShelf.objects.get(id=pk, user=request.user)
|
||
except StoryShelf.DoesNotExist:
|
||
return error(code=ErrorCode.SHELF_NOT_FOUND, message='书架不存在')
|
||
|
||
with transaction.atomic():
|
||
try:
|
||
Story.objects.filter(shelf=shelf).update(shelf=None)
|
||
except Exception:
|
||
Story.objects.filter(shelf=shelf).delete()
|
||
shelf.delete()
|
||
return success(message='删除成功')
|
||
|
||
@action(detail=False, methods=['post'], url_path='unlock')
|
||
def unlock(self, request):
|
||
"""
|
||
积分解锁新书架
|
||
POST /api/v1/stories/shelves/unlock/
|
||
"""
|
||
from apps.users.models import PointsRecord
|
||
|
||
# 解锁费用(可后续改为从配置读取)
|
||
unlock_cost = 100
|
||
user = request.user
|
||
|
||
if user.points < unlock_cost:
|
||
return error(
|
||
code=ErrorCode.POINTS_NOT_ENOUGH,
|
||
message=f'积分不足,需要 {unlock_cost} 积分,当前 {user.points} 积分'
|
||
)
|
||
|
||
shelf_count = StoryShelf.objects.filter(user=user).count()
|
||
shelf_name = f'书架 {shelf_count + 1}'
|
||
|
||
with transaction.atomic():
|
||
# 扣除积分
|
||
user.points -= unlock_cost
|
||
user.save(update_fields=['points'])
|
||
|
||
# 创建书架
|
||
shelf = StoryShelf.objects.create(
|
||
user=user,
|
||
name=shelf_name,
|
||
unlock_cost=unlock_cost,
|
||
)
|
||
|
||
# 记录积分流水
|
||
PointsRecord.objects.create(
|
||
user=user,
|
||
amount=-unlock_cost,
|
||
type='unlock_shelf',
|
||
description=f'解锁书架「{shelf_name}」',
|
||
)
|
||
|
||
shelf.story_count = 0
|
||
return success(data={
|
||
'shelf': StoryShelfSerializer(shelf).data,
|
||
'remaining_points': user.points,
|
||
}, message='解锁成功')
|