diff --git a/docs/superpowers/plans/2026-08-06-problem-reaction.md b/docs/superpowers/plans/2026-08-06-problem-reaction.md index f9550c7..dce6b10 100644 --- a/docs/superpowers/plans/2026-08-06-problem-reaction.md +++ b/docs/superpowers/plans/2026-08-06-problem-reaction.md @@ -1,5 +1,7 @@ # 题目点评重写(表情 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. **Goal:** 把题目点评从「三维评分 + 文字」重写为「一排七个表情按钮,点击即表态」,并把后台从逐条评论管理改为按题目聚合的反馈统计表。 diff --git a/docs/superpowers/specs/2026-08-06-problem-reaction-design.md b/docs/superpowers/specs/2026-08-06-problem-reaction-design.md index d59c52a..64151b5 100644 --- a/docs/superpowers/specs/2026-08-06-problem-reaction-design.md +++ b/docs/superpowers/specs/2026-08-06-problem-reaction-design.md @@ -1,5 +1,7 @@ # 题目点评重写:从评分表单到表情 Reaction +> 2026-08-06 规则更新:评价已改为单选,点击一个表情后立即提交,且提交后不可修改。一人一题在数据库中最多保留一条记录。新请求字段为 `type`,新响应字段为 `mine_type: ReactionKey | null`;为支持前后端错序部署,过渡期仍接受单元素 `types` 并返回数组 `mine`。计数直接查询数据库,不使用 Redis 缓存。本文后续关于“最多选 3 个、可取消、整份覆盖、统计缓存”的内容仅保留为早期设计记录,不再代表当前行为。 + 日期:2026-08-06 涉及仓库:`OnlineJudge`(后端)、`ojnext`(前端) diff --git a/reaction/migrations/0003_keep_one_reaction_per_user.py b/reaction/migrations/0003_keep_one_reaction_per_user.py new file mode 100644 index 0000000..1e13c54 --- /dev/null +++ b/reaction/migrations/0003_keep_one_reaction_per_user.py @@ -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"), + ), + ] diff --git a/reaction/models.py b/reaction/models.py index 1c15a5a..1da7d47 100644 --- a/reaction/models.py +++ b/reaction/models.py @@ -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"), ] diff --git a/reaction/serializers.py b/reaction/serializers.py index 889408c..b8cf5b1 100644 --- a/reaction/serializers.py +++ b/reaction/serializers.py @@ -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 diff --git a/reaction/tests.py b/reaction/tests.py new file mode 100644 index 0000000..6c9498b --- /dev/null +++ b/reaction/tests.py @@ -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, ()) diff --git a/reaction/views/oj.py b/reaction/views/oj.py index 44d2642..85d6bed 100644 --- a/reaction/views/oj.py +++ b/reaction/views/oj.py @@ -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)}) diff --git a/utils/constants.py b/utils/constants.py index 2af341f..9a5e419 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -17,7 +17,6 @@ class CacheKey: website_config = "website_config" problem_authors = "problem_authors" problem_tags = "problem_tags" - reaction_stats = "reaction_stats" user_activity_rank = "user_activity_rank" problem_yearly_ac = "problem_yearly_ac"