repair-agent 487b258bbe 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>
2026-03-02 15:16:38 +08:00

263 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
故事模块视图 - 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='解锁成功')