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.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): 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', '') 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 ) 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: 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 = 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: return create_standardized_error_response( code=ResponseCode.VERIFICATION_CODE_ERROR, 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: refresh = 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') return create_standardized_error_response( code=ResponseCode.LOGIN_VERIFICATION_ERROR, 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 ) 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] @swagger_auto_schema( tags=['认证'], operation_summary='账号密码登录', operation_description='使用账号和密码进行登录,返回JWT token', 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='密码'), } ), 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 ) try: user = await sync_to_async(authenticate)(username=account, password=password) if user is not None: if user.is_active: refresh = 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(_create_login_record)(request, user, 'failed') return create_standardized_error_response( code=ResponseCode.PARAMETER_ERROR, message="账号已被禁用", status_code=status.HTTP_403_FORBIDDEN ) else: 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 = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: return x_forwarded_for.split(',')[0].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 = 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, )