431 lines
17 KiB
Python
431 lines
17 KiB
Python
from rest_framework import status
|
||
from adrf.views import APIView
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from asgiref.sync import sync_to_async
|
||
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 utils.async_decorators import async_never_cache_dispatch
|
||
from utils.response_codes import (
|
||
ResponseCode,
|
||
create_standardized_response,
|
||
create_standardized_error_response
|
||
)
|
||
from ..models import (
|
||
FUser, TaskDefinition, UserTaskProgress,
|
||
UserLevel, LevelThreshold, PointTransaction,
|
||
)
|
||
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__)
|
||
|
||
|
||
def get_period_key(task_type):
|
||
now = timezone.localtime(timezone.now())
|
||
if task_type == 'daily':
|
||
return now.strftime('%Y-%m-%d')
|
||
elif task_type == 'weekly':
|
||
return now.strftime('%Y-W%W')
|
||
return 'permanent'
|
||
|
||
|
||
def get_or_create_progress(user, task, period_key):
|
||
progress, created = UserTaskProgress.objects.get_or_create(
|
||
user=user,
|
||
task=task,
|
||
period_key=period_key,
|
||
defaults={'current_count': 0}
|
||
)
|
||
return progress
|
||
|
||
|
||
def track_user_action(user, action_type, count=1):
|
||
"""追踪用户行为并更新任务进度,返回更新的任务列表"""
|
||
now = timezone.localtime(timezone.now())
|
||
daily_period = now.strftime('%Y-%m-%d')
|
||
weekly_period = now.strftime('%Y-W%W')
|
||
|
||
tasks = TaskDefinition.objects.filter(
|
||
action_type=action_type,
|
||
is_active=True,
|
||
)
|
||
|
||
updated_tasks = []
|
||
for task in tasks:
|
||
if task.task_type == 'daily':
|
||
period_key = daily_period
|
||
elif task.task_type == 'weekly':
|
||
period_key = weekly_period
|
||
else:
|
||
period_key = 'permanent'
|
||
|
||
progress = get_or_create_progress(user, task, period_key)
|
||
|
||
if progress.is_completed and progress.is_claimed:
|
||
continue
|
||
|
||
progress.current_count = min(
|
||
progress.current_count + count,
|
||
task.target_count
|
||
)
|
||
|
||
if progress.current_count >= task.target_count and not progress.is_completed:
|
||
progress.is_completed = True
|
||
progress.completed_at = now
|
||
|
||
progress.save()
|
||
|
||
updated_tasks.append({
|
||
'id': task.id,
|
||
'name': task.name,
|
||
'task_type': task.task_type,
|
||
'current_count': progress.current_count,
|
||
'target_count': task.target_count,
|
||
'is_completed': progress.is_completed,
|
||
'is_claimed': progress.is_claimed,
|
||
})
|
||
|
||
return updated_tasks
|
||
|
||
|
||
def check_level_up(user_level):
|
||
next_threshold = LevelThreshold.objects.filter(
|
||
level__gt=user_level.level
|
||
).order_by('level').first()
|
||
|
||
leveled_up = False
|
||
while next_threshold and user_level.xp >= next_threshold.xp_required:
|
||
user_level.level = next_threshold.level
|
||
leveled_up = True
|
||
next_threshold = LevelThreshold.objects.filter(
|
||
level__gt=user_level.level
|
||
).order_by('level').first()
|
||
|
||
if leveled_up:
|
||
user_level.save(update_fields=['level', 'updated_at'])
|
||
return leveled_up
|
||
|
||
|
||
@async_never_cache_dispatch
|
||
class TaskListAPIView(APIView):
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['任务'],
|
||
operation_summary='获取任务列表',
|
||
operation_description='获取所有活跃任务列表及当前用户的进度和等级信息',
|
||
responses={200: success_response, 401: unauthorized_response, 500: error_response},
|
||
)
|
||
async def get(self, request):
|
||
try:
|
||
user = request.user
|
||
now = timezone.localtime(timezone.now())
|
||
daily_period = now.strftime('%Y-%m-%d')
|
||
weekly_period = now.strftime('%Y-W%W')
|
||
|
||
tasks = TaskDefinition.objects.filter(is_active=True)
|
||
|
||
task_list = []
|
||
async for task in tasks:
|
||
if task.task_type == 'daily':
|
||
period_key = daily_period
|
||
elif task.task_type == 'weekly':
|
||
period_key = weekly_period
|
||
else:
|
||
period_key = 'permanent'
|
||
|
||
progress = await UserTaskProgress.objects.filter(
|
||
user=user, task=task, period_key=period_key
|
||
).afirst()
|
||
|
||
task_list.append({
|
||
'id': task.id,
|
||
'name': task.name,
|
||
'task_type': task.task_type,
|
||
'action_type': task.action_type,
|
||
'description': task.description,
|
||
'target_count': task.target_count,
|
||
'reward_points': task.reward_points,
|
||
'reward_coins': task.reward_coins,
|
||
'reward_xp': task.reward_xp,
|
||
'icon': task.icon,
|
||
'redirect_url': task.redirect_url,
|
||
'current_count': progress.current_count if progress else 0,
|
||
'is_completed': progress.is_completed if progress else False,
|
||
'is_claimed': progress.is_claimed if progress else False,
|
||
})
|
||
|
||
user_level = await UserLevel.objects.filter(user=user).afirst()
|
||
next_threshold = await LevelThreshold.objects.filter(
|
||
level__gt=user_level.level if user_level else 1
|
||
).order_by('level').afirst() if user_level else None
|
||
|
||
cur_threshold = await LevelThreshold.objects.filter(
|
||
level=user_level.level
|
||
).afirst() if user_level else None
|
||
|
||
level_data = {
|
||
'level': user_level.level if user_level else 1,
|
||
'xp': user_level.xp if user_level else 0,
|
||
'title': cur_threshold.title if cur_threshold else '新手',
|
||
'next_level_xp': next_threshold.xp_required if next_threshold else None,
|
||
'next_level_title': next_threshold.title if next_threshold else None,
|
||
}
|
||
|
||
return create_standardized_response(
|
||
data={
|
||
'tasks': task_list,
|
||
'level': level_data,
|
||
},
|
||
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
|
||
)
|
||
|
||
|
||
@async_never_cache_dispatch
|
||
class TaskTrackAPIView(APIView):
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['任务'],
|
||
operation_summary='上报任务进度',
|
||
operation_description='上报用户行为进度,自动匹配并更新相关任务的完成状态',
|
||
request_body=openapi.Schema(
|
||
type=openapi.TYPE_OBJECT,
|
||
required=['action_type'],
|
||
properties={
|
||
'action_type': openapi.Schema(type=openapi.TYPE_STRING, description='行为类型'),
|
||
'count': openapi.Schema(type=openapi.TYPE_INTEGER, description='行为次数,默认1'),
|
||
}
|
||
),
|
||
responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response},
|
||
)
|
||
async def post(self, request):
|
||
action_type = request.data.get('action_type')
|
||
count = int(request.data.get('count', 1))
|
||
|
||
if not action_type:
|
||
return create_standardized_error_response(
|
||
message='缺少 action_type 参数',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
valid_actions = [choice[0] for choice in TaskDefinition.ACTION_TYPE_CHOICES]
|
||
if action_type not in valid_actions:
|
||
return create_standardized_error_response(
|
||
message=f'无效的行为类型: {action_type}',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
try:
|
||
# track_user_action 为同步 helper(user.py 也以 sync_to_async 调用),线程池兜底
|
||
updated_tasks = await sync_to_async(track_user_action)(request.user, action_type, count)
|
||
return create_standardized_response(
|
||
data={'updated_tasks': updated_tasks},
|
||
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
|
||
)
|
||
|
||
|
||
@async_never_cache_dispatch
|
||
class TaskClaimAPIView(APIView):
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['任务'],
|
||
operation_summary='领取任务奖励',
|
||
operation_description='领取已完成任务的奖励,包括积分、y币和经验值',
|
||
manual_parameters=[
|
||
openapi.Parameter('task_id', openapi.IN_PATH, description='任务ID', type=openapi.TYPE_INTEGER),
|
||
],
|
||
responses={200: success_response, 400: error_response, 401: unauthorized_response, 500: error_response},
|
||
)
|
||
async def post(self, request, task_id):
|
||
try:
|
||
user = request.user
|
||
task = await TaskDefinition.objects.filter(id=task_id, is_active=True).afirst()
|
||
|
||
if not task:
|
||
return create_standardized_error_response(
|
||
message='任务不存在',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
now = timezone.localtime(timezone.now())
|
||
if task.task_type == 'daily':
|
||
period_key = now.strftime('%Y-%m-%d')
|
||
elif task.task_type == 'weekly':
|
||
period_key = now.strftime('%Y-W%W')
|
||
else:
|
||
period_key = 'permanent'
|
||
|
||
progress = await UserTaskProgress.objects.filter(
|
||
user=user, task=task, period_key=period_key
|
||
).afirst()
|
||
|
||
if not progress or not progress.is_completed:
|
||
return create_standardized_error_response(
|
||
message='任务尚未完成',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
if progress.is_claimed:
|
||
return create_standardized_error_response(
|
||
message='奖励已领取',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# select_for_update + 事务 + refresh_from_db 整体在同步函数内执行,线程池兜底
|
||
def _claim_reward():
|
||
with transaction.atomic():
|
||
locked_user = FUser.objects.select_for_update().get(pk=user.pk)
|
||
user_level, _ = UserLevel.objects.select_for_update().get_or_create(
|
||
user=locked_user,
|
||
defaults={'xp': 0, 'level': 1}
|
||
)
|
||
|
||
if task.reward_points > 0:
|
||
locked_user.points = F('points') + task.reward_points
|
||
locked_user.save(update_fields=['points'])
|
||
locked_user.refresh_from_db()
|
||
PointTransaction.objects.create(
|
||
user=locked_user,
|
||
transaction_type='earn',
|
||
currency_type='points',
|
||
amount=task.reward_points,
|
||
balance_after=locked_user.points,
|
||
description=f'完成任务: {task.name}',
|
||
)
|
||
|
||
if task.reward_coins > 0:
|
||
locked_user.coins = F('coins') + task.reward_coins
|
||
locked_user.save(update_fields=['coins'])
|
||
locked_user.refresh_from_db()
|
||
PointTransaction.objects.create(
|
||
user=locked_user,
|
||
transaction_type='earn',
|
||
currency_type='coins',
|
||
amount=task.reward_coins,
|
||
balance_after=locked_user.coins,
|
||
description=f'完成任务: {task.name}',
|
||
)
|
||
|
||
if task.reward_xp > 0:
|
||
user_level.xp = F('xp') + task.reward_xp
|
||
user_level.save(update_fields=['xp', 'updated_at'])
|
||
user_level.refresh_from_db()
|
||
check_level_up(user_level)
|
||
|
||
progress.is_claimed = True
|
||
progress.claimed_at = now
|
||
progress.save(update_fields=['is_claimed', 'claimed_at'])
|
||
|
||
user_level.refresh_from_db()
|
||
next_threshold = LevelThreshold.objects.filter(
|
||
level__gt=user_level.level
|
||
).order_by('level').first()
|
||
|
||
return locked_user, user_level, next_threshold
|
||
|
||
locked_user, user_level, next_threshold = await sync_to_async(_claim_reward)()
|
||
|
||
return create_standardized_response(
|
||
data={
|
||
'points': locked_user.points,
|
||
'coins': locked_user.coins,
|
||
'xp': user_level.xp,
|
||
'level': user_level.level,
|
||
'next_level_xp': next_threshold.xp_required if next_threshold else None,
|
||
'reward_points': task.reward_points,
|
||
'reward_coins': task.reward_coins,
|
||
'reward_xp': task.reward_xp,
|
||
},
|
||
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
|
||
)
|
||
|
||
|
||
@async_never_cache_dispatch
|
||
class UserLevelAPIView(APIView):
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['任务'],
|
||
operation_summary='获取用户等级信息',
|
||
operation_description='获取当前用户的等级、经验值、等级称号和升级进度',
|
||
responses={200: success_response, 401: unauthorized_response, 500: error_response},
|
||
)
|
||
async def get(self, request):
|
||
try:
|
||
user = request.user
|
||
user_level, _ = await UserLevel.objects.aget_or_create(
|
||
user=user, defaults={'xp': 0, 'level': 1}
|
||
)
|
||
|
||
current_threshold = await LevelThreshold.objects.filter(
|
||
level=user_level.level
|
||
).afirst()
|
||
next_threshold = await LevelThreshold.objects.filter(
|
||
level__gt=user_level.level
|
||
).order_by('level').afirst()
|
||
|
||
current_xp = current_threshold.xp_required if current_threshold else 0
|
||
next_xp = next_threshold.xp_required if next_threshold else user_level.xp
|
||
progress_pct = 0
|
||
if next_xp > current_xp:
|
||
progress_pct = round((user_level.xp - current_xp) / (next_xp - current_xp) * 100, 1)
|
||
progress_pct = max(0, min(100, progress_pct))
|
||
|
||
return create_standardized_response(
|
||
data={
|
||
'level': user_level.level,
|
||
'xp': user_level.xp,
|
||
'title': current_threshold.title if current_threshold else '新手',
|
||
'next_level_xp': next_threshold.xp_required if next_threshold else None,
|
||
'next_level_title': next_threshold.title if next_threshold else None,
|
||
'progress_pct': progress_pct,
|
||
},
|
||
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
|
||
)
|