from rest_framework import status from adrf.views import APIView from rest_framework.permissions import IsAuthenticated, IsAdminUser from django.db import transaction from django.db.models import F from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from datetime import timedelta import logging from asgiref.sync import sync_to_async from utils.response_codes import ( ResponseCode, create_standardized_response, create_standardized_error_response ) from ..models import FUser, PointTransaction, DailyCheckin, TaskDefinition, UserTaskProgress from ..serializers.user_serializers import UserSerializer, PointTransactionSerializer 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 logger = logging.getLogger(__name__) @method_decorator(never_cache, name='dispatch') class WalletBalanceAPIView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=['钱包'], operation_summary='获取钱包余额', operation_description='获取当前用户的积分和y币余额', responses={200: success_response, 401: unauthorized_response}, ) async def get(self, request): user = request.user return create_standardized_response( data={ 'points': user.points, 'coins': user.coins, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) class WalletTransactionsAPIView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=['钱包'], operation_summary='获取交易记录', operation_description='获取当前用户的积分/y币交易记录,支持按类型筛选和分页', manual_parameters=[ openapi.Parameter('currency_type', openapi.IN_QUERY, description='货币类型(points/coins)', type=openapi.TYPE_STRING), 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}, ) async def get(self, request): user = request.user currency_type = request.query_params.get('currency_type', None) page = int(request.query_params.get('page', 1)) page_size = int(request.query_params.get('page_size', 20)) transactions = PointTransaction.objects.filter(user=user) if currency_type: transactions = transactions.filter(currency_type=currency_type) total = await transactions.acount() start = (page - 1) * page_size end = start + page_size rows = [t async for t in transactions[start:end]] serializer = PointTransactionSerializer(rows, many=True) return create_standardized_response( data={ 'transactions': await serializer.adata, 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) class EarnPointsAPIView(APIView): permission_classes = [IsAuthenticated, IsAdminUser] @swagger_auto_schema( tags=['钱包'], operation_summary='发放积分(管理员)', operation_description='管理员向指定用户发放积分', request_body=openapi.Schema( type=openapi.TYPE_OBJECT, required=['amount'], properties={ 'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='目标用户ID(为空则为当前用户)'), 'amount': openapi.Schema(type=openapi.TYPE_INTEGER, description='积分数量'), 'description': openapi.Schema(type=openapi.TYPE_STRING, description='描述'), } ), responses={200: success_response, 400: error_response, 401: unauthorized_response}, ) async def post(self, request): user_id = request.data.get('user_id') amount = request.data.get('amount', 0) description = request.data.get('description', '') try: amount = int(amount) except (ValueError, TypeError): return create_standardized_error_response( message='无效的积分数量', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) if amount <= 0: return create_standardized_error_response( message='积分数量必须大于0', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) try: target_user = await FUser.objects.aget(pk=user_id) if user_id else request.user except FUser.DoesNotExist: return create_standardized_error_response( message='目标用户不存在', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) def _earn_points_txn(): with transaction.atomic(): target_user.points = F('points') + amount target_user.save(update_fields=['points']) target_user.refresh_from_db() PointTransaction.objects.create( user=target_user, transaction_type='earn', currency_type='points', amount=amount, balance_after=target_user.points, description=description or '获得积分', ) try: await sync_to_async(_earn_points_txn)() return create_standardized_response( data={ 'points': target_user.points, 'coins': target_user.coins, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) except Exception as e: logger.error(f'积分发放失败: {e}') return create_standardized_error_response( message='操作失败,请稍后再试', code=ResponseCode.SERVER_INTERNAL_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) CHECKIN_POINTS = 30 FULL_WEEK_BONUS = 100 async def track_checkin_task(user): """ 签到成功后,自动更新签到任务的进度(异步版) """ try: now = timezone.localtime(timezone.now()) daily_period = now.strftime('%Y-%m-%d') # 查找 action_type="checkin" 的活跃任务 checkin_tasks = TaskDefinition.objects.filter( action_type='checkin', is_active=True, ) async for task in checkin_tasks: period_key = daily_period if task.task_type == 'daily' else ( now.strftime('%Y-W%W') if task.task_type == 'weekly' else 'permanent' ) progress, created = await UserTaskProgress.objects.aget_or_create( user=user, task=task, period_key=period_key, defaults={'current_count': 0} ) if progress.is_completed and progress.is_claimed: continue progress.current_count = min( progress.current_count + 1, task.target_count ) if progress.current_count >= task.target_count and not progress.is_completed: progress.is_completed = True progress.completed_at = now await progress.asave() except Exception as e: logger.error(f'签到任务进度更新失败: {e}') @method_decorator(never_cache, name='dispatch') class CheckinStatusAPIView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=['签到'], operation_summary='获取签到状态', operation_description='获取当前用户的签到状态,包括本周签到记录和积分信息', responses={200: success_response, 401: unauthorized_response}, ) async def get(self, request): user = request.user today = timezone.localdate() day_of_week = today.weekday() monday = today - timedelta(days=day_of_week) week_dates = [monday + timedelta(days=i) for i in range(7)] week_checkins = DailyCheckin.objects.filter( user=user, checkin_date__gte=monday, checkin_date__lte=week_dates[6], ).values_list('checkin_date', flat=True) checked_dates = set([d async for d in week_checkins]) signed_today = today in checked_dates week_days = [] for i, d in enumerate(week_dates): week_days.append({ 'date': d.isoformat(), 'checked': d in checked_dates, 'is_today': d == today, 'is_future': d > today, }) week_signed_count = len(checked_dates) full_week = week_signed_count >= 7 return create_standardized_response( data={ 'signed_today': signed_today, 'week_days': week_days, 'week_signed_count': week_signed_count, 'full_week': full_week, 'checkin_points': CHECKIN_POINTS, 'full_week_bonus': FULL_WEEK_BONUS, 'points': user.points, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) @method_decorator(never_cache, name='dispatch') class CheckinAPIView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=['签到'], operation_summary='每日签到', operation_description='执行每日签到,获得积分奖励,连续签到满一周可获得额外奖励', responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response}, ) async def post(self, request): user = request.user today = timezone.localdate() if await DailyCheckin.objects.filter(user=user, checkin_date=today).aexists(): return create_standardized_error_response( message='今日已签到,请勿重复签到', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) def _checkin_txn(): with transaction.atomic(): DailyCheckin.objects.create(user=user, checkin_date=today) day_of_week = today.weekday() monday = today - timedelta(days=day_of_week) week_checkin_count = DailyCheckin.objects.filter( user=user, checkin_date__gte=monday, checkin_date__lte=monday + timedelta(days=6), ).count() bonus = FULL_WEEK_BONUS if week_checkin_count >= 7 else 0 total_points = CHECKIN_POINTS + bonus user.points = F('points') + total_points user.save(update_fields=['points']) user.refresh_from_db() PointTransaction.objects.create( user=user, transaction_type='earn', currency_type='points', amount=total_points, balance_after=user.points, description=f'每日签到' + (f'(含满周奖励{FULL_WEEK_BONUS}积分)' if bonus else ''), ) return week_checkin_count, bonus, total_points try: week_checkin_count, bonus, total_points = await sync_to_async(_checkin_txn)() # 更新签到任务进度 await track_checkin_task(user) return create_standardized_response( data={ 'points': user.points, 'earned': total_points, 'bonus': bonus, 'week_signed_count': week_checkin_count, 'full_week': week_checkin_count >= 7, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) except Exception as e: logger.error(f'签到失败: {e}') return create_standardized_error_response( message='签到失败,请稍后再试', code=ResponseCode.SERVER_INTERNAL_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) class EarnCoinsAPIView(APIView): permission_classes = [IsAuthenticated, IsAdminUser] @swagger_auto_schema( tags=['钱包'], operation_summary='发放y币(管理员)', operation_description='管理员向指定用户发放y币', request_body=openapi.Schema( type=openapi.TYPE_OBJECT, required=['amount'], properties={ 'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='目标用户ID(为空则为当前用户)'), 'amount': openapi.Schema(type=openapi.TYPE_INTEGER, description='y币数量'), 'description': openapi.Schema(type=openapi.TYPE_STRING, description='描述'), } ), responses={200: success_response, 400: error_response, 401: unauthorized_response}, ) async def post(self, request): user_id = request.data.get('user_id') amount = request.data.get('amount', 0) description = request.data.get('description', '') try: amount = int(amount) except (ValueError, TypeError): return create_standardized_error_response( message='无效的y币数量', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) if amount <= 0: return create_standardized_error_response( message='y币数量必须大于0', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) try: target_user = await FUser.objects.aget(pk=user_id) if user_id else request.user except FUser.DoesNotExist: return create_standardized_error_response( message='目标用户不存在', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) def _earn_coins_txn(): with transaction.atomic(): target_user.coins = F('coins') + amount target_user.save(update_fields=['coins']) target_user.refresh_from_db() PointTransaction.objects.create( user=target_user, transaction_type='earn', currency_type='coins', amount=amount, balance_after=target_user.coins, description=description or '获得y币', ) try: await sync_to_async(_earn_coins_txn)() return create_standardized_response( data={ 'points': target_user.points, 'coins': target_user.coins, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) except Exception as e: logger.error(f'y币发放失败: {e}') return create_standardized_error_response( message='操作失败,请稍后再试', code=ResponseCode.SERVER_INTERNAL_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) class SpendPointsAPIView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=['钱包'], operation_summary='消费积分', operation_description='消费当前用户的积分', request_body=openapi.Schema( type=openapi.TYPE_OBJECT, required=['amount'], properties={ 'amount': openapi.Schema(type=openapi.TYPE_INTEGER, description='消费积分数量'), 'description': openapi.Schema(type=openapi.TYPE_STRING, description='消费描述'), } ), responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response}, ) async def post(self, request): amount = request.data.get('amount', 0) description = request.data.get('description', '') try: amount = int(amount) except (ValueError, TypeError): return create_standardized_error_response( message='无效的积分数量', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) if amount <= 0: return create_standardized_error_response( message='积分数量必须大于0', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) def _spend_points_txn(): with transaction.atomic(): user = FUser.objects.select_for_update().get(pk=request.user.pk) if user.points < amount: return None user.points = F('points') - amount user.save(update_fields=['points']) user.refresh_from_db() PointTransaction.objects.create( user=user, transaction_type='spend', currency_type='points', amount=amount, balance_after=user.points, description=description or '消费积分', ) return user try: user = await sync_to_async(_spend_points_txn)() if user is None: return create_standardized_error_response( message='积分余额不足', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) return create_standardized_response( data={ 'points': user.points, 'coins': user.coins, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) except Exception as e: logger.error(f'积分消费失败: {e}') return create_standardized_error_response( message='操作失败,请稍后再试', code=ResponseCode.SERVER_INTERNAL_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) class SpendCoinsAPIView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( tags=['钱包'], operation_summary='消费y币', operation_description='消费当前用户的y币', request_body=openapi.Schema( type=openapi.TYPE_OBJECT, required=['amount'], properties={ 'amount': openapi.Schema(type=openapi.TYPE_INTEGER, description='消费y币数量'), 'description': openapi.Schema(type=openapi.TYPE_STRING, description='消费描述'), } ), responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response}, ) async def post(self, request): amount = request.data.get('amount', 0) description = request.data.get('description', '') try: amount = int(amount) except (ValueError, TypeError): return create_standardized_error_response( message='无效的y币数量', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) if amount <= 0: return create_standardized_error_response( message='y币数量必须大于0', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) def _spend_coins_txn(): with transaction.atomic(): user = FUser.objects.select_for_update().get(pk=request.user.pk) if user.coins < amount: return None user.coins = F('coins') - amount user.save(update_fields=['coins']) user.refresh_from_db() PointTransaction.objects.create( user=user, transaction_type='spend', currency_type='coins', amount=amount, balance_after=user.coins, description=description or '消费y币', ) return user try: user = await sync_to_async(_spend_coins_txn)() if user is None: return create_standardized_error_response( message='y币余额不足', code=ResponseCode.PARAMETER_ERROR, status_code=status.HTTP_400_BAD_REQUEST ) return create_standardized_response( data={ 'points': user.points, 'coins': user.coins, }, code=ResponseCode.SUCCESS, status_code=status.HTTP_200_OK ) except Exception as e: logger.error(f'y币消费失败: {e}') return create_standardized_error_response( message='操作失败,请稍后再试', code=ResponseCode.SERVER_INTERNAL_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR )