refactor(reaction): 收敛为单选接口
This commit is contained in:
32
reaction/migrations/0003_keep_one_reaction_per_user.py
Normal file
32
reaction/migrations/0003_keep_one_reaction_per_user.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Min, Subquery
|
||||
|
||||
|
||||
def keep_earliest(apps, schema_editor):
|
||||
"""一人一题只保留最早的一条表情,多余的删掉。
|
||||
|
||||
以前上限是 3 个,改成单选后这些老数据仍在给全站计数贡献数字。
|
||||
同一次提交的 create_time 相同,所以按自增 id 认「最早」。
|
||||
"""
|
||||
Reaction = apps.get_model("reaction", "Reaction")
|
||||
keep_ids = Reaction.objects.values("user_id", "problem_id").annotate(keep=Min("id")).values("keep")
|
||||
Reaction.objects.exclude(id__in=Subquery(keep_ids)).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("reaction", "0002_delete_comment"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 删掉的数据回不来,回滚只当无事发生
|
||||
migrations.RunPython(keep_earliest, migrations.RunPython.noop),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="reaction",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="reaction",
|
||||
constraint=models.UniqueConstraint(fields=("problem", "user"), name="reaction_problem_user_unique"),
|
||||
),
|
||||
]
|
||||
@@ -22,7 +22,9 @@ class Reaction(models.Model):
|
||||
|
||||
class Meta:
|
||||
db_table = "reaction"
|
||||
unique_together = ("problem", "user", "type")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["problem", "user"], name="reaction_problem_user_unique"),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["problem", "type"], name="reaction_problem_type_idx"),
|
||||
]
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
from reaction.models import ReactionType
|
||||
from utils.api import serializers
|
||||
|
||||
MAX_REACTIONS = 3
|
||||
|
||||
|
||||
class SetReactionSerializer(serializers.Serializer):
|
||||
problem_id = serializers.IntegerField()
|
||||
type = serializers.ChoiceField(choices=ReactionType.choices, required=False)
|
||||
types = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=ReactionType.choices),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
)
|
||||
|
||||
def validate_types(self, value):
|
||||
unique = list(dict.fromkeys(value))
|
||||
if len(unique) > MAX_REACTIONS:
|
||||
raise serializers.ValidationError(f"最多只能选 {MAX_REACTIONS} 个")
|
||||
return unique
|
||||
def validate(self, attrs):
|
||||
reaction_type = attrs.get("type")
|
||||
legacy_types = list(dict.fromkeys(attrs.get("types", [])))
|
||||
|
||||
if len(legacy_types) > 1:
|
||||
raise serializers.ValidationError({"types": "只能选择一个评价"})
|
||||
if reaction_type is None and not legacy_types:
|
||||
raise serializers.ValidationError({"type": "This field is required."})
|
||||
if reaction_type is not None and legacy_types and reaction_type != legacy_types[0]:
|
||||
raise serializers.ValidationError({"types": "新旧评价字段不一致"})
|
||||
|
||||
attrs["type"] = reaction_type or legacy_types[0]
|
||||
return attrs
|
||||
|
||||
56
reaction/tests.py
Normal file
56
reaction/tests.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from reaction.models import Reaction
|
||||
from reaction.serializers import SetReactionSerializer
|
||||
|
||||
|
||||
class SetReactionSerializerTests(TestCase):
|
||||
def test_accepts_one_reaction(self):
|
||||
serializer = SetReactionSerializer(data={"problem_id": 1, "type": "learned"})
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(serializer.validated_data["type"], "learned")
|
||||
|
||||
def test_accepts_legacy_single_reaction(self):
|
||||
serializer = SetReactionSerializer(data={"problem_id": 1, "types": ["learned"]})
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(serializer.validated_data["type"], "learned")
|
||||
|
||||
def test_accepts_matching_transition_fields(self):
|
||||
serializer = SetReactionSerializer(data={"problem_id": 1, "type": "learned", "types": ["learned"]})
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
|
||||
def test_rejects_multiple_legacy_reactions(self):
|
||||
serializer = SetReactionSerializer(data={"problem_id": 1, "types": ["learned", "interesting"]})
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("types", serializer.errors)
|
||||
|
||||
def test_rejects_mismatched_transition_fields(self):
|
||||
serializer = SetReactionSerializer(data={"problem_id": 1, "type": "learned", "types": ["interesting"]})
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("types", serializer.errors)
|
||||
|
||||
def test_rejects_missing_reaction(self):
|
||||
serializer = SetReactionSerializer(data={"problem_id": 1})
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("type", serializer.errors)
|
||||
|
||||
def test_rejects_unknown_reaction(self):
|
||||
serializer = SetReactionSerializer(data={"problem_id": 1, "type": "unknown"})
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("type", serializer.errors)
|
||||
|
||||
|
||||
class ReactionModelConstraintTests(TestCase):
|
||||
def test_one_reaction_per_problem_and_user(self):
|
||||
constraints = {constraint.name: constraint for constraint in Reaction._meta.constraints}
|
||||
|
||||
constraint = constraints["reaction_problem_user_unique"]
|
||||
self.assertEqual(tuple(constraint.fields), ("problem", "user"))
|
||||
self.assertEqual(Reaction._meta.unique_together, ())
|
||||
@@ -7,32 +7,24 @@ from reaction.serializers import SetReactionSerializer
|
||||
from submission.models import JudgeStatus, Submission
|
||||
from utils.api import AsyncAPIView
|
||||
from utils.api.api import validate_serializer
|
||||
from utils.async_helpers import async_cache_delete, async_cache_get, async_cache_set
|
||||
from utils.constants import CacheKey
|
||||
|
||||
ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
|
||||
class ReactionAPI(AsyncAPIView):
|
||||
async def get_counts(self, problem_id):
|
||||
"""返回该题七个表情的计数,带 Redis 缓存。"""
|
||||
cache_key = f"{CacheKey.reaction_stats}:{problem_id}"
|
||||
cached = await async_cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
counts = await Reaction.objects.filter(problem_id=problem_id).aaggregate(**{t.value: Count("id", filter=Q(type=t.value)) for t in ReactionType})
|
||||
await async_cache_set(cache_key, counts, 3600)
|
||||
return counts
|
||||
"""直接从数据库返回该题七个表情的计数。"""
|
||||
return await Reaction.objects.filter(problem_id=problem_id).aaggregate(**{t.value: Count("id", filter=Q(type=t.value)) for t in ReactionType})
|
||||
|
||||
@login_required
|
||||
async def get(self, request):
|
||||
problem_id = request.GET.get("problem_id")
|
||||
if not problem_id:
|
||||
return self.error("problem_id is required")
|
||||
mine = [r.type async for r in Reaction.objects.filter(user=request.user, problem_id=problem_id)]
|
||||
if not mine:
|
||||
return self.success({"mine": [], "counts": None})
|
||||
return self.success({"mine": mine, "counts": await self.get_counts(problem_id)})
|
||||
mine = await Reaction.objects.filter(user=request.user, problem_id=problem_id).values_list("type", flat=True).afirst()
|
||||
if mine is None:
|
||||
return self.success({"mine": [], "mine_type": None, "counts": None})
|
||||
return self.success({"mine": [mine], "mine_type": mine, "counts": await self.get_counts(problem_id)})
|
||||
|
||||
@login_required
|
||||
@validate_serializer(SetReactionSerializer)
|
||||
@@ -51,18 +43,15 @@ class ReactionAPI(AsyncAPIView):
|
||||
if not solved:
|
||||
return self.error("submission is not exists or not accepted")
|
||||
|
||||
types = data["types"]
|
||||
reaction_type = data["type"]
|
||||
user = request.user
|
||||
|
||||
# 评价一次定终身:已经评过的题不允许再改,避免统计被反复刷
|
||||
if await Reaction.objects.filter(user=user, problem=problem).aexists():
|
||||
return self.error("已经评价过了,不能修改")
|
||||
|
||||
# ignore_conflicts 兜住并发重复提交,unique_together 保证不会写重
|
||||
await Reaction.objects.abulk_create(
|
||||
[Reaction(user=user, problem=problem, type=t) for t in types],
|
||||
ignore_conflicts=True,
|
||||
# 数据库唯一约束保证一人一题只有一条;重复请求返回实际保存的评价,
|
||||
# 让网络重试和多标签页并发都收敛到同一状态。
|
||||
reaction, _ = await Reaction.objects.aget_or_create(
|
||||
user=user,
|
||||
problem=problem,
|
||||
defaults={"type": reaction_type},
|
||||
)
|
||||
await async_cache_delete(f"{CacheKey.reaction_stats}:{problem.id}")
|
||||
|
||||
return self.success({"mine": types, "counts": await self.get_counts(problem.id)})
|
||||
return self.success({"mine": [reaction.type], "mine_type": reaction.type, "counts": await self.get_counts(problem.id)})
|
||||
|
||||
Reference in New Issue
Block a user