refactor(reaction): 收敛为单选接口
This commit is contained in:
@@ -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:** 把题目点评从「三维评分 + 文字」重写为「一排七个表情按钮,点击即表态」,并把后台从逐条评论管理改为按题目聚合的反馈统计表。
|
||||||
|
|||||||
@@ -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`(前端)
|
||||||
|
|
||||||
|
|||||||
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:
|
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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
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 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)})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user