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