Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 3m32s
Wrap shelf deletion in transaction.atomic to handle cases where stories cannot be unlinked from shelf due to database constraints. Falls back to deleting stories if update fails. Fixes: Log Center Bug #13 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
261 lines
8.4 KiB
Python
261 lines
8.4 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 .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
|
||
"""
|
||
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_shelf(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='解锁成功')
|