fix: 修掉重写期间在旧后端发现的 6 个问题

这些是做 Bun 重写时逐行对照发现的,旧后端现在还在线上跑,学生今天就在踩。
每条都在本地起 Django + postgres 实跑验证过,**正反两面都验**:越权要挡住、
正常操作要还能用。合计 14 条断言全过。

## 越权(跨教师)

**ACMContestHelper.put** — 只按 pk 取 rank,任何老师都能改**别人比赛**里的
检查标记。而 `contest_id` 明明在 ACMContesHelperSerializer 里、客户端一直在传,
视图完全没用它。改成按 (pk, contest_id) 取 + ensure_created_by。
同类的 get 本来就有 ensure_created_by,是 put 漏了。

**AddContestProblemAPI.post** — 一个权限装饰器都没有,导致两件事:
  - 少 problem_permission_required:problem_permission=None 的学生管理员也能建题
  - 少 ensure_created_by:任何管理员都能往**别人的比赛**里塞题
另外源题只按 id 取,能把别人比赛里的赛题(或别人未公开的草稿)拷进自己的比赛,
连 answers 参考答案一起拿到。改成源题必须是公开题库题,不可见的要求归属。
(中间件 AdminRoleRequiredMiddleware 只保证"是管理员身份",挡不住这些。)

## 会 500

**ProblemVisibleAPI.put** — `self.error(...)` 漏写 return,题不存在时继续执行到
未赋值的 problem,抛 UnboundLocalError → 500。

## 数据正确性

**ProblemSetAdminAPI.get** — 后台列表写死 filter(visible=True),而列表里就有
可见性开关:一旦关掉,题单从后台消失,教师再也找不到、也无法在界面上打开回来。
后台列表不该过滤 visible,前台该。

**增删题单题目不重算进度** — 题目数是进度的分母。不重算的话学生进度百分比全是
虚高的,原本标"已完成"的人会一直是已完成。更麻烦的是那个手动同步接口
(ProblemSetSyncAPI)前端没有调用点,等于**界面上根本没办法修**。
改成增删之后直接 sync_all_progress_for_problemset。

**移除用户留下孤儿奖章** — 只删了 ProblemSetProgress,UserBadge 还在。学生个人页
会一直挂着一个自己已不在的题单的奖章,而且重新加入时因唯一约束拿不到第二次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 06:50:47 -06:00
parent 5586c9b573
commit 4ebcecfab4
3 changed files with 34 additions and 4 deletions

View File

@@ -201,10 +201,14 @@ class ACMContestHelper(APIView):
@validate_serializer(ACMContesHelperSerializer)
def put(self, request):
data = request.data
# 原来只按 pk 取contest_id 明明在序列化器里、客户端一直在传,却完全没用上。
# 于是任何老师都能改别人比赛里的检查标记 —— teacher_admin_required 只保证
# "是老师"。同类的 get 是有 ensure_created_by 的,这里补齐。
try:
rank = ACMContestRank.objects.get(pk=data["rank_id"])
rank = ACMContestRank.objects.get(pk=data["rank_id"], contest_id=data["contest_id"])
except ACMContestRank.DoesNotExist:
return self.error("Rank id does not exist")
ensure_created_by(rank.contest, request.user)
problem_rank_status = rank.submission_info.get(data["problem_id"])
if not problem_rank_status:
return self.error("Problem id does not exist")

View File

@@ -484,15 +484,26 @@ class MakeContestProblemPublicAPIView(APIView):
class AddContestProblemAPI(APIView):
@problem_permission_required
@validate_serializer(AddContestProblemSerializer)
def post(self, request):
data = request.data
try:
contest = Contest.objects.get(id=data["contest_id"])
problem = Problem.objects.get(id=data["problem_id"])
# 源题必须是**公开题库**里的题。原来只按 id 取,于是能把别人比赛里的赛题
# (或别人尚未公开的草稿)拷进自己的比赛,连带 answers 参考答案一起拿到。
problem = Problem.objects.get(id=data["problem_id"], contest__isnull=True)
except (Contest.DoesNotExist, Problem.DoesNotExist):
return self.error("Contest or Problem does not exist")
# 原来这里一个权限装饰器都没有:
# - 少了 problem_permission_requiredproblem_permission=None 的学生管理员也能建题
# - 少了 ensure_created_by任何管理员都能往别人的比赛里塞题
# 中间件只保证了"是管理员身份",挡不住这两件事。
ensure_created_by(contest, request.user)
if not problem.visible:
ensure_created_by(problem, request.user)
if contest.status == ContestStatus.CONTEST_ENDED:
return self.error("Contest has ended")
if Problem.objects.filter(contest=contest, _id=data["display_id"]).exists():
@@ -604,7 +615,9 @@ class ProblemVisibleAPI(APIView):
try:
problem = Problem.objects.get(id=data["id"])
except Problem.DoesNotExist:
self.error("problem does not exists")
# 这里原来漏了 return会继续往下走到未赋值的 problem
# 抛 UnboundLocalError → 500。题不存在时应该是一条正常的错误响应。
return self.error("problem does not exists")
problem.visible = not problem.visible
problem.save()
return self.success()

View File

@@ -7,6 +7,7 @@ from problemset.models import (
ProblemSetBadge,
ProblemSetProblem,
ProblemSetProgress,
UserBadge,
)
from problemset.serializers import (
AddProblemToSetSerializer,
@@ -32,7 +33,10 @@ class ProblemSetAdminAPI(APIView):
@teacher_admin_required
def get(self, request):
"""获取题单列表(管理员)"""
problem_sets = ProblemSet.objects.filter(visible=True).annotate(problems_count=Count("problemsetproblem", distinct=True)).order_by("-create_time")
# 后台列表**不能**过滤 visible题单列表里有可见性开关一旦关掉
# 题单就从这个列表里消失,教师再也找不到它、也就无法在界面上打开回来。
# 前台列表views/oj.py该过滤后台不该。
problem_sets = ProblemSet.objects.annotate(problems_count=Count("problemsetproblem", distinct=True)).order_by("-create_time")
if not request.user.is_super_admin():
problem_sets = problem_sets.filter(created_by=request.user)
@@ -162,6 +166,10 @@ class ProblemSetProblemAdminAPI(APIView):
score=data.get("score", 0),
hint=data.get("hint", ""),
)
# 题目数就是进度的分母,加完必须重算:不重算的话已加入的学生进度百分比
# 全是虚高的,而且原本标成"已完成"的人会一直是已完成。
# 手动同步接口ProblemSetSyncAPI前端没有调用点所以只能在这里做。
ProblemSetProgress.sync_all_progress_for_problemset(problem_set)
return self.success("题目已添加到题单")
@@ -207,6 +215,8 @@ class ProblemSetProblemAdminAPI(APIView):
try:
problem_set_problem = ProblemSetProblem.objects.get(id=problem_set_problem_id, problemset=problem_set)
problem_set_problem.delete()
# 同上:分母变了,进度要重算
ProblemSetProgress.sync_all_progress_for_problemset(problem_set)
return self.success("题目已从题单中移除")
except ProblemSetProblem.DoesNotExist:
return self.error("题目不在该题单中")
@@ -329,6 +339,9 @@ class ProblemSetProgressAdminAPI(APIView):
try:
progress = ProblemSetProgress.objects.get(problemset=problem_set, user_id=user_id)
progress.delete()
# 奖章是靠这个题单挣的,人移出去了奖章却留着 —— 学生个人页上会一直挂着
# 一个自己已经不在的题单的奖章,而且重新加入时会因唯一约束拿不到第二次。
UserBadge.objects.filter(user_id=user_id, badge__problemset=problem_set).delete()
return self.success("用户已从题单中移除")
except ProblemSetProgress.DoesNotExist:
return self.error("用户未加入该题单")