refactor(reaction): 收敛为单选接口

This commit is contained in:
2026-08-06 07:53:06 -06:00
parent 776b470457
commit 8d8ab01d8b
8 changed files with 124 additions and 34 deletions

View File

@@ -1,5 +1,7 @@
# 题目点评重写(表情 Reaction实现计划 # 题目点评重写(表情 Reaction实现计划
> 2026-08-06 规则更新:当前实现已改为单选、点击即提交、提交后不可修改,并通过 `(problem, user)` 数据库唯一约束保证一人一题一条。新接口使用单值 `type` / `mine_type`;为支持前后端错序部署,过渡期仍接受单元素 `types` 并返回数组 `mine`。统计直接查询数据库,不再使用 reaction 缓存。本文中的多选及缓存步骤是早期实施记录。
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把题目点评从「三维评分 + 文字」重写为「一排七个表情按钮,点击即表态」,并把后台从逐条评论管理改为按题目聚合的反馈统计表。 **Goal:** 把题目点评从「三维评分 + 文字」重写为「一排七个表情按钮,点击即表态」,并把后台从逐条评论管理改为按题目聚合的反馈统计表。

View File

@@ -1,5 +1,7 @@
# 题目点评重写:从评分表单到表情 Reaction # 题目点评重写:从评分表单到表情 Reaction
> 2026-08-06 规则更新:评价已改为单选,点击一个表情后立即提交,且提交后不可修改。一人一题在数据库中最多保留一条记录。新请求字段为 `type`,新响应字段为 `mine_type: ReactionKey | null`;为支持前后端错序部署,过渡期仍接受单元素 `types` 并返回数组 `mine`。计数直接查询数据库,不使用 Redis 缓存。本文后续关于“最多选 3 个、可取消、整份覆盖、统计缓存”的内容仅保留为早期设计记录,不再代表当前行为。
日期2026-08-06 日期2026-08-06
涉及仓库:`OnlineJudge`(后端)、`ojnext`(前端) 涉及仓库:`OnlineJudge`(后端)、`ojnext`(前端)

View 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"),
),
]

View File

@@ -22,7 +22,9 @@ class Reaction(models.Model):
class Meta: class Meta:
db_table = "reaction" db_table = "reaction"
unique_together = ("problem", "user", "type") constraints = [
models.UniqueConstraint(fields=["problem", "user"], name="reaction_problem_user_unique"),
]
indexes = [ indexes = [
models.Index(fields=["problem", "type"], name="reaction_problem_type_idx"), models.Index(fields=["problem", "type"], name="reaction_problem_type_idx"),
] ]

View File

@@ -1,18 +1,26 @@
from reaction.models import ReactionType from reaction.models import ReactionType
from utils.api import serializers from utils.api import serializers
MAX_REACTIONS = 3
class SetReactionSerializer(serializers.Serializer): class SetReactionSerializer(serializers.Serializer):
problem_id = serializers.IntegerField() problem_id = serializers.IntegerField()
type = serializers.ChoiceField(choices=ReactionType.choices, required=False)
types = serializers.ListField( types = serializers.ListField(
child=serializers.ChoiceField(choices=ReactionType.choices), child=serializers.ChoiceField(choices=ReactionType.choices),
required=False,
allow_empty=False, allow_empty=False,
) )
def validate_types(self, value): def validate(self, attrs):
unique = list(dict.fromkeys(value)) reaction_type = attrs.get("type")
if len(unique) > MAX_REACTIONS: legacy_types = list(dict.fromkeys(attrs.get("types", [])))
raise serializers.ValidationError(f"最多只能选 {MAX_REACTIONS}")
return unique 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
View 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, ())

View File

@@ -7,32 +7,24 @@ from reaction.serializers import SetReactionSerializer
from submission.models import JudgeStatus, Submission from submission.models import JudgeStatus, Submission
from utils.api import AsyncAPIView from utils.api import AsyncAPIView
from utils.api.api import validate_serializer 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] ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
class ReactionAPI(AsyncAPIView): class ReactionAPI(AsyncAPIView):
async def get_counts(self, problem_id): async def get_counts(self, problem_id):
"""返回该题七个表情的计数,带 Redis 缓存""" """直接从数据库返回该题七个表情的计数。"""
cache_key = f"{CacheKey.reaction_stats}:{problem_id}" return await Reaction.objects.filter(problem_id=problem_id).aaggregate(**{t.value: Count("id", filter=Q(type=t.value)) for t in ReactionType})
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
@login_required @login_required
async def get(self, request): async def get(self, request):
problem_id = request.GET.get("problem_id") problem_id = request.GET.get("problem_id")
if not problem_id: if not problem_id:
return self.error("problem_id is required") return self.error("problem_id is required")
mine = [r.type async for r in Reaction.objects.filter(user=request.user, problem_id=problem_id)] mine = await Reaction.objects.filter(user=request.user, problem_id=problem_id).values_list("type", flat=True).afirst()
if not mine: if mine is None:
return self.success({"mine": [], "counts": None}) return self.success({"mine": [], "mine_type": None, "counts": None})
return self.success({"mine": mine, "counts": await self.get_counts(problem_id)}) return self.success({"mine": [mine], "mine_type": mine, "counts": await self.get_counts(problem_id)})
@login_required @login_required
@validate_serializer(SetReactionSerializer) @validate_serializer(SetReactionSerializer)
@@ -51,18 +43,15 @@ class ReactionAPI(AsyncAPIView):
if not solved: if not solved:
return self.error("submission is not exists or not accepted") return self.error("submission is not exists or not accepted")
types = data["types"] reaction_type = data["type"]
user = request.user user = request.user
# 评价一次定终身:已经评过的题不允许再改,避免统计被反复刷 # 数据库唯一约束保证一人一题只有一条;重复请求返回实际保存的评价,
if await Reaction.objects.filter(user=user, problem=problem).aexists(): # 让网络重试和多标签页并发都收敛到同一状态。
return self.error("已经评价过了,不能修改") reaction, _ = await Reaction.objects.aget_or_create(
user=user,
# ignore_conflicts 兜住并发重复提交unique_together 保证不会写重 problem=problem,
await Reaction.objects.abulk_create( defaults={"type": reaction_type},
[Reaction(user=user, problem=problem, type=t) for t in types],
ignore_conflicts=True,
) )
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)})

View File

@@ -17,7 +17,6 @@ class CacheKey:
website_config = "website_config" website_config = "website_config"
problem_authors = "problem_authors" problem_authors = "problem_authors"
problem_tags = "problem_tags" problem_tags = "problem_tags"
reaction_stats = "reaction_stats"
user_activity_rank = "user_activity_rank" user_activity_rank = "user_activity_rank"
problem_yearly_ac = "problem_yearly_ac" problem_yearly_ac = "problem_yearly_ac"