Files
root 3618323192 fix(security): P1/P2 审计修复 + JWT HttpOnly Cookie 双模认证 + 限流
- 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)
2026-09-08 11:28:00 +08:00

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)