- 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)
1181 lines
50 KiB
Python
1181 lines
50 KiB
Python
import os
|
|
import uuid
|
|
import logging
|
|
|
|
from utils.async_cache import aget_cache, aset_cache, adelete_cache
|
|
from asgiref.sync import sync_to_async
|
|
from utils.email_utils import validate_email_mx
|
|
from rest_framework.decorators import permission_classes
|
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from adrf.views import APIView
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
from django.contrib.auth import authenticate
|
|
from django.conf import settings
|
|
from django.core.files.storage import default_storage
|
|
from utils import RandCode
|
|
from django.core.cache import caches
|
|
default_cache = caches['default']
|
|
session_cache = caches['session']
|
|
celery_cache = caches['celery']
|
|
from ..models import FUser, LoginRecord, Follow
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
from ..serializers.user_serializers import UserSerializer, ChangePasswordSerializer, UserUpdateSerializer
|
|
from ..tasks import send_verification_email_task, send_reset_password_email_task
|
|
from logs.utils import log_event
|
|
from .tasks import track_user_action
|
|
from utils.captcha import check_captcha_required, record_failure, reset_failures
|
|
from utils.slider_captcha import verify_slider_captcha, SliderCaptchaError
|
|
from utils.safe_task import submit_task
|
|
from utils.rate_limit import (
|
|
check_rate_limit,
|
|
record_failure as rl_record_failure,
|
|
reset_failures as rl_reset_failures,
|
|
get_client_ip,
|
|
)
|
|
from utils.response_codes import (
|
|
ResponseCode,
|
|
create_standardized_response,
|
|
create_standardized_error_response
|
|
)
|
|
from drf_yasg.utils import swagger_auto_schema
|
|
|
|
logger = logging.getLogger(__name__)
|
|
from drf_yasg import openapi
|
|
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response
|
|
|
|
|
|
def _get_client_ip(request):
|
|
real_ip = request.META.get('HTTP_X_REAL_IP')
|
|
if real_ip:
|
|
return real_ip.strip()
|
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
|
if x_forwarded_for:
|
|
return x_forwarded_for.split(',')[0].strip()
|
|
return request.META.get('REMOTE_ADDR', '').strip()
|
|
|
|
|
|
def _create_login_record(request, user, record_status):
|
|
from user.services import create_login_record
|
|
create_login_record(request, user, record_status)
|
|
|
|
|
|
def _serialize_user(user):
|
|
"""同步序列化助手:在 async 视图中通过 sync_to_async 调用,避免 SynchronousOnlyOperation。"""
|
|
return UserSerializer(user).data
|
|
|
|
|
|
class SendUserEmailAPIView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['认证'],
|
|
operation_summary='发送邮箱验证码',
|
|
operation_description='向指定邮箱发送验证码,若邮箱未注册则发送注册验证码,已注册则发送登录验证码',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
required=['to_email'],
|
|
properties={
|
|
'to_email': openapi.Schema(type=openapi.TYPE_STRING, description='目标邮箱地址'),
|
|
}
|
|
),
|
|
responses={201: success_response, 200: success_response, 400: error_response, 500: error_response},
|
|
)
|
|
async def post(self, request):
|
|
to_email = request.data.get('to_email', None)
|
|
|
|
if to_email is None or to_email == "":
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.EMAIL_EMPTY,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# 频控:同邮箱 60 秒内只能发送 1 次
|
|
if not await sync_to_async(check_rate_limit)('email_send_target', to_email.lower(), limit=1, window_seconds=60):
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="验证码发送过于频繁,请60秒后再试",
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS
|
|
)
|
|
|
|
# 频控:同 IP 10 分钟内最多发送 10 次
|
|
client_ip = _get_client_ip(request)
|
|
if client_ip and not await sync_to_async(check_rate_limit)('email_send_ip', client_ip, limit=10, window_seconds=600):
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="请求过于频繁,请稍后再试",
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS
|
|
)
|
|
|
|
if not await sync_to_async(validate_email_mx)(to_email):
|
|
logger.warning(f'[Email] Domain MX check failed: email={to_email}')
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.EMAIL_DOMAIN_INVALID,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
user_exists_result = await FUser.objects.filter(email=to_email).aexists()
|
|
|
|
if not user_exists_result:
|
|
code = RandCode.get_digit_characters_code_8()
|
|
await aset_cache(f"register_{to_email}", code, timeout=600)
|
|
|
|
result = await sync_to_async(submit_task)(send_verification_email_task, to_email, code, 'register')
|
|
if result is None:
|
|
logger.warning(f'[Register] Email send failed: email={to_email}')
|
|
else:
|
|
logger.info(f'[Register] Email sent successfully: email={to_email}')
|
|
|
|
return create_standardized_response(
|
|
data={"email_sent": True, "type": "register"},
|
|
code=ResponseCode.EMAIL_SENT_REGISTER,
|
|
status_code=status.HTTP_201_CREATED
|
|
)
|
|
else:
|
|
code = RandCode.get_digit_characters_code_8()
|
|
await aset_cache(f"login_{to_email}", code, timeout=600)
|
|
|
|
result = await sync_to_async(submit_task)(send_verification_email_task, to_email, code, 'login')
|
|
if result is None:
|
|
logger.warning(f'[Login] Email send failed: email={to_email}')
|
|
else:
|
|
logger.info(f'[Login] Email sent successfully: email={to_email}')
|
|
|
|
return create_standardized_response(
|
|
data={"email_sent": True, "type": "login"},
|
|
code=ResponseCode.EMAIL_SENT_LOGIN,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
|
|
except Exception as e:
|
|
return create_standardized_error_response(
|
|
message=f"邮件发送失败: {str(e)}",
|
|
code=ResponseCode.EMAIL_SEND_FAILED,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
class UserLoginOrRegisterAPIView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['认证'],
|
|
operation_summary='邮箱验证码登录或注册',
|
|
operation_description='使用邮箱和验证码进行登录或注册,新用户自动注册并返回token',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
required=['email', 'code'],
|
|
properties={
|
|
'email': openapi.Schema(type=openapi.TYPE_STRING, description='邮箱地址'),
|
|
'code': openapi.Schema(type=openapi.TYPE_STRING, description='验证码'),
|
|
'username': openapi.Schema(type=openapi.TYPE_STRING, description='用户名(注册时可选)'),
|
|
}
|
|
),
|
|
responses={200: success_response, 400: error_response, 500: error_response},
|
|
)
|
|
async def post(self, request):
|
|
code = request.data.get('code', None)
|
|
to_email = request.data.get('email', None)
|
|
|
|
if code is None or to_email is None or code == "" or to_email == "":
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
user = await FUser.objects.filter(email=to_email).afirst()
|
|
|
|
if user is None:
|
|
# Registration flow
|
|
vcode = await aget_cache(f"register_{to_email}")
|
|
|
|
if vcode is None:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VERIFICATION_CODE_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if code == vcode:
|
|
# 安全加固:验证码成即销毁,防重放攻击
|
|
await adelete_cache(f"register_{to_email}")
|
|
await sync_to_async(rl_reset_failures)('reg_vcode', to_email.lower())
|
|
|
|
user_serializer = UserSerializer(data=request.data)
|
|
if await sync_to_async(user_serializer.is_valid)():
|
|
user = await user_serializer.acreate_by_email(request.data)
|
|
refresh = await sync_to_async(RefreshToken.for_user)(user)
|
|
user_data = await UserSerializer(user).adata
|
|
|
|
# Prepare response data
|
|
response_data = {
|
|
'user': user_data,
|
|
'refresh': str(refresh),
|
|
'access': str(refresh.access_token),
|
|
'token_type': 'bearer',
|
|
'expires_at_timestamp': refresh.access_token.payload['exp']
|
|
}
|
|
|
|
await sync_to_async(_create_login_record)(request, user, 'success')
|
|
|
|
return create_standardized_response(
|
|
data=response_data,
|
|
code=ResponseCode.REGISTRATION_SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
else:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.USER_DATA_INVALID,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
else:
|
|
# 安全加固:验证码错误累计计数,5 次即直接销毁,杜绝穷举爆破
|
|
fails = await sync_to_async(rl_record_failure)('reg_vcode', to_email.lower(), max_failures=5, window_seconds=300)
|
|
if fails >= 5:
|
|
await adelete_cache(f"register_{to_email}")
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VERIFICATION_CODE_EXPIRED,
|
|
message="验证码错误次数超限,已作废,请重新获取",
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VERIFICATION_CODE_ERROR,
|
|
message=f"验证码错误,还剩 {5 - fails} 次尝试机会",
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
else:
|
|
# Login flow
|
|
vcode = await aget_cache(f"login_{to_email}")
|
|
|
|
if vcode is None:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.LOGIN_VERIFICATION_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if code == vcode:
|
|
# 安全加固:验证码成即销毁,防重放攻击
|
|
await adelete_cache(f"login_{to_email}")
|
|
await sync_to_async(rl_reset_failures)('login_vcode', to_email.lower())
|
|
|
|
refresh = await sync_to_async(RefreshToken.for_user)(user)
|
|
user_data = await UserSerializer(user).adata
|
|
|
|
# Prepare response data
|
|
response_data = {
|
|
'user': user_data,
|
|
'refresh': str(refresh),
|
|
'access': str(refresh.access_token),
|
|
'token_type': 'bearer',
|
|
'expires_at_timestamp': refresh.access_token.payload['exp']
|
|
}
|
|
|
|
await sync_to_async(_create_login_record)(request, user, 'success')
|
|
|
|
return create_standardized_response(
|
|
data=response_data,
|
|
code=ResponseCode.LOGIN_SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
else:
|
|
await sync_to_async(_create_login_record)(request, user, 'failed')
|
|
# 安全加固:验证码错误累计计数,5 次即直接销毁,杜绝穷举爆破
|
|
fails = await sync_to_async(rl_record_failure)('login_vcode', to_email.lower(), max_failures=5, window_seconds=300)
|
|
if fails >= 5:
|
|
await adelete_cache(f"login_{to_email}")
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.LOGIN_VERIFICATION_EXPIRED,
|
|
message="验证码错误次数超限,已作废,请重新获取",
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.LOGIN_VERIFICATION_ERROR,
|
|
message=f"验证码错误,还剩 {5 - fails} 次尝试机会",
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
except Exception as e:
|
|
return create_standardized_error_response(
|
|
message=f"服务器内部错误: {str(e)}",
|
|
code=ResponseCode.SERVER_INTERNAL_ERROR,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
|
|
class ForgotPasswordSendCodeAPIView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['认证'],
|
|
operation_summary='发送忘记密码验证码',
|
|
operation_description='向已注册邮箱发送重置密码验证码,失败次数过多需要验证码',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
required=['email'],
|
|
properties={
|
|
'email': openapi.Schema(type=openapi.TYPE_STRING, description='注册邮箱地址'),
|
|
'captcha_key': openapi.Schema(type=openapi.TYPE_STRING, description='验证码key(需要时必填)'),
|
|
'captcha_code': openapi.Schema(type=openapi.TYPE_STRING, description='验证码(需要时必填)'),
|
|
}
|
|
),
|
|
responses={200: success_response, 400: error_response, 500: error_response},
|
|
)
|
|
async def post(self, request):
|
|
from utils.captcha import check_captcha_required, verify_captcha, record_failure, reset_failures
|
|
|
|
to_email = request.data.get('email', None)
|
|
|
|
if not to_email:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.EMAIL_EMPTY,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# 频控:同邮箱 60 秒内只能发送 1 次
|
|
if not await sync_to_async(check_rate_limit)('forgot_send', to_email.lower(), limit=1, window_seconds=60):
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="验证码发送过于频繁,请60秒后再试",
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS
|
|
)
|
|
|
|
if not await sync_to_async(validate_email_mx)(to_email):
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.EMAIL_DOMAIN_INVALID,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
identifier = to_email
|
|
operation = 'forgot_send'
|
|
captcha_required = await sync_to_async(check_captcha_required)(operation, identifier)
|
|
|
|
if captcha_required:
|
|
captcha_key = request.data.get('captcha_key', None)
|
|
captcha_code = request.data.get('captcha_code', None)
|
|
|
|
if not captcha_key or not captcha_code:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_REQUIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
captcha_result = await sync_to_async(verify_captcha)(captcha_key, captcha_code)
|
|
if captcha_result == 'expired':
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
elif captcha_result == 'wrong':
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_ERROR,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
user = await FUser.objects.filter(email=to_email).afirst()
|
|
|
|
if user is None:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.USER_NOT_FOUND,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
code = RandCode.get_digit_characters_code_8()
|
|
await aset_cache(f"reset_password_{to_email}", code, timeout=600)
|
|
|
|
result = await sync_to_async(submit_task)(send_reset_password_email_task, to_email, code)
|
|
if result is None:
|
|
logger.warning(f'[ResetPassword] Email send failed (both sync and async): email={to_email}')
|
|
else:
|
|
logger.info(f'[ResetPassword] Email sent successfully: email={to_email}')
|
|
|
|
await sync_to_async(reset_failures)(operation, to_email)
|
|
return create_standardized_response(
|
|
data={"email_sent": True},
|
|
code=ResponseCode.RESET_CODE_SENT,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
|
|
except Exception as e:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
message=f"邮件发送失败: {str(e)}",
|
|
code=ResponseCode.EMAIL_SEND_FAILED,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
|
|
class ForgotPasswordResetAPIView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['认证'],
|
|
operation_summary='重置密码',
|
|
operation_description='使用验证码重置密码,密码需至少8位且包含字母和数字',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
required=['email', 'code', 'new_password', 'confirm_password'],
|
|
properties={
|
|
'email': openapi.Schema(type=openapi.TYPE_STRING, description='注册邮箱地址'),
|
|
'code': openapi.Schema(type=openapi.TYPE_STRING, description='验证码'),
|
|
'new_password': openapi.Schema(type=openapi.TYPE_STRING, description='新密码'),
|
|
'confirm_password': openapi.Schema(type=openapi.TYPE_STRING, description='确认密码'),
|
|
'captcha_key': openapi.Schema(type=openapi.TYPE_STRING, description='验证码key(需要时必填)'),
|
|
'captcha_code': openapi.Schema(type=openapi.TYPE_STRING, description='验证码(需要时必填)'),
|
|
}
|
|
),
|
|
responses={200: success_response, 400: error_response, 500: error_response},
|
|
)
|
|
async def post(self, request):
|
|
from utils.captcha import check_captcha_required, verify_captcha, record_failure, reset_failures
|
|
|
|
to_email = request.data.get('email', None)
|
|
code = request.data.get('code', None)
|
|
new_password = request.data.get('new_password', None)
|
|
confirm_password = request.data.get('confirm_password', None)
|
|
|
|
if not to_email or not code or not new_password or not confirm_password:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="邮箱、验证码和密码不能为空",
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
identifier = to_email
|
|
operation = 'forgot_reset'
|
|
captcha_required = await sync_to_async(check_captcha_required)(operation, identifier)
|
|
|
|
if captcha_required:
|
|
captcha_key = request.data.get('captcha_key', None)
|
|
captcha_code = request.data.get('captcha_code', None)
|
|
|
|
if not captcha_key or not captcha_code:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_REQUIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
captcha_result = await sync_to_async(verify_captcha)(captcha_key, captcha_code)
|
|
if captcha_result == 'expired':
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
elif captcha_result == 'wrong':
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_ERROR,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if new_password != confirm_password:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PASSWORD_MISMATCH,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if len(new_password) < 8:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PASSWORD_TOO_SHORT,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
has_letter = any(c.isalpha() for c in new_password)
|
|
has_digit = any(c.isdigit() for c in new_password)
|
|
if not (has_letter and has_digit):
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PASSWORD_TOO_WEAK,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
vcode = await aget_cache(f"reset_password_{to_email}")
|
|
|
|
if vcode is None:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VERIFICATION_CODE_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if code != vcode:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VERIFICATION_CODE_ERROR,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
user = await FUser.objects.filter(email=to_email).afirst()
|
|
|
|
if user is None:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.USER_NOT_FOUND,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
await sync_to_async(user.set_password)(new_password)
|
|
await user.asave()
|
|
|
|
await adelete_cache(f"reset_password_{to_email}")
|
|
|
|
await sync_to_async(reset_failures)(operation, to_email)
|
|
return create_standardized_response(
|
|
code=ResponseCode.PASSWORD_RESET_SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
|
|
except Exception as e:
|
|
await sync_to_async(record_failure)(operation, to_email)
|
|
return create_standardized_error_response(
|
|
message=f"服务器内部错误: {str(e)}",
|
|
code=ResponseCode.SERVER_INTERNAL_ERROR,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
|
|
class UserUpdateAPIView(APIView):
|
|
@swagger_auto_schema(
|
|
tags=['用户'],
|
|
operation_summary='获取当前用户信息',
|
|
operation_description='获取当前登录用户的个人信息',
|
|
responses={200: success_response, 401: unauthorized_response},
|
|
)
|
|
async def get(self, request):
|
|
user = request.user
|
|
user_data = await UserSerializer(user).adata
|
|
|
|
return create_standardized_response(
|
|
data={'user': user_data},
|
|
code=ResponseCode.SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['用户'],
|
|
operation_summary='更新用户信息',
|
|
operation_description='更新当前登录用户的个人信息(部分更新)',
|
|
request_body=UserUpdateSerializer,
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response},
|
|
)
|
|
async def put(self, request):
|
|
user = request.user
|
|
serializer = UserUpdateSerializer(
|
|
user,
|
|
data=request.data,
|
|
partial=True,
|
|
context={'request': request}
|
|
)
|
|
|
|
if not await sync_to_async(serializer.is_valid)():
|
|
errors = serializer.errors
|
|
first_error = ''
|
|
for field, msgs in errors.items():
|
|
if isinstance(msgs, list) and msgs:
|
|
first_error = str(msgs[0])
|
|
break
|
|
|
|
return create_standardized_error_response(
|
|
data=errors,
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message=first_error or '参数异常',
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
updated_user = await serializer.asave()
|
|
user_data = await UserSerializer(updated_user).adata
|
|
|
|
try:
|
|
await sync_to_async(log_event)(
|
|
event_type='profile_update',
|
|
user=updated_user,
|
|
description=f'更新了{len(serializer.validated_data)}项个人资料',
|
|
metadata={'updated_fields': list(serializer.validated_data.keys())},
|
|
request=request
|
|
)
|
|
await sync_to_async(track_user_action)(updated_user, 'profile', count=1)
|
|
except Exception as e:
|
|
logging.getLogger(__name__).warning(f'Profile track failed: {e}')
|
|
|
|
return create_standardized_response(
|
|
data={'user': user_data},
|
|
code=ResponseCode.SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['用户'],
|
|
operation_summary='部分更新用户信息',
|
|
operation_description='部分更新当前登录用户的个人信息',
|
|
request_body=UserUpdateSerializer,
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response},
|
|
)
|
|
async def patch(self, request):
|
|
return await self.put(request)
|
|
|
|
|
|
class ChangePasswordAPIView(APIView):
|
|
@swagger_auto_schema(
|
|
tags=['用户'],
|
|
operation_summary='修改密码',
|
|
operation_description='修改当前用户密码,需通过验证码验证,密码需至少8位且包含字母和数字',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
properties={
|
|
'old_password': openapi.Schema(type=openapi.TYPE_STRING, description='旧密码(首次设置可为空)'),
|
|
'new_password': openapi.Schema(type=openapi.TYPE_STRING, description='新密码'),
|
|
'confirm_password': openapi.Schema(type=openapi.TYPE_STRING, description='确认密码'),
|
|
'captcha_key': openapi.Schema(type=openapi.TYPE_STRING, description='验证码key(需要时必填)'),
|
|
'captcha_code': openapi.Schema(type=openapi.TYPE_STRING, description='验证码(需要时必填)'),
|
|
}
|
|
),
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response},
|
|
)
|
|
async def post(self, request):
|
|
from utils.captcha import check_captcha_required, verify_captcha, record_failure, reset_failures
|
|
|
|
identifier = str(request.user.id)
|
|
operation = 'change_pwd'
|
|
captcha_required = await sync_to_async(check_captcha_required)(operation, identifier)
|
|
|
|
if captcha_required:
|
|
captcha_key = request.data.get('captcha_key', None)
|
|
captcha_code = request.data.get('captcha_code', None)
|
|
|
|
if not captcha_key or not captcha_code:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_REQUIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
captcha_result = await sync_to_async(verify_captcha)(captcha_key, captcha_code)
|
|
if captcha_result == 'expired':
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
elif captcha_result == 'wrong':
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_ERROR,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
serializer = ChangePasswordSerializer(
|
|
data=request.data,
|
|
context={'request': request}
|
|
)
|
|
|
|
if not await sync_to_async(serializer.is_valid)():
|
|
errors = serializer.errors
|
|
first_error = ''
|
|
for field, msgs in errors.items():
|
|
if isinstance(msgs, list) and msgs:
|
|
first_error = str(msgs[0])
|
|
break
|
|
elif isinstance(msgs, dict):
|
|
for sub_msgs in msgs.values():
|
|
if isinstance(sub_msgs, list) and sub_msgs:
|
|
first_error = str(sub_msgs[0])
|
|
break
|
|
break
|
|
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
return create_standardized_error_response(
|
|
data=errors,
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message=first_error or '参数异常',
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
user = await serializer.asave()
|
|
user_data = await UserSerializer(user).adata
|
|
|
|
is_new_set = not request.user.has_usable_password() or request.data.get('old_password', '') == ''
|
|
|
|
await sync_to_async(reset_failures)(operation, identifier)
|
|
return create_standardized_response(
|
|
data={
|
|
'user': user_data,
|
|
'is_new_set': is_new_set,
|
|
},
|
|
code=ResponseCode.PASSWORD_SET if is_new_set else ResponseCode.PASSWORD_CHANGED,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
|
|
|
|
class UserLoginAPIView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
def _get_identifier(self, request, account=''):
|
|
"""安全修复:优先取 nginx 覆写设置的 X-Real-IP(客户端伪造的 X-Forwarded-For
|
|
会被我们网关覆盖),并叠加账号维度,防止单一维度被绕过/恶意锁号。"""
|
|
real_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR') or 'unknown'
|
|
if account:
|
|
return f"{real_ip}:{account}"
|
|
return str(real_ip)
|
|
|
|
@swagger_auto_schema(
|
|
tags=['认证'],
|
|
operation_summary='账号密码登录',
|
|
operation_description='使用账号和密码进行登录,失败次数过多需通过滑块验证码验证',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
required=['account', 'password'],
|
|
properties={
|
|
'account': openapi.Schema(type=openapi.TYPE_STRING, description='账号(用户名或邮箱)'),
|
|
'password': openapi.Schema(type=openapi.TYPE_STRING, description='密码'),
|
|
'slider_captcha_key': openapi.Schema(type=openapi.TYPE_STRING, description='滑块验证码key(需要时必填)'),
|
|
'slider_captcha_x': openapi.Schema(type=openapi.TYPE_INTEGER, description='滑块X坐标(需要时必填)'),
|
|
}
|
|
),
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response, 403: error_response},
|
|
)
|
|
async def post(self, request):
|
|
account = request.data.get('account', None)
|
|
password = request.data.get('password', None)
|
|
|
|
if not account or not password:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="账号和密码不能为空",
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# 安全修复:与 LoginView 一致的失败计数 + 滑块验证码门控,
|
|
# 此前该端点无任何防爆破机制,攻击者可绕过 /user/login/ 无限暴力破解
|
|
identifier = self._get_identifier(request, account)
|
|
operation = 'login'
|
|
captcha_required = await sync_to_async(check_captcha_required)(operation, identifier)
|
|
|
|
if captcha_required:
|
|
slider_captcha_key = request.data.get('slider_captcha_key', None)
|
|
slider_captcha_x = request.data.get('slider_captcha_x', None)
|
|
|
|
if not slider_captcha_key or slider_captcha_x is None:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_REQUIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
captcha_valid = await sync_to_async(verify_slider_captcha)(slider_captcha_key, int(slider_captcha_x))
|
|
if not captcha_valid:
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_ERROR,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
except SliderCaptchaError:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
user = await sync_to_async(authenticate)(username=account, password=password)
|
|
|
|
if user is not None:
|
|
if user.is_active:
|
|
await sync_to_async(reset_failures)(operation, identifier)
|
|
refresh = await sync_to_async(RefreshToken.for_user)(user)
|
|
user_data = await UserSerializer(user).adata
|
|
|
|
response_data = {
|
|
'user': user_data,
|
|
'refresh': str(refresh),
|
|
'access': str(refresh.access_token),
|
|
'token_type': 'bearer',
|
|
'expires_at_timestamp': refresh.access_token.payload['exp']
|
|
}
|
|
|
|
await sync_to_async(_create_login_record)(request, user, 'success')
|
|
|
|
return create_standardized_response(
|
|
data=response_data,
|
|
code=ResponseCode.LOGIN_SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
else:
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
await sync_to_async(_create_login_record)(request, user, 'failed')
|
|
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="账号已被禁用",
|
|
status_code=status.HTTP_403_FORBIDDEN
|
|
)
|
|
else:
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
login_user = await FUser.objects.filter(username=account).afirst() or await FUser.objects.filter(email=account).afirst()
|
|
if login_user:
|
|
await sync_to_async(_create_login_record)(request, login_user, 'failed')
|
|
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="账号或密码错误",
|
|
status_code=status.HTTP_401_UNAUTHORIZED
|
|
)
|
|
|
|
except Exception as e:
|
|
return create_standardized_error_response(
|
|
message=f"服务器内部错误: {str(e)}",
|
|
code=ResponseCode.SERVER_INTERNAL_ERROR,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
|
|
class LoginView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
def _get_identifier(self, request):
|
|
"""安全修复:X-Forwarded-For 首段可被客户端任意伪造;改用 nginx 覆写的
|
|
X-Real-IP(网关以 $remote_addr 设置,客户端伪造值会被覆盖),无代理时回退 REMOTE_ADDR。"""
|
|
real_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')
|
|
if real_ip:
|
|
return str(real_ip).strip()
|
|
return request.META.get('REMOTE_ADDR')
|
|
|
|
@swagger_auto_schema(
|
|
tags=['认证'],
|
|
operation_summary='登录(含滑块验证码)',
|
|
operation_description='使用账号和密码登录,失败次数过多需通过滑块验证码验证',
|
|
request_body=openapi.Schema(
|
|
type=openapi.TYPE_OBJECT,
|
|
required=['account', 'password'],
|
|
properties={
|
|
'account': openapi.Schema(type=openapi.TYPE_STRING, description='账号'),
|
|
'password': openapi.Schema(type=openapi.TYPE_STRING, description='密码'),
|
|
'slider_captcha_key': openapi.Schema(type=openapi.TYPE_STRING, description='滑块验证码key(需要时必填)'),
|
|
'slider_captcha_x': openapi.Schema(type=openapi.TYPE_INTEGER, description='滑块X坐标(需要时必填)'),
|
|
}
|
|
),
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response, 403: error_response},
|
|
)
|
|
async def post(self, request):
|
|
account = request.data.get('account', None)
|
|
password = request.data.get('password', None)
|
|
|
|
if not account or not password:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="账号和密码不能为空",
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
identifier = self._get_identifier(request)
|
|
operation = 'login'
|
|
captcha_required = await sync_to_async(check_captcha_required)(operation, identifier)
|
|
|
|
if captcha_required:
|
|
slider_captcha_key = request.data.get('slider_captcha_key', None)
|
|
slider_captcha_x = request.data.get('slider_captcha_x', None)
|
|
|
|
if not slider_captcha_key or slider_captcha_x is None:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_REQUIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
captcha_valid = await sync_to_async(verify_slider_captcha)(slider_captcha_key, int(slider_captcha_x))
|
|
if not captcha_valid:
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_ERROR,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
except SliderCaptchaError:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.CAPTCHA_EXPIRED,
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
user = await sync_to_async(authenticate)(username=account, password=password)
|
|
|
|
if user is not None:
|
|
if user.is_active:
|
|
await sync_to_async(reset_failures)(operation, identifier)
|
|
refresh = await sync_to_async(RefreshToken.for_user)(user)
|
|
user_data = await UserSerializer(user).adata
|
|
|
|
response_data = {
|
|
'user': user_data,
|
|
'refresh': str(refresh),
|
|
'access': str(refresh.access_token),
|
|
'token_type': 'bearer',
|
|
'expires_at_timestamp': refresh.access_token.payload['exp']
|
|
}
|
|
|
|
await sync_to_async(_create_login_record)(request, user, 'success')
|
|
|
|
return create_standardized_response(
|
|
data=response_data,
|
|
code=ResponseCode.LOGIN_SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
else:
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
await sync_to_async(_create_login_record)(request, user, 'failed')
|
|
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="账号已被禁用",
|
|
status_code=status.HTTP_403_FORBIDDEN
|
|
)
|
|
else:
|
|
await sync_to_async(record_failure)(operation, identifier)
|
|
login_user = await FUser.objects.filter(username=account).afirst() or await FUser.objects.filter(email=account).afirst()
|
|
if login_user:
|
|
await sync_to_async(_create_login_record)(request, login_user, 'failed')
|
|
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.PARAMETER_ERROR,
|
|
message="账号或密码错误",
|
|
status_code=status.HTTP_401_UNAUTHORIZED
|
|
)
|
|
|
|
except Exception as e:
|
|
return create_standardized_error_response(
|
|
message=f"服务器内部错误: {str(e)}",
|
|
code=ResponseCode.SERVER_INTERNAL_ERROR,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
|
|
class PublicProfileAPIView(APIView):
|
|
permission_classes = [AllowAny]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['用户'],
|
|
operation_summary='获取公开用户资料',
|
|
operation_description='获取指定用户的公开资料信息,包含文章列表和统计数据',
|
|
manual_parameters=[
|
|
openapi.Parameter('user_id', openapi.IN_PATH, description='用户ID', type=openapi.TYPE_INTEGER),
|
|
],
|
|
responses={200: success_response, 404: not_found_response},
|
|
)
|
|
async def get(self, request, user_id):
|
|
try:
|
|
user = await FUser.objects.aget(id=user_id, is_active=True)
|
|
except FUser.DoesNotExist:
|
|
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
try:
|
|
from article.models import Article
|
|
article_count = await Article.objects.filter(author=user, status='published').acount()
|
|
recent_articles = Article.objects.filter(author=user, status='published').order_by('-created_at')[:5]
|
|
recent_articles = [a async for a in recent_articles]
|
|
articles_data = []
|
|
for article in recent_articles:
|
|
articles_data.append({
|
|
'id': article.id,
|
|
'title': article.title,
|
|
'excerpt': article.excerpt or '',
|
|
'cover_image': article.cover_image.url if article.cover_image else '',
|
|
'views': article.views,
|
|
'likes': article.likes,
|
|
'tags': article.tags or [],
|
|
'created_at': article.created_at.strftime('%Y-%m-%d'),
|
|
})
|
|
except Exception:
|
|
article_count = 0
|
|
articles_data = []
|
|
|
|
# Get analytics data for the last 7 days
|
|
from datetime import date, timedelta
|
|
today = date.today()
|
|
analytics = {
|
|
'reading_trend': [0, 0, 0, 0, 0, 0, 0], # placeholder for 7 days
|
|
'comment_count': 0,
|
|
'like_count': 0,
|
|
'follower_count': 0,
|
|
'trends': {
|
|
'reading_growth': '+0%',
|
|
'like_growth': '+0%',
|
|
'comment_growth': '+0%',
|
|
}
|
|
}
|
|
|
|
is_owner = request.user.is_authenticated and request.user.id == user_id
|
|
|
|
data: dict = {
|
|
'id': user.id,
|
|
'username': user.username,
|
|
'avatar': user.avatar.url if user.avatar else '',
|
|
'bio': user.bio or '',
|
|
'article_count': article_count,
|
|
'recent_articles': articles_data,
|
|
'follower_count': 0,
|
|
'following_count': 0,
|
|
'like_count': 0,
|
|
}
|
|
|
|
if is_owner:
|
|
data.update({
|
|
'points': user.points,
|
|
'coins': user.coins,
|
|
'gender': user.get_gender_display(),
|
|
'location': user.location or '',
|
|
'date_joined': user.date_joined.strftime('%Y-%m-%d'),
|
|
'analytics': analytics,
|
|
})
|
|
else:
|
|
data.update({
|
|
'gender': '',
|
|
'location': '',
|
|
'date_joined': None,
|
|
})
|
|
|
|
return Response(data)
|
|
|
|
|
|
ALLOWED_AVATAR_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
|
ALLOWED_AVATAR_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024
|
|
|
|
|
|
class UploadAvatarAPIView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [MultiPartParser, FormParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['用户'],
|
|
operation_summary='上传头像',
|
|
operation_description='上传用户头像,支持JPG/PNG/GIF/WEBP格式,最大2MB',
|
|
manual_parameters=[
|
|
openapi.Parameter(
|
|
'avatar', openapi.IN_FORM,
|
|
description='头像文件',
|
|
type=openapi.TYPE_FILE,
|
|
required=True,
|
|
),
|
|
],
|
|
responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response},
|
|
)
|
|
async def post(self, request):
|
|
file = request.FILES.get('avatar')
|
|
|
|
if not file:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VALIDATION_ERROR,
|
|
message='请选择头像文件',
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if file.content_type not in ALLOWED_AVATAR_TYPES:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VALIDATION_ERROR,
|
|
message='头像格式仅支持 JPG、PNG、GIF、WEBP',
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
ext = os.path.splitext(file.name)[1].lower()
|
|
if ext not in ALLOWED_AVATAR_EXTENSIONS:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VALIDATION_ERROR,
|
|
message='头像格式仅支持 JPG、PNG、GIF、WEBP',
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if file.size > MAX_AVATAR_SIZE:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VALIDATION_ERROR,
|
|
message='头像大小不能超过 2MB',
|
|
status_code=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
filename = f'avatars/{uuid.uuid4().hex}{ext}'
|
|
saved_path = await sync_to_async(default_storage.save)(filename, file)
|
|
|
|
user = request.user
|
|
if user.avatar and user.avatar.name:
|
|
try:
|
|
await sync_to_async(default_storage.delete)(user.avatar.name)
|
|
except Exception:
|
|
pass
|
|
|
|
user.avatar = saved_path
|
|
await user.asave(update_fields=['avatar'])
|
|
|
|
avatar_url = request.build_absolute_uri(settings.MEDIA_URL + saved_path)
|
|
|
|
return create_standardized_response(
|
|
data={'avatar': avatar_url},
|
|
code=ResponseCode.SUCCESS,
|
|
status_code=status.HTTP_200_OK
|
|
)
|
|
|
|
except Exception as e:
|
|
return create_standardized_error_response(
|
|
message=f"头像上传失败: {str(e)}",
|
|
code=ResponseCode.SERVER_INTERNAL_ERROR,
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
)
|
|
|
|
|
|
class FollowToggleView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
parser_classes = [JSONParser]
|
|
|
|
@swagger_auto_schema(
|
|
tags=['用户'],
|
|
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 post(self, request, user_id):
|
|
try:
|
|
target_user = await FUser.objects.aget(id=user_id, is_active=True)
|
|
except FUser.DoesNotExist:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.NOT_FOUND,
|
|
message='用户不存在',
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
if target_user == request.user:
|
|
return create_standardized_error_response(
|
|
code=ResponseCode.VALIDATION_ERROR,
|
|
message='不能关注自己',
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
follow, created = await Follow.objects.aget_or_create(
|
|
follower=request.user,
|
|
following=target_user,
|
|
)
|
|
|
|
if not created:
|
|
await follow.adelete()
|
|
is_following = False
|
|
else:
|
|
is_following = True
|
|
|
|
follower_count = await target_user.followers.acount()
|
|
following_count = await request.user.following.acount()
|
|
|
|
return create_standardized_response(
|
|
data={
|
|
'is_following': is_following,
|
|
'follower_count': follower_count,
|
|
'following_count': following_count,
|
|
},
|
|
code=ResponseCode.SUCCESS,
|
|
)
|