609 lines
22 KiB
Python
609 lines
22 KiB
Python
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.async_decorators import async_never_cache_dispatch
|
|
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__)
|
|
|
|
|
|
@async_never_cache_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}')
|
|
|
|
|
|
@async_never_cache_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
|
|
)
|
|
|
|
|
|
@async_never_cache_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
|
|
)
|