- P0/P1 审计修复: 滑块验证码不再下发 x_position/成败即销毁 key、 user-login 补失败计数+滑块门控、限流标识改 X-Real-IP、 百度翻译 appkey 环境化、ChangeEmail/ChangePhone 补调 avalidate、 logs/tasks.py Count(filter=Q) 修复、chat 收藏 SSRF 内网黑名单 - P1 #6/7: token_blacklist + ROTATE_REFRESH_TOKENS 开启, /user/token/refresh/ 挂载 - #2 JWT HttpOnly Cookie 双模认证: user/cookie_auth.py 种/清 Cookie, user/authentication.py CookieOrHeaderJWTAuthentication(Bearer 优先/_COOKIE 兜底), user/views/token.py CookieTokenRefreshView + UserLogoutAPIView(/user/logout/), create_standardized_response 自动对含 token 的响应种 Cookie, 异步视图内 RefreshToken.for_user 全部 sync_to_async 包裹(修 SynchronousOnlyOperation 500), WS ChatConsumer 优先读 Cookie token - P2 #11 限流: utils/rate_limit.py 固定窗口频控, shorturl 生成 匿名10次/分+登录60次/分, 邮箱验证码 同邮箱60s1次+同IP10次/10min, 登录/注册验证码 错5次作废+成功即销毁防重放, 换绑邮箱/手机 同步落地, urls.py 补挂 shorturl 路由(此前 404)
792 lines
38 KiB
Python
792 lines
38 KiB
Python
from adrf.views import APIView
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
from django.conf import settings
|
|
from django.core.files.base import ContentFile
|
|
from datetime import timedelta
|
|
import uuid
|
|
import os
|
|
import ipaddress
|
|
import socket
|
|
from urllib.parse import urlparse
|
|
import aiohttp
|
|
from asgiref.sync import sync_to_async
|
|
from drf_yasg.utils import swagger_auto_schema
|
|
from drf_yasg import openapi
|
|
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response
|
|
|
|
from .models import FriendRequest, Friendship, Conversation, ConversationParticipant, ChatMessage, FavoriteSticker
|
|
from .serializers import (
|
|
FriendRequestSerializer, FriendshipSerializer,
|
|
ConversationSerializer, ChatMessageSerializer, UserBriefSerializer
|
|
)
|
|
from utils.response_codes import ResponseCode, create_standardized_response, create_standardized_error_response
|
|
from user.models import FUser
|
|
|
|
|
|
# 安全修复:SSRF 内网黑名单。收藏表情时若用户消息里的图片地址指向内网/回环/链路本地,
|
|
# 必须直接拒绝以防 SSRF。DNS 解析也参与判断以防 DNS rebinding。
|
|
SSRF_BLOCKED_NETWORKS = [
|
|
ipaddress.ip_network('0.0.0.0/8'),
|
|
ipaddress.ip_network('10.0.0.0/8'),
|
|
ipaddress.ip_network('100.64.0.0/10'),
|
|
ipaddress.ip_network('127.0.0.0/8'),
|
|
ipaddress.ip_network('169.254.0.0/16'),
|
|
ipaddress.ip_network('172.16.0.0/12'),
|
|
ipaddress.ip_network('192.0.0.0/24'),
|
|
ipaddress.ip_network('192.168.0.0/16'),
|
|
ipaddress.ip_network('198.18.0.0/15'),
|
|
ipaddress.ip_network('224.0.0.0/4'),
|
|
ipaddress.ip_network('240.0.0.0/4'),
|
|
ipaddress.ip_network('::1/128'),
|
|
]
|
|
|
|
|
|
def _ssrf_is_safe(url: str) -> bool:
|
|
"""校验外链 URL 是否指向公网域名。"""
|
|
try:
|
|
parsed = urlparse(url)
|
|
except Exception:
|
|
return False
|
|
if parsed.scheme not in ('http', 'https'):
|
|
return False
|
|
host = parsed.hostname
|
|
if not host:
|
|
return False
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
except Exception:
|
|
return False
|
|
for info in infos:
|
|
try:
|
|
ip = ipaddress.ip_address(info[4][0])
|
|
except Exception:
|
|
return False
|
|
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
|
return False
|
|
for net in SSRF_BLOCKED_NETWORKS:
|
|
if ip in net:
|
|
return False
|
|
return True
|
|
|
|
|
|
class FriendRequestListView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='获取好友请求列表',
|
|
operation_description='获取当前用户收到或发送的好友请求列表',
|
|
manual_parameters=[
|
|
openapi.Parameter('direction', openapi.IN_QUERY, description='方向: received/sent', type=openapi.TYPE_STRING),
|
|
openapi.Parameter('status', openapi.IN_QUERY, description='状态: pending/accepted/rejected', type=openapi.TYPE_STRING),
|
|
],
|
|
responses={200: success_response, 401: unauthorized_response},
|
|
)
|
|
async def get(self, request):
|
|
direction = request.query_params.get('direction', 'received')
|
|
req_status = request.query_params.get('status', 'pending')
|
|
if direction == 'sent':
|
|
queryset = FriendRequest.objects.filter(from_user=request.user, status=req_status)
|
|
else:
|
|
queryset = FriendRequest.objects.filter(to_user=request.user, status=req_status)
|
|
queryset = queryset.select_related('from_user', 'to_user')
|
|
requests_list = [r async for r in queryset]
|
|
serializer = FriendRequestSerializer(requests_list, many=True, context={'request': request})
|
|
return create_standardized_response(data=await serializer.adata, code=ResponseCode.SUCCESS)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='发送好友请求',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
properties={
|
|
'to_user_id': openapi.Schema(type=openapi.TYPE_INTEGER),
|
|
'message': openapi.Schema(type=openapi.TYPE_STRING),
|
|
},
|
|
),
|
|
responses={201: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request):
|
|
to_user_id = request.data.get('to_user_id')
|
|
message = request.data.get('message', '')
|
|
if not to_user_id:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='to_user_id 不能为空', status_code=status.HTTP_400_BAD_REQUEST)
|
|
if int(to_user_id) == request.user.id:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='不能向自己发送好友请求', status_code=status.HTTP_400_BAD_REQUEST)
|
|
try:
|
|
to_user = await FUser.objects.aget(pk=to_user_id)
|
|
except FUser.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='用户不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
if await Friendship.objects.filter(
|
|
(Q(user1=request.user) & Q(user2=to_user)) | (Q(user1=to_user) & Q(user2=request.user))
|
|
).aexists():
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='已经是好友关系', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
existing = await FriendRequest.objects.filter(from_user=request.user, to_user=to_user, status='pending').afirst()
|
|
if existing:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='已发送过好友请求', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
reverse = await FriendRequest.objects.filter(from_user=to_user, to_user=request.user, status='pending').afirst()
|
|
if reverse:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='对方已向你发送好友请求,请直接接受', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
friend_request = await FriendRequest.objects.acreate(from_user=request.user, to_user=to_user, message=message)
|
|
serializer = FriendRequestSerializer(friend_request, context={'request': request})
|
|
return create_standardized_response(data=await serializer.adata, code=ResponseCode.SUCCESS, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
|
class FriendRequestAcceptView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='接受好友请求',
|
|
operation_description='接受指定的好友请求并建立好友关系',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='好友请求ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request, pk):
|
|
try:
|
|
friend_request = await FriendRequest.objects.aget(pk=pk, to_user_id=request.user.id, status='pending')
|
|
except FriendRequest.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='好友请求不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
friend_request.status = 'accepted'
|
|
await friend_request.asave()
|
|
|
|
u1, u2 = sorted([friend_request.from_user_id, friend_request.to_user_id])
|
|
await Friendship.objects.aget_or_create(user1_id=u1, user2_id=u2)
|
|
|
|
return create_standardized_response(data={'status': 'accepted'}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class FriendRequestRejectView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='拒绝好友请求',
|
|
operation_description='拒绝指定的好友请求',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='好友请求ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request, pk):
|
|
try:
|
|
friend_request = await FriendRequest.objects.aget(pk=pk, to_user_id=request.user.id, status='pending')
|
|
except FriendRequest.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='好友请求不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
friend_request.status = 'rejected'
|
|
await friend_request.asave()
|
|
return create_standardized_response(data={'status': 'rejected'}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class FriendRequestCancelView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='取消好友请求',
|
|
operation_description='取消自己发送的好友请求',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='好友请求ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request, pk):
|
|
try:
|
|
friend_request = await FriendRequest.objects.aget(pk=pk, from_user_id=request.user.id, status='pending')
|
|
except FriendRequest.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='好友请求不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
friend_request.status = 'cancelled'
|
|
await friend_request.asave()
|
|
return create_standardized_response(data={'status': 'cancelled'}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class FriendListView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='获取好友列表',
|
|
operation_description='获取当前用户的好友列表',
|
|
responses={200: success_response, 401: unauthorized_response},
|
|
)
|
|
async def get(self, request):
|
|
friendships = Friendship.objects.filter(
|
|
Q(user1=request.user) | Q(user2=request.user)
|
|
).select_related('user1', 'user2')
|
|
friendships_list = [f async for f in friendships]
|
|
serializer = FriendshipSerializer(friendships_list, many=True, context={'request': request})
|
|
return create_standardized_response(data=await serializer.adata, code=ResponseCode.SUCCESS)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='删除好友',
|
|
operation_description='解除与指定用户的好友关系',
|
|
manual_parameters=[openapi.Parameter('user_id', openapi.IN_PATH, description='目标用户ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={204: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def delete(self, request, user_id):
|
|
try:
|
|
target_user = await FUser.objects.aget(pk=user_id)
|
|
except FUser.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='用户不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
u1, u2 = sorted([request.user.id, target_user.id])
|
|
deleted, _ = await Friendship.objects.filter(user1_id=u1, user2_id=u2).adelete()
|
|
if deleted:
|
|
return create_standardized_response(code=ResponseCode.SUCCESS, status_code=status.HTTP_204_NO_CONTENT)
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='好友关系不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
|
|
class FriendCheckView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='检查好友关系',
|
|
operation_description='检查与指定用户的好友关系及待定好友请求',
|
|
manual_parameters=[openapi.Parameter('user_id', openapi.IN_PATH, description='目标用户ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def get(self, request, user_id):
|
|
try:
|
|
target_user = await FUser.objects.aget(pk=user_id)
|
|
except FUser.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='用户不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
u1, u2 = sorted([request.user.id, target_user.id])
|
|
is_friend = await Friendship.objects.filter(user1_id=u1, user2_id=u2).aexists()
|
|
|
|
pending_request = await FriendRequest.objects.filter(
|
|
(Q(from_user_id=request.user.id, to_user_id=target_user.id) | Q(from_user_id=target_user.id, to_user_id=request.user.id)),
|
|
status='pending'
|
|
).afirst()
|
|
|
|
return create_standardized_response(data={
|
|
'is_friend': is_friend,
|
|
'pending_request': pending_request.id if pending_request else None,
|
|
}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class UserSearchView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='搜索用户',
|
|
operation_description='按用户名或昵称搜索用户',
|
|
manual_parameters=[openapi.Parameter('q', openapi.IN_QUERY, description='搜索关键词', type=openapi.TYPE_STRING)],
|
|
responses={200: success_response, 401: unauthorized_response},
|
|
)
|
|
async def get(self, request):
|
|
q = request.query_params.get('q', '').strip()
|
|
if not q:
|
|
return create_standardized_response(data=[], code=ResponseCode.SUCCESS)
|
|
|
|
users = FUser.objects.filter(
|
|
Q(username__icontains=q) | Q(nickname__icontains=q)
|
|
).exclude(id=request.user.id)[:20]
|
|
|
|
friend_ids = set()
|
|
friendships = Friendship.objects.filter(
|
|
Q(user1_id=request.user.id) | Q(user2_id=request.user.id)
|
|
).values_list('user1_id', 'user2_id')
|
|
async for user1_id, user2_id in friendships:
|
|
friend_ids.add(user2_id if user1_id == request.user.id else user1_id)
|
|
|
|
results = []
|
|
async for user in users:
|
|
avatar_url = ''
|
|
if user.avatar and hasattr(user.avatar, 'url'):
|
|
avatar_url = request.build_absolute_uri(user.avatar.url)
|
|
results.append({
|
|
'id': user.id,
|
|
'username': user.username,
|
|
'nickname': getattr(user, 'nickname', '') or user.username,
|
|
'avatar': avatar_url,
|
|
'is_friend': user.id in friend_ids,
|
|
})
|
|
|
|
return create_standardized_response(data=results, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class ConversationListView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='获取会话列表',
|
|
operation_description='获取当前用户参与的所有会话',
|
|
responses={200: success_response, 401: unauthorized_response},
|
|
)
|
|
async def get(self, request):
|
|
participations = ConversationParticipant.objects.filter(
|
|
user=request.user
|
|
).select_related('conversation').order_by('-conversation__created_at')
|
|
conversations = [p.conversation async for p in participations]
|
|
serializer = ConversationSerializer(conversations, many=True, context={'request': request})
|
|
# 兜底:ConversationSerializer 的 SerializerMethodField 内部有同步 ORM 查询
|
|
data = await serializer.adata
|
|
return create_standardized_response(data=data, code=ResponseCode.SUCCESS)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='创建会话',
|
|
operation_description='与指定好友创建私聊会话',
|
|
request_body=openapi.Schema(type=openapi.TYPE_OBJECT, properties={'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='目标用户ID')}),
|
|
responses={201: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request):
|
|
user_id = request.data.get('user_id')
|
|
if not user_id:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='user_id 不能为空', status_code=status.HTTP_400_BAD_REQUEST)
|
|
if int(user_id) == request.user.id:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='不能和自己聊天', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
try:
|
|
target_user = await FUser.objects.aget(pk=user_id)
|
|
except FUser.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='用户不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
u1, u2 = sorted([request.user.id, target_user.id])
|
|
if not await Friendship.objects.filter(user1_id=u1, user2_id=u2).aexists():
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='只能与好友聊天', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
my_participations = ConversationParticipant.objects.filter(
|
|
user=request.user, conversation__type='private'
|
|
).values_list('conversation_id', flat=True)
|
|
existing = await ConversationParticipant.objects.filter(
|
|
user=target_user, conversation_id__in=my_participations, conversation__type='private'
|
|
).select_related('conversation').afirst()
|
|
|
|
if existing:
|
|
conversation = existing.conversation
|
|
else:
|
|
conversation = await Conversation.objects.acreate(type='private')
|
|
await ConversationParticipant.objects.acreate(conversation=conversation, user=request.user)
|
|
await ConversationParticipant.objects.acreate(conversation=conversation, user=target_user)
|
|
|
|
serializer = ConversationSerializer(conversation, context={'request': request})
|
|
# 兜底:ConversationSerializer 的 SerializerMethodField 内部有同步 ORM 查询
|
|
data = await serializer.adata
|
|
return create_standardized_response(data=data, code=ResponseCode.SUCCESS, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
|
class ConversationMessageView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='获取会话消息列表',
|
|
operation_description='分页获取指定会话的消息列表',
|
|
manual_parameters=[
|
|
openapi.Parameter('pk', openapi.IN_PATH, description='会话ID', type=openapi.TYPE_INTEGER, required=True),
|
|
openapi.Parameter('page', openapi.IN_QUERY, description='页码', type=openapi.TYPE_INTEGER),
|
|
openapi.Parameter('page_size', openapi.IN_QUERY, description='每页数量', type=openapi.TYPE_INTEGER),
|
|
],
|
|
responses={200: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def get(self, request, pk):
|
|
try:
|
|
conversation = await Conversation.objects.aget(pk=pk)
|
|
except Conversation.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='会话不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
if not await ConversationParticipant.objects.filter(conversation=conversation, user=request.user).aexists():
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='你不是该会话的参与者', status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
page = int(request.query_params.get('page', 1))
|
|
page_size = int(request.query_params.get('page_size', 50))
|
|
offset = (page - 1) * page_size
|
|
|
|
messages = ChatMessage.objects.filter(conversation=conversation).select_related('sender', 'reply_to', 'reply_to__sender')
|
|
total = await messages.acount()
|
|
page_messages = [m async for m in messages[offset:offset + page_size]]
|
|
|
|
serializer = ChatMessageSerializer(page_messages, many=True, context={'request': request})
|
|
# 兜底:'conversation' 外键未预加载,serializer 取值会触发同步 ORM
|
|
data = await serializer.adata
|
|
return create_standardized_response(data={
|
|
'results': data,
|
|
'total': total,
|
|
'page': page,
|
|
'page_size': page_size,
|
|
}, code=ResponseCode.SUCCESS)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='发送会话消息',
|
|
operation_description='向指定会话发送文本消息,支持回复引用',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='会话ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
properties={
|
|
'content': openapi.Schema(type=openapi.TYPE_STRING, description='消息内容'),
|
|
'msg_type': openapi.Schema(type=openapi.TYPE_STRING, description='消息类型 text/image/file'),
|
|
'file_url': openapi.Schema(type=openapi.TYPE_STRING, description='文件URL'),
|
|
'reply_to': openapi.Schema(type=openapi.TYPE_INTEGER, description='回复的消息ID'),
|
|
},
|
|
),
|
|
responses={201: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request, pk):
|
|
try:
|
|
conversation = await Conversation.objects.aget(pk=pk)
|
|
except Conversation.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='会话不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
if not await ConversationParticipant.objects.filter(conversation=conversation, user=request.user).aexists():
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='你不是该会话的参与者', status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
content = request.data.get('content', '').strip()
|
|
if not content:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='消息内容不能为空', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
msg_type = request.data.get('msg_type', 'text')
|
|
file_url = request.data.get('file_url', '')
|
|
reply_to_id = request.data.get('reply_to')
|
|
|
|
reply_to = None
|
|
if reply_to_id:
|
|
try:
|
|
reply_to = await ChatMessage.objects.aget(pk=reply_to_id, conversation=conversation)
|
|
except ChatMessage.DoesNotExist:
|
|
pass
|
|
|
|
message = await ChatMessage.objects.acreate(
|
|
conversation=conversation,
|
|
sender=request.user,
|
|
content=content,
|
|
msg_type=msg_type,
|
|
file_url=file_url,
|
|
reply_to=reply_to,
|
|
)
|
|
|
|
serializer = ChatMessageSerializer(message, context={'request': request})
|
|
return create_standardized_response(data=await serializer.adata, code=ResponseCode.SUCCESS, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
|
class ConversationClearView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='清空会话聊天记录',
|
|
operation_description='删除指定会话下的所有消息(仅当前用户的视图)',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='会话ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def delete(self, request, pk):
|
|
try:
|
|
conversation = await Conversation.objects.aget(pk=pk)
|
|
except Conversation.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='会话不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
if not await ConversationParticipant.objects.filter(conversation=conversation, user=request.user).aexists():
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='你不是该会话的参与者', status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
# 物理删除该会话下所有消息
|
|
deleted_count, _ = await ChatMessage.objects.filter(conversation=conversation).adelete()
|
|
|
|
return create_standardized_response(data={'deleted': deleted_count}, code=ResponseCode.SUCCESS)
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='标记会话已读',
|
|
operation_description='标记指定会话为已读状态',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='会话ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request, pk):
|
|
try:
|
|
participant = await ConversationParticipant.objects.aget(conversation_id=pk, user=request.user)
|
|
except ConversationParticipant.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='会话不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
participant.last_read_at = timezone.now()
|
|
await participant.asave(update_fields=['last_read_at'])
|
|
return create_standardized_response(data={'read': True}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class MessageRecallView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='撤回消息',
|
|
operation_description='在发送后2分钟内可撤回自己发送的消息',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='消息ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request, pk):
|
|
try:
|
|
message = await ChatMessage.objects.aget(pk=pk)
|
|
except ChatMessage.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='消息不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
if message.sender_id != request.user.id:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='只能撤回自己发送的消息', status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
if timezone.now() - message.created_at > timedelta(minutes=2):
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='超过2分钟无法撤回', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
message.is_recalled = True
|
|
message.content = '你撤回了一条消息'
|
|
await message.asave(update_fields=['is_recalled', 'content'])
|
|
|
|
return create_standardized_response(data={'recalled': True}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class MessageDeleteView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='删除消息',
|
|
operation_description='删除自己发送的消息(物理删除)',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='消息ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response, 403: error_response, 404: not_found_response},
|
|
)
|
|
async def delete(self, request, pk):
|
|
try:
|
|
message = await ChatMessage.objects.aget(pk=pk)
|
|
except ChatMessage.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='消息不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
if message.sender_id != request.user.id:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='只能删除自己发送的消息', status_code=status.HTTP_403_FORBIDDEN)
|
|
|
|
await message.adelete()
|
|
return create_standardized_response(data={'deleted': True}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class FileUploadView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [MultiPartParser, FormParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='上传聊天文件',
|
|
operation_description='上传聊天消息中的文件,返回可访问的URL',
|
|
manual_parameters=[openapi.Parameter('file', openapi.IN_FORM, description='文件', type=openapi.TYPE_FILE, required=True)],
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response},
|
|
)
|
|
async def post(self, request):
|
|
file = request.FILES.get('file')
|
|
if not file:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='请选择文件', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
ext = os.path.splitext(file.name)[1]
|
|
date_path = timezone.now().strftime('%Y/%m/%d')
|
|
filename = f'{uuid.uuid4().hex}{ext}'
|
|
filepath = f'chat_files/{date_path}/{filename}'
|
|
|
|
from django.core.files.storage import default_storage
|
|
saved_path = await sync_to_async(default_storage.save)(filepath, file)
|
|
url = request.build_absolute_uri(settings.MEDIA_URL + saved_path)
|
|
|
|
return create_standardized_response(data={
|
|
'url': url,
|
|
'file_name': file.name,
|
|
'file_size': file.size,
|
|
}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class StickerUploadView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [MultiPartParser, FormParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='上传表情',
|
|
operation_description='上传图片作为个人表情收藏',
|
|
manual_parameters=[openapi.Parameter('file', openapi.IN_FORM, description='图片文件', type=openapi.TYPE_FILE, required=True)],
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response},
|
|
)
|
|
async def post(self, request):
|
|
file = request.FILES.get('file')
|
|
if not file:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='请选择文件', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if not file.content_type.startswith('image/'):
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='只能上传图片文件', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
ext = os.path.splitext(file.name)[1] or '.png'
|
|
filename = f'{uuid.uuid4().hex}{ext}'
|
|
filepath = f'chat_stickers/{request.user.id}/{filename}'
|
|
|
|
from django.core.files.storage import default_storage
|
|
saved_path = await sync_to_async(default_storage.save)(filepath, file)
|
|
|
|
sticker = await FavoriteSticker.objects.acreate(
|
|
user=request.user,
|
|
image=saved_path,
|
|
)
|
|
|
|
url = request.build_absolute_uri(sticker.image.url)
|
|
return create_standardized_response(data={
|
|
'id': sticker.id,
|
|
'url': url,
|
|
}, code=ResponseCode.SUCCESS)
|
|
|
|
|
|
class FavoriteStickerView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='获取收藏表情列表',
|
|
responses={200: success_response, 401: unauthorized_response},
|
|
)
|
|
async def get(self, request):
|
|
stickers = FavoriteSticker.objects.filter(user=request.user)
|
|
data = []
|
|
async for s in stickers:
|
|
data.append({
|
|
'id': s.id,
|
|
'url': request.build_absolute_uri(s.image.url) if s.image else '',
|
|
})
|
|
return create_standardized_response(data=data, code=ResponseCode.SUCCESS)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='添加收藏表情',
|
|
manual_parameters=[openapi.Parameter('image', openapi.IN_FORM, description='图片文件', type=openapi.TYPE_FILE, required=True)],
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response},
|
|
)
|
|
async def post(self, request):
|
|
file = request.FILES.get('image')
|
|
if not file:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='请选择图片', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if not file.content_type.startswith('image/'):
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='只能上传图片', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
ext = os.path.splitext(file.name)[1] or '.png'
|
|
filename = f'{uuid.uuid4().hex}{ext}'
|
|
filepath = f'chat_stickers/{request.user.id}/{filename}'
|
|
|
|
from django.core.files.storage import default_storage
|
|
saved_path = await sync_to_async(default_storage.save)(filepath, file)
|
|
|
|
sticker = await FavoriteSticker.objects.acreate(
|
|
user=request.user,
|
|
image=saved_path,
|
|
)
|
|
|
|
url = request.build_absolute_uri(sticker.image.url)
|
|
return create_standardized_response(data={
|
|
'id': sticker.id,
|
|
'url': url,
|
|
}, code=ResponseCode.SUCCESS)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='删除收藏表情',
|
|
manual_parameters=[openapi.Parameter('pk', openapi.IN_PATH, description='表情ID', type=openapi.TYPE_INTEGER, required=True)],
|
|
responses={204: success_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def delete(self, request, pk):
|
|
try:
|
|
sticker = await FavoriteSticker.objects.aget(pk=pk, user=request.user)
|
|
await sticker.adelete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
except FavoriteSticker.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='收藏不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
|
|
class FavoriteStickerFromMessageView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['AI对话'],
|
|
operation_summary='从消息收藏表情',
|
|
operation_description='将消息中的图片收藏为个人表情',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
properties={
|
|
'message_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='消息ID'),
|
|
},
|
|
),
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response},
|
|
)
|
|
async def post(self, request):
|
|
message_id = request.data.get('message_id')
|
|
if not message_id:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='message_id 不能为空', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
try:
|
|
message = await ChatMessage.objects.aget(pk=message_id)
|
|
except ChatMessage.DoesNotExist:
|
|
return create_standardized_error_response(code=ResponseCode.NOT_FOUND, message='消息不存在', status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
file_url = message.file_url or message.content
|
|
if not file_url:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='该消息没有可收藏的图片', status_code=status.HTTP_400_BAD_REQUEST)
|
|
|
|
try:
|
|
if file_url.startswith('http'):
|
|
# 安全修复:先做 SSRF 内网黑名单校验,再走网络;host 必须解析到公网
|
|
if not _ssrf_is_safe(file_url):
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VALIDATION_ERROR,
|
|
message='消息图片链接不可访问',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
async with session.get(file_url, headers={'User-Agent': 'Mozilla/5.0'}) as resp:
|
|
image_data = await resp.read()
|
|
else:
|
|
local_path = os.path.join(settings.MEDIA_ROOT, file_url.replace(settings.MEDIA_URL, ''))
|
|
|
|
def _read_local(p):
|
|
with open(p, 'rb') as f:
|
|
return f.read()
|
|
|
|
image_data = await sync_to_async(_read_local)(local_path)
|
|
|
|
ext = '.png'
|
|
if '.' in file_url.split('/')[-1]:
|
|
ext = '.' + file_url.split('/')[-1].split('.')[-1].split('?')[0]
|
|
filename = f'{uuid.uuid4().hex}{ext}'
|
|
filepath = f'chat_stickers/{request.user.id}/{filename}'
|
|
|
|
from django.core.files.storage import default_storage
|
|
saved_path = await sync_to_async(default_storage.save)(filepath, ContentFile(image_data))
|
|
|
|
sticker = await FavoriteSticker.objects.acreate(
|
|
user=request.user,
|
|
image=saved_path,
|
|
)
|
|
|
|
url = request.build_absolute_uri(sticker.image.url)
|
|
return create_standardized_response(data={
|
|
'id': sticker.id,
|
|
'url': url,
|
|
}, code=ResponseCode.SUCCESS)
|
|
except Exception as e:
|
|
return create_standardized_error_response(code=ResponseCode.VALIDATION_ERROR, message='保存失败', status_code=status.HTTP_400_BAD_REQUEST)
|