Django REST Framework如何通过限流实现用户每周答题次数限制
不要直接套用框架默认的全局throttling规则,你的需求是单用户对单道当周题目独立计算2次作答额度,新旧题额度不共享,发布周几可动态调整,默认限流的固定周期、全局/用户维度计数完全不匹配这个场景,采用「数据库存作答记录+业务层校验」的方案最稳妥,可维护性也最高。
1. 补全核心数据模型
你现有的QuizDetail模型只存了题目本身的信息,缺少用户作答记录的存储,这是计数的核心依据,先新增QuizSubmission模型存储每一次作答行为:
from django.db import models from django.conf import settings # 原有Days枚举保留,修正QuizDetail的两处拼写错误 Days = ( ("Mon", "Monday"), ("Tue", "Tuesday"), ("Wed", "Wednesday"), ("Thu", "Thursday"), ("Fri", "Friday"), ("Sat", "Saturday"), ("Sun", "Sunday") ) class QuizDetail(models.Model): name = models.CharField(max_length=255, blank=False, null=False) # 原max_lenght拼写修正 start_date = models.DateTimeField() end_date = models.DateTimeField() published_week_day = models.CharField(max_length=255, choices=Days) # 原publisehd_week_day拼写修正 # 新增作答记录模型 class QuizSubmission(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="quiz_submissions" ) quiz = models.ForeignKey( QuizDetail, on_delete=models.CASCADE, related_name="submissions" ) submit_time = models.DateTimeField(auto_now_add=True) score = models.IntegerField(null=True, blank=True) # 可扩展存储得分、答案等业务字段 class Meta: # 加索引提升后续计数查询的速度 indexes = [ models.Index(fields=["user", "quiz", "submit_time"]), ]
注意:每道周更题创建时,直接把
start_date设为当周题目上线的具体时间(比如本月周二上线就设为对应周二的10点),end_date设为下一道题上线前的截止时间即可,后续统计直接复用这两个字段,不需要动态根据周几计算时间窗口,哪怕后续调整发布周几,只要新建题目时填对这两个时间,逻辑完全不需要修改。
2. 实现作答权限校验逻辑
核心规则是统计当前用户在当前题目的有效期内的提交次数,因为计数是和具体题目ID绑定的,天然满足「旧题额度耗尽不影响新题作答」的要求:周一答上周的旧题满2次,周二新题上线后统计的是新题ID下的提交数,从0开始计数,完全独立。
把校验逻辑封装成可复用的工具函数:
from django.utils import timezone from .models import QuizSubmission MAX_ATTEMPTS_PER_QUIZ = 2 def validate_quiz_attempt(user, quiz): """ 返回格式:(是否允许作答, 剩余次数, 提示信息) """ now = timezone.now() # 先校验题目是否在作答有效期 if now < quiz.start_date or now > quiz.end_date: return (False, 0, "当前题目不在作答有效期内") # 统计用户对这道题已经提交的有效次数 used_attempts = QuizSubmission.objects.filter( user=user, quiz=quiz, submit_time__gte=quiz.start_date, submit_time__lte=quiz.end_date ).count() remaining = MAX_ATTEMPTS_PER_QUIZ - used_attempts if remaining <= 0: return (False, 0, "你当前这道题的2次作答机会已用完") return (True, remaining, "校验通过")
3. 接口层接入校验
如果用Django REST Framework开发接口,不需要自定义复杂的Throttle类,直接在提交作答的接口前置校验即可:
from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from .models import QuizDetail from .serializers import QuizSubmissionSerializer from .utils import validate_quiz_attempt class QuizSubmitAPI(APIView): def post(self, request, quiz_id): # 登录校验 if not request.user.is_authenticated: return Response({"msg": "请先登录后再作答"}, status=status.HTTP_401_UNAUTHORIZED) # 校验题目是否存在 try: target_quiz = QuizDetail.objects.get(id=quiz_id) except QuizDetail.DoesNotExist: return Response({"msg": "题目不存在"}, status=status.HTTP_404_NOT_FOUND) # 校验作答次数 allow_attempt, remaining, msg = validate_quiz_attempt(request.user, target_quiz) if not allow_attempt: return Response({"msg": msg, "remaining": remaining}, status=status.HTTP_403_FORBIDDEN) # 正常处理提交逻辑 serializer = QuizSubmissionSerializer(data=request.data) if serializer.is_valid(): serializer.save(user=request.user, quiz=target_quiz) return Response({ "msg": "作答提交成功", "remaining": remaining - 1 }, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
可选性能优化
如果接口访问量较高,每次请求都count数据库会带来不必要的压力,可以搭配Redis做计数缓存:
- 缓存key规则:
quiz_attempt_cnt:{user_id}:{quiz_id} - 缓存过期时间:和对应题目的
end_date对齐,到期自动失效 - 校验逻辑优先查缓存,缓存不存在时再查数据库回写,提交成功后同步更新缓存计数即可,注意不要完全依赖缓存做唯一判断,数据库的提交记录始终是计数的可信数据源,避免缓存故障导致限制失效。
为什么不推荐用默认Throttling实现
框架自带的Throttle方案(包括DRF默认的UserRateThrottle、AnonRateThrottle)存在几个核心问题,完全不匹配你的需求:
- 计数维度不支持绑定具体题目ID,只能按用户/IP做全局计数,会出现旧题作答次数占用新题额度的问题
- 限流周期只支持固定的自然时间单位(分钟/小时/天/自然周),没法对齐你动态调整的题目上线周期,也无法支持新旧题额度独立的规则
- 限流计数默认存在缓存中,缓存清空/过期会导致限制失效,对于作答次数这种强业务规则,可靠性不足
内容的提问来源于stack exchange,提问作者Arthur Dayne

