第二轮 stat.save() 在锁外整体写回解锁前的快照, 并发判题时会覆盖掉另一条链路刚提交的指标增量。 改为重新取锁、重读、只合并元指标的 key。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 KiB
成就系统实施计划
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: 给 OJ 加一套 PlayStation 奖杯风格的全站成就系统:代码注册指标、后台配置条件、判题后异步判定、前端奖杯馆展示。
Architecture: 后端新建 Django app achievement/,三张表(成就定义 / 用户指标快照 / 解锁记录)。指标由 metrics.py 的注册表提供,管理员在后台把「指标 + 比较符 + 阈值」组合成成就。判题完成后投递 dramatiq 任务增量更新指标并判定解锁。通知走推拉结合:解锁记录带 notified 标志,前端在布局层拉取待弹列表,WebSocket 作为在线时的即时增强。前端新增奖杯馆页面与全局奖杯弹窗。
Tech Stack: Django 6 + DRF + PostgreSQL(JSONB) + dramatiq + channels / Vue 3 + TypeScript + Naive UI + Pinia
Spec: docs/superpowers/specs/2026-08-03-achievement-system-design.md
Global Constraints
- 不写测试。 项目
CLAUDE.md明确规定 "Do not write tests"。本计划因此不含 TDD 循环,每个任务以可执行的验证命令(Django shell / ruff / curl / 浏览器操作)替代测试步骤。执行者不得自行添加测试文件。 - 后端仓库为
OnlineJudge/,前端仓库为ojnext/,两者是独立的 git 仓库,根目录OJ/不是仓库。跨仓库的任务分别提交。 - 后端当前分支
yuetsh。 - 后端 lint:
ruff check .与ruff format .(E/F/I 规则,行宽 180,双引号)。每次提交前必须通过。 - 前端格式化:
npm fmt(Prettier)。 - 所有基于提交的指标只统计
contest_id IS NULL的提交,比赛题不计入成就。唯一例外是contest_joined。 - 判断提交是否算通过一律用
submission.models.is_accepted(),即result in (ACCEPTED, AST_CHECK_FAILED),与项目其余所有 AC 统计口径一致。ORM 过滤用result__in=(JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED)。(Task 2 执行期间由 reviewer 发现计划原文写成了result == ACCEPTED,经人工裁决改正。) - 成就为纯荣誉,不发放任何可消费奖励,不接入积分/道具/权限体系。
- 判定任务内所有异常必须捕获并记日志,绝不允许影响判题结果。
- 指标未产生过有效值时,其 key 不存在于
metrics字典中(而非置 0)。判定时遇到缺失 key 直接跳过该成就。 - 前端自动导入已配置:Vue API、Vue Router、Pinia、VueUse、Naive UI 组件与 composable 无需手动 import。
- 后端视图统一继承
utils.api.APIView(非 DRF 的 APIView),用self.success(data)/self.error(msg)返回。
File Structure
后端(OnlineJudge/)
新建:
| 文件 | 职责 |
|---|---|
achievement/__init__.py |
空 |
achievement/apps.py |
AppConfig,ready() 中导入 metrics 以触发注册 |
achievement/models.py |
Achievement / UserStat / UserAchievement 三张表 |
achievement/metrics.py |
指标注册表与全部指标实现 |
achievement/checker.py |
判定核心:更新指标 → 比对 → 解锁,被任务和管理命令共用 |
achievement/tasks.py |
dramatiq actor:check_achievements、rescan_achievement |
achievement/notify.py |
解锁通知(写 notified=False 记录 + WebSocket 推送) |
achievement/serializers.py |
DRF 序列化器,隐藏成就的打码在此完成 |
achievement/views/oj.py |
用户侧视图 |
achievement/views/admin.py |
管理侧视图 |
achievement/urls/oj.py、achievement/urls/admin.py |
路由 |
achievement/management/commands/recompute_achievements.py |
全量重算命令 |
achievement/migrations/ |
迁移 |
修改:
| 文件 | 改动 |
|---|---|
oj/settings.py:47-64 |
LOCAL_APPS 加 "achievement" |
oj/urls.py |
注册两条路由 |
judge/tasks.py:11-19 |
judge_task 末尾投递 check_achievements |
problemset/views/oj.py:231-247 |
重写 _check_badges:修 N+1、改 get_or_create、接通知 |
前端(ojnext/)
新建:
| 文件 | 职责 |
|---|---|
src/oj/achievement/index.vue |
奖杯馆页面 |
src/oj/achievement/api.ts |
成就相关 API |
src/oj/achievement/components/AchievementCard.vue |
单个成就卡片(三种状态) |
src/shared/components/AchievementToast.vue |
全局解锁弹窗 |
src/shared/store/achievement.ts |
Pinia store:待弹队列 |
src/admin/achievement/list.vue |
管理后台成就列表 |
src/admin/achievement/components/AchievementModal.vue |
新建/编辑成就弹窗 |
修改:
| 文件 | 改动 |
|---|---|
src/routes.ts |
加奖杯馆路由与管理后台路由 |
src/utils/types.ts |
成就相关类型 |
src/oj/problem/composables/useSubmissionMonitor.ts:85 附近 |
handler 按 data.type 过滤,成就消息分流 |
src/shared/layout/default.vue |
挂载 AchievementToast,路由切换拉取 pending |
src/oj/user/index.vue |
成就摘要区 |
src/oj/rank/list.vue |
每行挂最稀有徽章 |
Task 1: 后端 app 骨架与数据模型
Files:
- Create:
OnlineJudge/achievement/__init__.py、apps.py、models.py、migrations/__init__.py - Create:
OnlineJudge/achievement/views/__init__.py、urls/__init__.py - Modify:
OnlineJudge/oj/settings.py:47-64
Interfaces:
-
Produces:
Achievement、UserStat、UserAchievement三个模型;Rarity、Operator两个TextChoices。后续所有任务都从achievement.models导入这些名字。 -
Step 1: 创建包结构
cd OnlineJudge
mkdir -p achievement/migrations achievement/views achievement/urls achievement/management/commands
touch achievement/__init__.py achievement/migrations/__init__.py
touch achievement/views/__init__.py achievement/urls/__init__.py
touch achievement/management/__init__.py achievement/management/commands/__init__.py
- Step 2: 写
achievement/apps.py
metrics.py 要到 Task 2 才存在,所以本任务的 ready() 先留空,Task 2 Step 6 再补上注册钩子。
from django.apps import AppConfig
class AchievementConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "achievement"
- Step 3: 写
achievement/models.py
from django.db import models
from account.models import User
from utils.models import JSONField
class Rarity(models.TextChoices):
BRONZE = "bronze", "青铜"
SILVER = "silver", "白银"
GOLD = "gold", "黄金"
PLATINUM = "platinum", "白金"
class Operator(models.TextChoices):
GTE = "gte", "大于等于"
LTE = "lte", "小于等于"
class Achievement(models.Model):
name = models.TextField(verbose_name="成就名称")
description = models.TextField(verbose_name="成就描述")
icon = models.TextField(verbose_name="图标")
rarity = models.TextField(default=Rarity.BRONZE, choices=Rarity.choices, verbose_name="稀有度")
hidden = models.BooleanField(default=False, db_default=False, verbose_name="是否隐藏")
metric = models.TextField(verbose_name="指标名")
operator = models.TextField(default=Operator.GTE, choices=Operator.choices, verbose_name="比较符")
threshold = models.IntegerField(verbose_name="阈值")
visible = models.BooleanField(default=True, db_default=True, verbose_name="是否上架")
unlock_count = models.IntegerField(default=0, db_default=0, verbose_name="已解锁人数")
order = models.IntegerField(default=0, db_default=0, verbose_name="排序")
create_time = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "achievement"
ordering = ("order", "id")
verbose_name = "成就"
verbose_name_plural = "成就"
class UserStat(models.Model):
"""成就系统唯一的指标源,不复用 UserProfile 的计数器,避免两处口径漂移。
metrics 为 {指标名: 数值};指标从未产生过有效值时 key 不存在(而非置 0),
否则 min_ac_code_chars 这类极小值指标会对新用户恒成立。
"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="achievement_stat")
metrics = JSONField(default=dict, db_default=models.Value({}, output_field=models.JSONField()))
update_time = models.DateTimeField(auto_now=True)
class Meta:
db_table = "user_stat"
class UserAchievement(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="achievements")
achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE)
unlock_time = models.DateTimeField(auto_now_add=True)
# 上线补发的记录:前端不显示具体日期,只显示"已获得"
backfilled = models.BooleanField(default=False, db_default=False)
# 是否已向用户弹过奖杯,pending 端点据此查询
notified = models.BooleanField(default=False, db_default=False)
class Meta:
db_table = "user_achievement"
ordering = ("-unlock_time",)
constraints = [
models.UniqueConstraint(fields=["user", "achievement"], name="unique_user_achievement"),
]
indexes = [
models.Index(fields=["user", "-unlock_time"], name="user_achv_time_idx"),
models.Index(fields=["user", "notified"], name="user_achv_notified_idx"),
]
- Step 4: 注册 app
在 oj/settings.py 的 LOCAL_APPS 列表末尾("class_pk", 之后)加一行:
"achievement",
- Step 5: 生成并应用迁移
cd OnlineJudge
python manage.py makemigrations achievement
python manage.py migrate achievement
Expected: 输出 Create model Achievement / UserStat / UserAchievement,migrate 成功无报错。
- Step 6: 验证模型可用
python manage.py shell -c "
from achievement.models import Achievement, UserStat, UserAchievement, Rarity, Operator
a = Achievement.objects.create(name='测试', description='d', icon='🏆', metric='accepted_count', operator=Operator.GTE, threshold=1)
print('created', a.id, a.rarity, a.visible, a.unlock_count)
a.delete()
print('ok')
"
Expected: 打印 created <id> bronze True 0 与 ok。
- Step 7: Lint 并提交
cd OnlineJudge
ruff format . && ruff check .
git add achievement oj/settings.py
git commit -m "feat(achievement): 添加成就系统数据模型"
Task 2: 指标注册表
Files:
- Create:
OnlineJudge/achievement/metrics.py - Modify:
OnlineJudge/achievement/apps.py
Interfaces:
-
Consumes:
achievement.models.UserStat -
Produces:
METRIC_REGISTRY: dict[str, BaseMetric]@metric(key, name, help_text)装饰器BaseMetric.on_submission(self, metrics: dict, sub: Submission, ctx: dict) -> None— 原地修改metricsBaseMetric.recompute(self, user) -> int | None— 返回None表示该用户此指标无有效值(调用方应删除该 key)build_ctx(user_id: int, submission) -> dict— 判题后预查一次的共享上下文META_METRICS: set[str]— 元指标 key 集合(第二轮判定用)
-
Step 1: 写指标基类与注册表
创建 achievement/metrics.py,先写骨架:
"""成就指标注册表。
这里定义"能测量什么",后台定义"多少算达成"。管理员在后台看到的指标下拉框
就是 METRIC_REGISTRY 的 key 列表。加一个新维度必须改本文件并部署,
之后在该维度上加任意多条成就都是纯配置。
约定:指标从未产生过有效值时,recompute 返回 None,调用方删除该 key。
metrics 字典里 key 不存在 == 未达标,判定时直接跳过。
"""
import logging
from django.db.models import Count, Q
from django.utils import timezone
from submission.models import JudgeStatus, Submission
logger = logging.getLogger(__name__)
METRIC_REGISTRY = {}
META_METRICS = set()
class BaseMetric:
key = ""
name = ""
help_text = ""
def on_submission(self, metrics, sub, ctx):
"""判题完成后增量更新 metrics(原地修改)。"""
raise NotImplementedError
def recompute(self, user):
"""全量重算。返回 None 表示该用户此指标无有效值。"""
raise NotImplementedError
def metric(key, name, help_text="", meta=False):
def deco(cls):
cls.key = key
cls.name = name
cls.help_text = help_text
METRIC_REGISTRY[key] = cls()
if meta:
META_METRICS.add(key)
return cls
return deco
def _practice_submissions(user_id):
"""成就只统计平时练习的提交,比赛提交不计入。"""
return Submission.objects.filter(user_id=user_id, contest_id__isnull=True)
- Step 2: 写共享上下文构建函数
追加到 metrics.py:
def build_ctx(user_id, sub):
"""判题后预查一次,所有指标复用,避免每个指标各自查库。
比赛提交不参与成就统计,此时返回 skip=True,调用方直接跳过整轮判定。
"""
if sub.contest_id is not None:
return {"skip": True}
prior = _practice_submissions(user_id).filter(problem_id=sub.problem_id).exclude(id=sub.id)
prior_stats = prior.aggregate(
total=Count("id"),
accepted=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)),
)
local_now = timezone.localtime(sub.create_time)
return {
"skip": False,
"is_accepted": sub.result == JudgeStatus.ACCEPTED,
# 该题此前的提交次数与 AC 次数
"prior_count": prior_stats["total"],
"prior_accepted": prior_stats["accepted"],
# 首次 AC 这道题(此前从未 AC 过)
"is_first_ac_of_problem": sub.result == JudgeStatus.ACCEPTED and prior_stats["accepted"] == 0,
# 一发入魂:此前无任何提交且本次 AC
"is_first_try_ac": sub.result == JudgeStatus.ACCEPTED and prior_stats["total"] == 0,
"local_date": local_now.date().isoformat(),
"local_hour": local_now.hour,
}
- Step 3: 写累积型指标
追加到 metrics.py:
@metric("accepted_count", "AC 题目数", "去重后通过的题目数量(不含比赛)")
class AcceptedCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if ctx["is_first_ac_of_problem"]:
metrics["accepted_count"] = metrics.get("accepted_count", 0) + 1
def recompute(self, user):
return _practice_submissions(user.id).filter(result=JudgeStatus.ACCEPTED).values("problem_id").distinct().count()
@metric("submission_count", "提交总数", "提交次数(不含比赛)")
class SubmissionCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
metrics["submission_count"] = metrics.get("submission_count", 0) + 1
def recompute(self, user):
return _practice_submissions(user.id).count()
@metric("active_days", "活跃天数", "有过提交的累计天数")
class ActiveDays(BaseMetric):
def on_submission(self, metrics, sub, ctx):
seen = metrics.get("_active_dates", [])
if ctx["local_date"] not in seen:
seen.append(ctx["local_date"])
metrics["_active_dates"] = seen
metrics["active_days"] = len(seen)
def recompute(self, user):
dates = {timezone.localtime(t).date().isoformat() for t in _practice_submissions(user.id).values_list("create_time", flat=True)}
return len(dates)
@metric("max_ac_streak_days", "最长连续 AC 天数", "连续每天至少 AC 一题的最长天数")
class MaxAcStreakDays(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if not ctx["is_accepted"]:
return
today = ctx["local_date"]
last = metrics.get("_last_ac_date")
if last == today:
return
current = metrics.get("_current_ac_streak", 0)
if last and (timezone.datetime.fromisoformat(today) - timezone.datetime.fromisoformat(last)).days == 1:
current += 1
else:
current = 1
metrics["_last_ac_date"] = today
metrics["_current_ac_streak"] = current
metrics["max_ac_streak_days"] = max(metrics.get("max_ac_streak_days", 0), current)
def recompute(self, user):
dates = sorted({timezone.localtime(t).date() for t in _practice_submissions(user.id).filter(result=JudgeStatus.ACCEPTED).values_list("create_time", flat=True)})
if not dates:
return None
best = current = 1
for prev, cur in zip(dates, dates[1:]):
current = current + 1 if (cur - prev).days == 1 else 1
best = max(best, current)
return best
@metric("languages_used", "使用语言数", "用过多少种编程语言")
class LanguagesUsed(BaseMetric):
def on_submission(self, metrics, sub, ctx):
seen = metrics.get("_languages", [])
if sub.language not in seen:
seen.append(sub.language)
metrics["_languages"] = seen
metrics["languages_used"] = len(seen)
def recompute(self, user):
return _practice_submissions(user.id).values("language").distinct().count()
@metric("contest_joined", "参赛场次", "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)")
class ContestJoined(BaseMetric):
def on_submission(self, metrics, sub, ctx):
# 比赛提交在 build_ctx 就被跳过,本指标只走 recompute
return
def recompute(self, user):
return Submission.objects.filter(user_id=user.id, contest_id__isnull=False).values("contest_id").distinct().count()
@metric("badge_count", "题单奖章数", "获得的题单奖章数量")
class BadgeCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
# 奖章由题单流程颁发,本指标只走 recompute,见 Task 7
return
def recompute(self, user):
from problemset.models import UserBadge
return UserBadge.objects.filter(user=user).count()
@metric("problemset_completed", "完成题单数", "完成的题单数量")
class ProblemSetCompleted(BaseMetric):
def on_submission(self, metrics, sub, ctx):
return
def recompute(self, user):
from problemset.models import ProblemSetProgress
return ProblemSetProgress.objects.filter(user=user, complete_time__isnull=False).count()
- Step 4: 写隐藏型指标
追加到 metrics.py:
@metric("first_try_ac_count", "一发入魂次数", "首次提交即通过的次数")
class FirstTryAcCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if ctx["is_first_try_ac"]:
metrics["first_try_ac_count"] = metrics.get("first_try_ac_count", 0) + 1
def recompute(self, user):
count = 0
seen = set()
for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
if s["problem_id"] in seen:
continue
seen.add(s["problem_id"])
if s["result"] == JudgeStatus.ACCEPTED:
count += 1
return count
@metric("midnight_submissions", "凌晨提交次数", "0:00–5:00 之间的提交次数")
class MidnightSubmissions(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if 0 <= ctx["local_hour"] < 5:
metrics["midnight_submissions"] = metrics.get("midnight_submissions", 0) + 1
def recompute(self, user):
return sum(1 for t in _practice_submissions(user.id).values_list("create_time", flat=True) if 0 <= timezone.localtime(t).hour < 5)
@metric("compile_error_count", "编译错误次数", "累计编译错误的次数")
class CompileErrorCount(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if sub.result == JudgeStatus.COMPILE_ERROR:
metrics["compile_error_count"] = metrics.get("compile_error_count", 0) + 1
def recompute(self, user):
return _practice_submissions(user.id).filter(result=JudgeStatus.COMPILE_ERROR).count()
@metric("max_wa_before_ac", "屡败屡战", "单题失败最多多少次后终于通过")
class MaxWaBeforeAc(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if ctx["is_first_ac_of_problem"]:
metrics["max_wa_before_ac"] = max(metrics.get("max_wa_before_ac", 0), ctx["prior_count"])
def recompute(self, user):
best = None
attempts = {}
for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
pid = s["problem_id"]
if pid in attempts and attempts[pid] is None:
continue
if s["result"] == JudgeStatus.ACCEPTED:
best = max(best or 0, attempts.get(pid, 0))
attempts[pid] = None
else:
attempts[pid] = attempts.get(pid, 0) + 1
return best
@metric("max_ac_in_one_day", "单日最多 AC", "一天之内最多通过多少题")
class MaxAcInOneDay(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if not ctx["is_first_ac_of_problem"]:
return
counts = metrics.get("_ac_per_day", {})
counts[ctx["local_date"]] = counts.get(ctx["local_date"], 0) + 1
metrics["_ac_per_day"] = counts
metrics["max_ac_in_one_day"] = max(counts.values())
def recompute(self, user):
counts = {}
seen = set()
for s in _practice_submissions(user.id).filter(result=JudgeStatus.ACCEPTED).order_by("create_time").values("problem_id", "create_time"):
if s["problem_id"] in seen:
continue
seen.add(s["problem_id"])
day = timezone.localtime(s["create_time"]).date().isoformat()
counts[day] = counts.get(day, 0) + 1
return max(counts.values()) if counts else None
@metric("min_ac_code_chars", "最短 AC 代码", "通过的代码里最短的字符数(配小于等于使用)")
class MinAcCodeChars(BaseMetric):
def on_submission(self, metrics, sub, ctx):
if not ctx["is_accepted"]:
return
length = len(sub.code)
cur = metrics.get("min_ac_code_chars")
metrics["min_ac_code_chars"] = length if cur is None else min(cur, length)
def recompute(self, user):
lengths = [len(c) for c in _practice_submissions(user.id).filter(result=JudgeStatus.ACCEPTED).values_list("code", flat=True)]
return min(lengths) if lengths else None
@metric("max_code_lines", "最长代码行数", "提交过的最长代码有多少行")
class MaxCodeLines(BaseMetric):
def on_submission(self, metrics, sub, ctx):
lines = len(sub.code.splitlines())
metrics["max_code_lines"] = max(metrics.get("max_code_lines", 0), lines)
def recompute(self, user):
counts = [len(c.splitlines()) for c in _practice_submissions(user.id).values_list("code", flat=True)]
return max(counts) if counts else None
- Step 5: 写元指标
追加到 metrics.py:
@metric("achievement_unlocked_count", "已解锁成就数", "已解锁的成就数量(不含白金档)", meta=True)
class AchievementUnlockedCount(BaseMetric):
"""自引用指标:解锁成就会改变它。
因此判定流程限定为最多两轮(见 checker.py),且口径排除白金档自身,
避免「集齐 N 个成就」这类白金奖杯把自己算进分子。
"""
def on_submission(self, metrics, sub, ctx):
# 由 checker 在第一轮解锁后显式重算,不参与增量更新
return
def recompute(self, user):
from achievement.models import Rarity, UserAchievement
return UserAchievement.objects.filter(user=user).exclude(achievement__rarity=Rarity.PLATINUM).count()
- Step 6: 打开 apps.py 的注册钩子
把 achievement/apps.py 改成:
from django.apps import AppConfig
class AchievementConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "achievement"
def ready(self):
# 导入以触发 @metric 装饰器把指标注册进 METRIC_REGISTRY
import achievement.metrics # noqa: F401
- Step 7: 验证注册表与全量重算
cd OnlineJudge
python manage.py shell -c "
from achievement.metrics import METRIC_REGISTRY, META_METRICS
print('registered:', len(METRIC_REGISTRY))
for k, m in METRIC_REGISTRY.items():
print(' ', k, '|', m.name)
print('meta:', META_METRICS)
"
Expected: registered: 16,逐行列出全部指标,meta: {'achievement_unlocked_count'}。
拿一个真实用户跑一遍全量重算,确认每个指标都能算出值且不抛异常:
python manage.py shell -c "
from account.models import User
from achievement.metrics import METRIC_REGISTRY
u = User.objects.filter(is_disabled=False).first()
print('user:', u.username)
for k, m in METRIC_REGISTRY.items():
print(f' {k} = {m.recompute(u)}')
"
Expected: 每个指标打印一个整数或 None,无 traceback。特别确认 min_ac_code_chars 对没有 AC 记录的用户返回 None 而不是 0。
- Step 8: Lint 并提交
cd OnlineJudge
ruff format . && ruff check .
git add achievement/metrics.py achievement/apps.py
git commit -m "feat(achievement): 添加指标注册表与 16 个内置指标"
Task 3: 判定核心、通知模块与判题链路接入
Files:
- Create:
OnlineJudge/achievement/checker.py、achievement/notify.py、achievement/tasks.py - Modify:
OnlineJudge/judge/tasks.py
Interfaces:
-
Consumes:
METRIC_REGISTRY、META_METRICS、build_ctx(Task 2),三个模型(Task 1) -
Produces:
checker.evaluate(user, metrics: dict, only_metrics: set | None = None) -> list[Achievement]— 纯判定,返回应解锁但尚未解锁的成就checker.unlock(user, achievements: list, backfilled=False, notified=False) -> list[UserAchievement]— 写库 + 更新unlock_countchecker.run_for_submission(user: User, submission) -> list[UserAchievement]— 完整两轮流程(传 User 实例,不是 id)notify.notify_achievements(user_id: int, records: list[UserAchievement]) -> Nonenotify.notify_badges(user_id: int, badges: list) -> Nonetasks.check_achievements.send(user_id, submission_id)
-
Step 1: 写
achievement/checker.py
"""成就判定核心,被异步任务和管理命令共用。
判定刻意做成"一次查询取全部候选 + 内存比对",与 problemset 里逐条查询的
旧写法相反:一次判题只多 3~4 条 SQL。
"""
import logging
from django.db import transaction
from django.db.models import F
from achievement.metrics import META_METRICS, METRIC_REGISTRY, build_ctx
from achievement.models import Achievement, Operator, UserAchievement, UserStat
logger = logging.getLogger(__name__)
def get_or_create_stat(user):
stat, _ = UserStat.objects.get_or_create(user=user)
return stat
def evaluate(user, metrics, only_metrics=None):
"""返回该用户应解锁但尚未解锁的成就列表。"""
unlocked_ids = set(UserAchievement.objects.filter(user=user).values_list("achievement_id", flat=True))
qs = Achievement.objects.filter(visible=True).exclude(id__in=unlocked_ids)
if only_metrics is not None:
qs = qs.filter(metric__in=only_metrics)
hits = []
for achievement in qs:
value = metrics.get(achievement.metric)
# 指标从未产生有效值时 key 不存在,直接跳过:
# 否则 min_ac_code_chars 这类极小值指标会对新用户恒成立
if value is None:
continue
if achievement.operator == Operator.GTE and value >= achievement.threshold:
hits.append(achievement)
elif achievement.operator == Operator.LTE and value <= achievement.threshold:
hits.append(achievement)
return hits
def unlock(user, achievements, backfilled=False, notified=False):
"""写入解锁记录并累加 unlock_count,返回实际新建的记录。
刻意逐条 get_or_create 而不是 bulk_create:unique_user_achievement 约束负责
并发竞态,was_created 是"这一条确实是我新建的"的唯一可信判据。用
bulk_create(ignore_conflicts=True) 则无法区分新建与已存在,并发判题时会把
unlock_count 重复累加(获得率永久偏高),并对同一个奖杯重复推送通知。
循环次数是"本次新解锁的成就数",常态为 0,因此常态零查询。
"""
created = []
for achievement in achievements:
record, was_created = UserAchievement.objects.get_or_create(
user=user,
achievement=achievement,
defaults={"backfilled": backfilled, "notified": notified},
)
if was_created:
Achievement.objects.filter(id=achievement.id).update(unlock_count=F("unlock_count") + 1)
created.append(record)
return created
def run_for_submission(user, submission):
"""判题后的完整流程:更新指标 → 第一轮判定 → 元指标第二轮判定。"""
ctx = build_ctx(user.id, submission)
if ctx["skip"]:
# 比赛提交不计入成就
return []
with transaction.atomic():
stat = UserStat.objects.select_for_update().get_or_create(user=user)[0]
for key, m in METRIC_REGISTRY.items():
if key in META_METRICS:
continue
m.on_submission(stat.metrics, submission, ctx)
stat.save(update_fields=["metrics", "update_time"])
first = unlock(user, evaluate(user, stat.metrics))
if not first:
return []
# 第二轮:只重算元指标、只判定依赖元指标的成就,不再有第三轮
meta_values = {key: METRIC_REGISTRY[key].recompute(user) for key in META_METRICS}
# 必须重新取锁并重新读一次 stat:上面那个 stat 对象的 metrics 是解锁前的快照,
# 直接 save 会把整份字典写回,覆盖掉并发判题在这期间已提交的增量
# (同一用户两次提交并发判题时会让提交数/AC 数静默倒退,且无定期重算兜底)。
# 这里只合并元指标那几个 key。
with transaction.atomic():
stat = UserStat.objects.select_for_update().get(user=user)
for key, value in meta_values.items():
if value is None:
stat.metrics.pop(key, None)
else:
stat.metrics[key] = value
stat.save(update_fields=["metrics", "update_time"])
second = unlock(user, evaluate(user, stat.metrics, only_metrics=META_METRICS))
return first + second
- Step 2: 写
achievement/notify.py
"""解锁通知。
通知走推拉结合,UserAchievement.notified 是唯一的真相来源:
- 拉(主):前端在布局层拉 /api/achievements/pending,覆盖全部场景
- 推(增强):WebSocket 只负责把"当场那一下"的延迟压到几百毫秒
必须推拉结合的原因:前端 WebSocket 不是常驻连接,useSubmissionWebSocket
只在问题页且有提交监听时建连,纯推会丢消息。
"""
import logging
from utils.websocket import push_to_user
logger = logging.getLogger(__name__)
def notify_achievements(user_id, records):
"""records: list[UserAchievement],已带 select_related('achievement')。"""
if not records:
return
payload = [
{
"id": r.achievement_id,
"name": r.achievement.name,
"description": r.achievement.description,
"icon": r.achievement.icon,
"rarity": r.achievement.rarity,
"kind": "achievement",
}
for r in records
]
_push(user_id, payload)
def notify_badges(user_id, badges):
"""badges: list[ProblemSetBadge]。题单奖章复用同一个弹窗组件。"""
if not badges:
return
payload = [
{
"id": b.id,
"name": b.name,
"description": b.description,
"icon": b.icon,
"rarity": "bronze",
"kind": "badge",
}
for b in badges
]
_push(user_id, payload)
def _push(user_id, payload):
# 推送失败不影响已入库的解锁记录,前端下次拉 pending 时仍会补弹
try:
push_to_user(user_id, "achievement_unlocked", {"achievements": payload})
except Exception as e:
logger.error(f"Failed to push achievement notification: user_id={user_id}, error={e}")
- Step 3: 写
achievement/tasks.py
import logging
import dramatiq
from account.models import User
from achievement import checker
from achievement.notify import notify_achievements
from submission.models import Submission
from utils.shortcuts import DRAMATIQ_WORKER_ARGS
logger = logging.getLogger(__name__)
@dramatiq.actor(**DRAMATIQ_WORKER_ARGS())
def check_achievements(user_id, submission_id):
"""判题完成后的成就判定。
所有异常在此吞掉:成就算错绝不能影响判题结果,这也是选异步的意义。
"""
try:
user = User.objects.get(id=user_id)
submission = Submission.objects.get(id=submission_id)
records = checker.run_for_submission(user, submission)
notify_achievements(user_id, records)
except Exception as e:
logger.exception(f"check_achievements failed: user_id={user_id}, submission_id={submission_id}, error={e}")
- Step 4: 接入判题链路
修改 judge/tasks.py,在 judge_task 末尾投递。必须挂在 actor 末尾而不是 dispatcher 内部:JudgeDispatcher.judge() 对比赛分支在 judge/dispatcher.py:213 会提前 return,挂在里面会漏;挂在这里同时覆盖 JudgeDispatcher 和 SQLJudgeDispatcher 两条链路。
改成:
import dramatiq
from account.models import User
from achievement.tasks import check_achievements
from judge.dispatcher import JudgeDispatcher
from judge.sql_dispatcher import SQLJudgeDispatcher
from submission.models import Submission
from utils.shortcuts import DRAMATIQ_WORKER_ARGS
@dramatiq.actor(**DRAMATIQ_WORKER_ARGS())
def judge_task(submission_id, problem_id):
submission = Submission.objects.get(id=submission_id)
if User.objects.get(id=submission.user_id).is_disabled:
return
# SQL 题不依赖 JudgeServer 沙箱,在 worker 内用 sqlite3 判题
if submission.language == "SQL":
SQLJudgeDispatcher(submission_id, problem_id).judge()
else:
JudgeDispatcher(submission_id, problem_id).judge()
# 判题结束后异步判定成就;投递失败不影响判题结果
check_achievements.send(submission.user_id, submission_id)
- Step 5: 手动验证判定流程
先造一个必然达成的成就,再拿一条已有的练习提交跑一遍:
cd OnlineJudge
python manage.py shell -c "
from account.models import User
from achievement.models import Achievement, Operator, UserAchievement, UserStat
from achievement import checker
from submission.models import Submission
a = Achievement.objects.create(name='初次提交', description='提交一次代码', icon='🎯', metric='submission_count', operator=Operator.GTE, threshold=1)
sub = Submission.objects.filter(contest_id__isnull=True).order_by('-create_time').first()
user = User.objects.get(id=sub.user_id)
UserStat.objects.filter(user=user).delete()
UserAchievement.objects.filter(user=user).delete()
records = checker.run_for_submission(user, sub)
print('unlocked:', [r.achievement.name for r in records])
print('metrics:', UserStat.objects.get(user=user).metrics)
a.refresh_from_db()
print('unlock_count:', a.unlock_count)
UserAchievement.objects.filter(user=user).delete()
a.delete()
print('cleaned')
"
Expected: unlocked: ['初次提交'],metrics 里出现 submission_count 等 key,unlock_count: 1,最后打印 cleaned。
再验证比赛提交被跳过:
python manage.py shell -c "
from account.models import User
from achievement import checker
from submission.models import Submission
sub = Submission.objects.filter(contest_id__isnull=False).first()
if sub is None:
print('no contest submission, skip check')
else:
user = User.objects.get(id=sub.user_id)
print('result:', checker.run_for_submission(user, sub))
"
Expected: 打印 result: [](或提示无比赛提交可测)。
- Step 6: Lint 并提交
cd OnlineJudge
ruff format . && ruff check .
git add achievement/checker.py achievement/notify.py achievement/tasks.py judge/tasks.py
git commit -m "feat(achievement): 添加判定核心、通知模块并接入判题链路"
Task 4: 用户侧 API
Files:
- Create:
OnlineJudge/achievement/serializers.py、achievement/views/oj.py、achievement/urls/oj.py - Modify:
OnlineJudge/oj/urls.py
Interfaces:
-
Consumes: 三个模型、
METRIC_REGISTRY -
Produces: 四个用户侧端点
GET /api/achievements?name=<username>GET /api/achievements/summary?name=<username>GET /api/achievements/pendingPOST /api/achievements/pending/read,body{"ids": [1, 2]}
-
Step 1: 写
achievement/serializers.py
隐藏成就的打码必须在序列化层完成——未解锁的隐藏成就绝不能把真实 name/description 下发到前端,否则打开 DevTools 就能看光。
from django.conf import settings
from django.core.cache import cache
from rest_framework import serializers
from account.models import User
from achievement.models import Achievement
def get_active_user_count():
"""获得率的分母:未禁用用户总数,缓存 1 小时。
不用"有过提交的用户数"这类动态口径:分母波动会让同一个成就的获得率
忽高忽低,学生会当成 bug。
"""
count = cache.get("achievement_active_user_count")
if count is None:
count = User.objects.filter(is_disabled=False).count()
cache.set("achievement_active_user_count", count, 3600)
return count
class AchievementSerializer(serializers.ModelSerializer):
unlocked = serializers.BooleanField(read_only=True)
unlock_time = serializers.DateTimeField(read_only=True, allow_null=True)
backfilled = serializers.BooleanField(read_only=True)
progress = serializers.IntegerField(read_only=True, allow_null=True)
unlock_rate = serializers.SerializerMethodField()
name = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
icon = serializers.SerializerMethodField()
class Meta:
model = Achievement
fields = (
"id", "name", "description", "icon", "rarity", "hidden", "metric",
"operator", "threshold", "unlocked", "unlock_time", "backfilled",
"progress", "unlock_rate",
)
def _masked(self, obj):
return obj.hidden and not getattr(obj, "unlocked", False)
def get_name(self, obj):
return "???" if self._masked(obj) else obj.name
def get_description(self, obj):
return "达成条件保密" if self._masked(obj) else obj.description
def get_icon(self, obj):
return "❓" if self._masked(obj) else obj.icon
def get_unlock_rate(self, obj):
total = get_active_user_count()
if not total:
return 0.0
return round(obj.unlock_count / total * 100, 1)
class PendingAchievementSerializer(serializers.ModelSerializer):
"""待弹窗的解锁记录,无需打码(已解锁)。"""
id = serializers.IntegerField(source="achievement_id", read_only=True)
name = serializers.CharField(source="achievement.name", read_only=True)
description = serializers.CharField(source="achievement.description", read_only=True)
icon = serializers.CharField(source="achievement.icon", read_only=True)
rarity = serializers.CharField(source="achievement.rarity", read_only=True)
class Meta:
from achievement.models import UserAchievement
model = UserAchievement
fields = ("id", "name", "description", "icon", "rarity")
- Step 2: 写
achievement/views/oj.py
from django.db.models import Count
from account.decorators import login_required
from account.models import User
from achievement.models import Achievement, Rarity, UserAchievement, UserStat
from achievement.serializers import AchievementSerializer, PendingAchievementSerializer
from utils.api import APIView
def _resolve_user(request):
"""?name=<username> 指定他人,不传则为自己。与 /api/profile 的约定一致。"""
username = request.GET.get("name")
if username:
return User.objects.filter(username=username, is_disabled=False).first()
return request.user
def _decorate(achievements, unlocked_map, metrics):
"""给成就对象挂上该用户的解锁状态与进度,供序列化器读取。"""
for a in achievements:
record = unlocked_map.get(a.id)
a.unlocked = record is not None
a.unlock_time = record.unlock_time if record else None
a.backfilled = record.backfilled if record else False
value = metrics.get(a.metric)
# 隐藏且未解锁的成就不下发进度,否则能反推出条件
a.progress = None if (a.hidden and not a.unlocked) else (value or 0)
return achievements
class AchievementListAPI(APIView):
@login_required
def get(self, request):
user = _resolve_user(request)
if user is None:
return self.error("用户不存在")
achievements = list(Achievement.objects.filter(visible=True))
unlocked_map = {r.achievement_id: r for r in UserAchievement.objects.filter(user=user)}
stat = UserStat.objects.filter(user=user).first()
metrics = stat.metrics if stat else {}
_decorate(achievements, unlocked_map, metrics)
return self.success({
"username": user.username,
"achievements": AchievementSerializer(achievements, many=True).data,
})
class AchievementSummaryAPI(APIView):
@login_required
def get(self, request):
user = _resolve_user(request)
if user is None:
return self.error("用户不存在")
total_by_rarity = dict(
Achievement.objects.filter(visible=True).values_list("rarity").annotate(c=Count("id"))
)
unlocked_by_rarity = dict(
UserAchievement.objects.filter(user=user, achievement__visible=True)
.values_list("achievement__rarity")
.annotate(c=Count("id"))
)
total = sum(total_by_rarity.values())
unlocked = sum(unlocked_by_rarity.values())
recent = list(
UserAchievement.objects.filter(user=user, achievement__visible=True)
.select_related("achievement")
.order_by("-unlock_time")[:5]
)
return self.success({
"username": user.username,
"total": total,
"unlocked": unlocked,
"percent": round(unlocked / total * 100, 1) if total else 0.0,
"rarity": [
{
"rarity": value,
"label": label,
"total": total_by_rarity.get(value, 0),
"unlocked": unlocked_by_rarity.get(value, 0),
}
for value, label in Rarity.choices
],
"recent": PendingAchievementSerializer(recent, many=True).data,
})
class AchievementPendingAPI(APIView):
@login_required
def get(self, request):
"""返回尚未弹过的解锁记录。前端在布局层路由切换时拉取。"""
records = (
UserAchievement.objects.filter(user=request.user, notified=False, achievement__visible=True)
.select_related("achievement")
.order_by("unlock_time")
)
return self.success(PendingAchievementSerializer(records, many=True).data)
@login_required
def post(self, request):
"""弹完后标记已读,避免下次导航重复弹。body: {"ids": [成就 id]}"""
ids = request.data.get("ids") or []
if not isinstance(ids, list):
return self.error("参数错误")
UserAchievement.objects.filter(user=request.user, achievement_id__in=ids).update(notified=True)
return self.success("ok")
- Step 3: 写
achievement/urls/oj.py
from django.urls import path
from achievement.views.oj import AchievementListAPI, AchievementPendingAPI, AchievementSummaryAPI
urlpatterns = [
path("achievements", AchievementListAPI.as_view(), name="achievement_list_api"),
path("achievements/summary", AchievementSummaryAPI.as_view(), name="achievement_summary_api"),
path("achievements/pending", AchievementPendingAPI.as_view(), name="achievement_pending_api"),
]
待读标记复用
AchievementPendingAPI的post,因此不需要单独的pending/read路径。Spec 里写的POST /api/achievements/pending/read以此实现为准:POST /api/achievements/pending。 前端 Task 8 按这个路径写。
- Step 4: 注册路由
在 oj/urls.py 的 urlpatterns 末尾(class_pk 那行之后)加:
path("api/", include("achievement.urls.oj")),
- Step 5: 验证端点
启动开发服务器:
cd OnlineJudge
python dev.py
另开一个终端,先造两条成就(一条普通、一条隐藏),然后用浏览器登录后访问接口。取一个已登录会话的 cookie 最简单的办法是直接在浏览器地址栏打开:
http://localhost:8000/api/achievements
http://localhost:8000/api/achievements/summary
http://localhost:8000/api/achievements/pending
造数据:
python manage.py shell -c "
from achievement.models import Achievement, Operator, Rarity
Achievement.objects.create(name='初出茅庐', description='通过 1 道题', icon='🌱', metric='accepted_count', operator=Operator.GTE, threshold=1, rarity=Rarity.BRONZE, order=1)
Achievement.objects.create(name='夜猫子', description='凌晨提交 10 次', icon='🦉', metric='midnight_submissions', operator=Operator.GTE, threshold=10, rarity=Rarity.SILVER, hidden=True, order=2)
print('seeded')
"
Expected:
-
/api/achievements返回data.achievements数组,其中「夜猫子」若未解锁,name为"???"、description为"达成条件保密"、icon为"❓"、progress为null -
「初出茅庐」的
progress是一个数字,unlock_rate是 0~100 的浮点数 -
/api/achievements/summary返回total、unlocked、percent、rarity四档、recent -
/api/achievements/pending返回数组(可能为空) -
Step 6: Lint 并提交
cd OnlineJudge
ruff format . && ruff check .
git add achievement/serializers.py achievement/views/oj.py achievement/urls/oj.py oj/urls.py
git commit -m "feat(achievement): 添加用户侧成就 API"
Task 5: 管理侧 API 与阈值下调补发
Files:
- Create:
OnlineJudge/achievement/views/admin.py、achievement/urls/admin.py - Modify:
OnlineJudge/achievement/tasks.py、OnlineJudge/oj/urls.py
Interfaces:
-
Consumes:
Achievement、METRIC_REGISTRY、checker.evaluate/unlock -
Produces:
GET/POST/PUT/DELETE /api/admin/achievementGET /api/admin/achievement/metricstasks.rescan_achievement.send(achievement_id)
-
Step 1: 加补发任务到
achievement/tasks.py
追加:
@dramatiq.actor(**DRAMATIQ_WORKER_ARGS())
def rescan_achievement(achievement_id):
"""新建成就或调低阈值后,补发给已达标的存量用户。
判定只在判题时发生,因此后台改了阈值不会自动补发,必须显式扫一遍。
"""
from django.db.models import IntegerField
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Cast
from achievement.models import Achievement, Operator, UserAchievement, UserStat
from achievement.notify import notify_achievements
try:
achievement = Achievement.objects.get(id=achievement_id, visible=True)
except Achievement.DoesNotExist:
return
# JSONField 的默认比较是 JSON 值比较,数字会按字符串序比("9" > "50"),
# 必须显式 cast 成整数,否则筛出来的用户是错的
qs = UserStat.objects.filter(metrics__has_key=achievement.metric).annotate(
v=Cast(KeyTextTransform(achievement.metric, "metrics"), IntegerField())
)
if achievement.operator == Operator.GTE:
qs = qs.filter(v__gte=achievement.threshold)
else:
qs = qs.filter(v__lte=achievement.threshold)
already = set(UserAchievement.objects.filter(achievement=achievement).values_list("user_id", flat=True))
for stat in qs.select_related("user").iterator():
if stat.user_id in already:
continue
try:
records = checker.unlock(stat.user, [achievement])
notify_achievements(stat.user_id, records)
except Exception as e:
logger.error(f"rescan_achievement failed for user {stat.user_id}: {e}")
- Step 2: 写
achievement/views/admin.py
from account.decorators import super_admin_required
from achievement.metrics import METRIC_REGISTRY
from achievement.models import Achievement
from achievement.tasks import rescan_achievement
from utils.api import APIView
from utils.shortcuts import check_is_id
class AchievementAdminAPI(APIView):
@super_admin_required
def get(self, request):
achievement_id = request.GET.get("id")
if achievement_id:
try:
achievement = Achievement.objects.get(id=achievement_id)
except Achievement.DoesNotExist:
return self.error("成就不存在")
return self.success(_serialize(achievement))
return self.success([_serialize(a) for a in Achievement.objects.all()])
@super_admin_required
def post(self, request):
data = request.data
error = _validate(data)
if error:
return self.error(error)
achievement = Achievement.objects.create(
name=data["name"],
description=data["description"],
icon=data["icon"],
rarity=data["rarity"],
hidden=data.get("hidden", False),
metric=data["metric"],
operator=data["operator"],
threshold=data["threshold"],
visible=data.get("visible", True),
order=data.get("order", 0),
)
# 新建成就需要补发给已达标的存量用户
rescan_achievement.send(achievement.id)
return self.success(_serialize(achievement))
@super_admin_required
def put(self, request):
data = request.data
if not check_is_id(data.get("id")):
return self.error("参数错误")
try:
achievement = Achievement.objects.get(id=data["id"])
except Achievement.DoesNotExist:
return self.error("成就不存在")
error = _validate(data)
if error:
return self.error(error)
old_threshold = achievement.threshold
old_operator = achievement.operator
for field in ("name", "description", "icon", "rarity", "hidden", "metric", "operator", "threshold", "visible", "order"):
if field in data:
setattr(achievement, field, data[field])
achievement.save()
# 条件放宽(gte 调低阈值 / lte 调高阈值 / 换了比较符)时补发
loosened = (
achievement.operator != old_operator
or (achievement.operator == "gte" and achievement.threshold < old_threshold)
or (achievement.operator == "lte" and achievement.threshold > old_threshold)
)
if loosened and achievement.visible:
rescan_achievement.send(achievement.id)
return self.success(_serialize(achievement))
@super_admin_required
def delete(self, request):
achievement_id = request.GET.get("id")
if not check_is_id(achievement_id):
return self.error("参数错误")
Achievement.objects.filter(id=achievement_id).delete()
return self.success("删除成功")
class AchievementMetricAdminAPI(APIView):
@super_admin_required
def get(self, request):
"""供后台指标下拉框使用。这里的列表就是代码里注册了什么。"""
return self.success([
{"key": key, "name": m.name, "help_text": m.help_text}
for key, m in METRIC_REGISTRY.items()
])
def _validate(data):
for field in ("name", "description", "icon", "rarity", "metric", "operator"):
if not data.get(field):
return f"{field} 不能为空"
if data["metric"] not in METRIC_REGISTRY:
return "指标不存在"
if data["operator"] not in ("gte", "lte"):
return "比较符不合法"
if not isinstance(data.get("threshold"), int):
return "阈值必须是整数"
return None
def _serialize(a):
return {
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"rarity": a.rarity,
"hidden": a.hidden,
"metric": a.metric,
"metric_name": METRIC_REGISTRY[a.metric].name if a.metric in METRIC_REGISTRY else a.metric,
"operator": a.operator,
"threshold": a.threshold,
"visible": a.visible,
# 后台列表必须显示这个:阈值配错时学生永远拿不到也永远不会来问,
# 这个计数器是唯一的仪表盘
"unlock_count": a.unlock_count,
"order": a.order,
"create_time": a.create_time,
}
- Step 3: 写
achievement/urls/admin.py
from django.urls import path
from achievement.views.admin import AchievementAdminAPI, AchievementMetricAdminAPI
urlpatterns = [
path("achievement", AchievementAdminAPI.as_view(), name="achievement_admin_api"),
path("achievement/metrics", AchievementMetricAdminAPI.as_view(), name="achievement_metric_admin_api"),
]
- Step 4: 注册路由
在 oj/urls.py 加:
path("api/admin/", include("achievement.urls.admin")),
- Step 5: 验证补发逻辑
cd OnlineJudge
python manage.py shell -c "
from achievement.models import Achievement, Operator, UserAchievement, UserStat
from achievement.tasks import rescan_achievement
from account.models import User
# 造一个有指标的用户
u = User.objects.filter(is_disabled=False).first()
stat, _ = UserStat.objects.get_or_create(user=u)
stat.metrics['submission_count'] = 100
stat.save()
a = Achievement.objects.create(name='补发测试', description='d', icon='🔁', metric='submission_count', operator=Operator.GTE, threshold=9)
print('before:', UserAchievement.objects.filter(achievement=a).count())
rescan_achievement(a.id) # 直接同步调用 actor 函数体
print('after:', UserAchievement.objects.filter(achievement=a).count())
UserAchievement.objects.filter(achievement=a).delete()
a.delete()
print('cleaned')
"
Expected: before: 0,after: 1,cleaned。
特别验证 JSONB 的字符串序陷阱:把上面的 threshold=9 换成 threshold=9、metrics['submission_count'] = 100。如果 cast 写错,"100" >= "9" 按字符串比较为 False,after 会是 0。看到 after: 1 才说明 cast 正确。
- Step 6: Lint 并提交
cd OnlineJudge
ruff format . && ruff check .
git add achievement/views/admin.py achievement/urls/admin.py achievement/tasks.py oj/urls.py
git commit -m "feat(achievement): 添加管理侧成就 API 与阈值补发"
Task 6: 全量重算管理命令
Files:
- Create:
OnlineJudge/achievement/management/commands/recompute_achievements.py
Interfaces:
-
Consumes:
METRIC_REGISTRY、checker.evaluate/unlock -
Produces:
python manage.py recompute_achievements [--user <id>] [--silent] -
Step 1: 写命令
"""全量重算指标并补发成就。
用途:
1. 系统首次上线,给存量用户补发(用 --silent,否则学生一登录被 30 个奖杯糊脸)
2. 新增指标或改了口径后重算(不带 --silent,新解锁会正常弹出)
这也是增量逻辑的安全网:怀疑 UserStat 漂移时跑一遍即可。
"""
from django.core.management.base import BaseCommand
from account.models import User
from achievement import checker
from achievement.metrics import META_METRICS, METRIC_REGISTRY
from achievement.models import UserStat
class Command(BaseCommand):
help = "重算全部用户的成就指标并补发成就"
def add_arguments(self, parser):
parser.add_argument("--user", type=int, default=None, help="只处理指定用户 id")
parser.add_argument("--silent", action="store_true", help="补发的成就标记为已通知,不弹窗")
def handle(self, *args, **options):
users = User.objects.filter(is_disabled=False)
if options["user"]:
users = users.filter(id=options["user"])
silent = options["silent"]
total_users = users.count()
total_unlocked = 0
for index, user in enumerate(users.iterator(), start=1):
stat, _ = UserStat.objects.get_or_create(user=user)
metrics = {}
for key, m in METRIC_REGISTRY.items():
if key in META_METRICS:
continue
value = m.recompute(user)
# None 表示该指标无有效值,key 必须缺席而不是置 0
if value is not None:
metrics[key] = value
stat.metrics = metrics
stat.save(update_fields=["metrics", "update_time"])
records = checker.unlock(user, checker.evaluate(user, metrics), backfilled=True, notified=silent)
# 元指标第二轮
for key in META_METRICS:
value = METRIC_REGISTRY[key].recompute(user)
if value is not None:
metrics[key] = value
stat.metrics = metrics
stat.save(update_fields=["metrics", "update_time"])
records += checker.unlock(
user, checker.evaluate(user, metrics, only_metrics=META_METRICS), backfilled=True, notified=silent
)
total_unlocked += len(records)
if index % 50 == 0:
self.stdout.write(f"processed {index}/{total_users}")
self.stdout.write(self.style.SUCCESS(f"完成:{total_users} 个用户,新解锁 {total_unlocked} 条"))
注意:重算时丢弃了
_active_dates/_languages等下划线前缀的增量辅助 key。这是有意的——recompute直接算出终值,辅助 key 会在下一次判题的on_submission中自然重建。但active_days、languages_used、max_ac_in_one_day、max_ac_streak_days这四个指标在重算后的第一次判题会从辅助 key 缺失的状态重新累积,可能导致数值短暂偏低。因此重算后应立刻再跑一次重算是无效的;正确做法是重算命令只在低峰期执行,且执行后不依赖增量值的绝对精确性——这些指标的成就阈值判定用的是 max 语义,不会回退。
- Step 2: 单用户试跑
cd OnlineJudge
python manage.py shell -c "
from account.models import User
u = User.objects.filter(is_disabled=False).first()
print('test user id:', u.id, u.username)
"
用上面打印的 id:
python manage.py recompute_achievements --user <上面的id>
Expected: 输出 完成:1 个用户,新解锁 N 条,无 traceback。
- Step 3: 检查重算结果
python manage.py shell -c "
from account.models import User
from achievement.models import UserStat, UserAchievement
u = User.objects.filter(is_disabled=False).first()
s = UserStat.objects.get(user=u)
print('metrics keys:', sorted(s.metrics.keys()))
print('unlocked:', [(r.achievement.name, r.backfilled, r.notified) for r in UserAchievement.objects.filter(user=u).select_related('achievement')])
"
Expected: metrics keys 不含 min_ac_code_chars(若该用户无 AC 记录),解锁记录的 backfilled 为 True。
- Step 4: 验证
--silent
python manage.py shell -c "
from achievement.models import UserAchievement
UserAchievement.objects.all().delete()
print('cleared')
"
python manage.py recompute_achievements --silent
python manage.py shell -c "
from achievement.models import UserAchievement
print('total:', UserAchievement.objects.count())
print('unnotified:', UserAchievement.objects.filter(notified=False).count())
"
Expected: unnotified: 0 —— --silent 下所有补发记录都是已通知状态。
- Step 5: Lint 并提交
cd OnlineJudge
ruff format . && ruff check .
git add achievement/management
git commit -m "feat(achievement): 添加全量重算管理命令"
Task 7: 题单奖章接入通知通道
Files:
- Modify:
OnlineJudge/problemset/views/oj.py:231-247
Interfaces:
-
Consumes:
achievement.notify.notify_badges -
Produces: 无新接口,只修复现有
_check_badges并接通知 -
Step 1: 重写
_check_badges
把 problemset/views/oj.py 的 _check_badges 方法整体替换为:
def _check_badges(self, progress):
"""检查是否获得奖章。
一次查询取出全部已获奖章 id,内存比对,避免逐条 exists()(原来的 N+1)。
用 get_or_create 避免并发重复提交撞 unique constraint 抛异常。
新获得的奖章推送给用户——接入前奖章是静默入库的,学生根本不知道自己拿到了。
"""
badges = list(ProblemSetBadge.objects.filter(problemset=progress.problemset))
if not badges:
return
owned = set(
UserBadge.objects.filter(user=progress.user, badge__in=badges).values_list("badge_id", flat=True)
)
earned = []
for badge in badges:
if badge.id in owned:
continue
if badge.condition_type == BadgeConditionType.ALL_PROBLEMS:
hit = progress.total_problems_count > 0 and progress.completed_problems_count == progress.total_problems_count
elif badge.condition_type == BadgeConditionType.PROBLEM_COUNT:
hit = progress.completed_problems_count >= badge.condition_value
elif badge.condition_type == BadgeConditionType.SCORE:
hit = progress.total_score >= badge.condition_value
else:
hit = False
if hit:
_, created = UserBadge.objects.get_or_create(user=progress.user, badge=badge)
if created:
earned.append(badge)
if earned:
notify_badges(progress.user_id, earned)
- Step 2: 加 import
在 problemset/views/oj.py 的 import 区加:
from achievement.notify import notify_badges
- Step 3: 验证奖章逻辑仍正确且不再 N+1
cd OnlineJudge
python manage.py shell -c "
from django.db import connection, reset_queries
from django.test.utils import CaptureQueriesContext
from problemset.models import ProblemSetBadge, ProblemSetProgress, UserBadge
progress = ProblemSetProgress.objects.filter(problemset__problemsetbadge__isnull=False).first()
if progress is None:
print('no problemset with badges, skip')
else:
badge_count = ProblemSetBadge.objects.filter(problemset=progress.problemset).count()
print('badges in this problemset:', badge_count)
from problemset.views.oj import ProblemSetProgressAPI
api = ProblemSetProgressAPI()
with CaptureQueriesContext(connection) as ctx:
api._check_badges(progress)
print('queries used:', len(ctx))
"
Expected: queries used 是个小常数(约 2–3),不随该题单的奖章数量线性增长。改动前这里会是 1 + badge_count 条。
若
ProblemSetProgressAPI不是_check_badges所在的类名,用grep -n "_check_badges" problemset/views/oj.py找到实际类名替换。
- Step 4: 验证重复调用不抛异常
python manage.py shell -c "
from problemset.models import ProblemSetProgress
from problemset.views.oj import ProblemSetProgressAPI
progress = ProblemSetProgress.objects.filter(problemset__problemsetbadge__isnull=False).first()
if progress:
api = ProblemSetProgressAPI()
api._check_badges(progress)
api._check_badges(progress)
api._check_badges(progress)
print('no IntegrityError')
else:
print('skip')
"
Expected: 打印 no IntegrityError。改动前第二次调用若并发写入会抛 IntegrityError。
- Step 5: Lint 并提交
cd OnlineJudge
ruff format . && ruff check .
git add problemset/views/oj.py
git commit -m "fix(problemset): 奖章判定修复 N+1 与并发冲突并接入通知"
Task 8: 前端 API 层、类型与 WebSocket 分流修复
Files:
- Create:
ojnext/src/oj/achievement/api.ts - Modify:
ojnext/src/utils/types.ts、ojnext/src/oj/problem/composables/useSubmissionMonitor.ts
Interfaces:
-
Consumes: Task 4 的四个用户侧端点
-
Produces:
Achievement、AchievementSummary、PendingAchievement三个 TS 类型getAchievements(name?)、getAchievementSummary(name?)、getPendingAchievements()、markAchievementsRead(ids)
-
Step 1: 加类型到
src/utils/types.ts
追加:
export type AchievementRarity = "bronze" | "silver" | "gold" | "platinum"
export interface Achievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
hidden: boolean
metric: string
operator: "gte" | "lte"
threshold: number
unlocked: boolean
unlock_time: string | null
backfilled: boolean
progress: number | null
unlock_rate: number
}
export interface AchievementRarityStat {
rarity: AchievementRarity
label: string
total: number
unlocked: number
}
export interface PendingAchievement {
id: number
name: string
description: string
icon: string
rarity: AchievementRarity
}
export interface AchievementSummary {
username: string
total: number
unlocked: number
percent: number
rarity: AchievementRarityStat[]
recent: PendingAchievement[]
}
- Step 2: 写
src/oj/achievement/api.ts
import http from "utils/http"
import type {
Achievement,
AchievementSummary,
PendingAchievement,
} from "utils/types"
export function getAchievements(name?: string) {
return http.get<{ username: string; achievements: Achievement[] }>(
"achievements",
{ params: name ? { name } : {} },
)
}
export function getAchievementSummary(name?: string) {
return http.get<AchievementSummary>("achievements/summary", {
params: name ? { name } : {},
})
}
export function getPendingAchievements() {
return http.get<PendingAchievement[]>("achievements/pending")
}
export function markAchievementsRead(ids: number[]) {
return http.post("achievements/pending", { ids })
}
若
utils/http的默认导出不是http或基础路径不含/api,照src/oj/api.ts里现有写法调整——先读一遍src/oj/api.ts的前 20 行,照抄它的 import 与调用风格。
- Step 3: 修复 WebSocket 消息分流
这是必须改的现有文件。utils/websocket.py:push_to_user() 复用了 submission_update 这个 channel layer handler 名(submission/consumers.py 文档中明写不可改名),只把自定义 type 塞进内层 data。SubmissionWebSocket 的泛型是 SubmissionUpdate,成就消息会被当成提交更新去读 submission_id / result。
先看现有 handler:
cd ojnext
grep -n "handleSubmissionUpdate" -A 20 src/oj/problem/composables/useSubmissionMonitor.ts
在 handleSubmissionUpdate 函数体最开头插入类型守卫。成就 store 要到 Task 10 才存在,所以本步骤先只丢弃非提交帧,Task 10 Step 4 再把成就帧分流给 store:
// push_to_user 复用了 submission_update 这个 channel handler,
// 其他类型的消息(如成就通知)会走同一条 WebSocket 帧进来,必须先挡掉
if (data.type !== "submission_update") {
return
}
- Step 4: 验证类型检查与格式化
cd ojnext
npm run build
npm fmt
Expected: 构建成功,无 TypeScript 报错。
- Step 5: 提交
cd ojnext
git add src/utils/types.ts src/oj/achievement/api.ts src/oj/problem/composables/useSubmissionMonitor.ts
git commit -m "feat(achievement): 添加成就 API 层与类型,修复 WebSocket 消息分流"
Task 9: 奖杯馆页面
Files:
- Create:
ojnext/src/oj/achievement/index.vue、ojnext/src/oj/achievement/components/AchievementCard.vue - Modify:
ojnext/src/routes.ts
Interfaces:
-
Consumes: Task 8 的
getAchievements、getAchievementSummary,Task 8 的类型 -
Produces: 路由
/achievement?name=<username> -
Step 1: 写
AchievementCard.vue
<script setup lang="ts">
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
const RARITY_COLOR: Record<string, string> = {
bronze: "#b87333",
silver: "#9fa6b2",
gold: "#e0a300",
platinum: "#7dd3fc",
}
const RARITY_LABEL: Record<string, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
const masked = computed(
() => props.achievement.hidden && !props.achievement.unlocked,
)
// 获得率低于 5% 的加稀有闪光边框
const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
)
const percent = computed(() => {
const { progress, threshold, operator } = props.achievement
if (progress === null || operator === "lte") return 0
if (threshold <= 0) return 100
return Math.min(100, Math.round((progress / threshold) * 100))
})
</script>
<template>
<n-card
size="small"
:class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
>
<div class="row">
<div class="icon">{{ achievement.icon }}</div>
<div class="body">
<div class="title">
<span class="name">{{ achievement.name }}</span>
<n-tag size="tiny" :color="{ borderColor: RARITY_COLOR[achievement.rarity], textColor: RARITY_COLOR[achievement.rarity] }">
{{ RARITY_LABEL[achievement.rarity] }}
</n-tag>
</div>
<div class="desc">{{ achievement.description }}</div>
<div v-if="achievement.unlocked" class="meta">
<span v-if="!achievement.backfilled && achievement.unlock_time">
{{ new Date(achievement.unlock_time).toLocaleDateString() }} 获得
</span>
<span v-else>已获得</span>
<span class="rate">仅 {{ achievement.unlock_rate }}% 的人获得</span>
</div>
<div v-else-if="!masked" class="meta">
<n-progress
type="line"
:percentage="percent"
:height="6"
:show-indicator="false"
/>
<span class="progress-text">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</span>
</div>
<div v-else class="meta">
<span class="rate">仅 {{ achievement.unlock_rate }}% 的人获得</span>
</div>
</div>
</div>
</n-card>
</template>
<style scoped>
.row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.icon {
font-size: 32px;
line-height: 1;
}
.body {
flex: 1;
min-width: 0;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.desc {
margin-top: 4px;
font-size: 13px;
opacity: 0.75;
}
.meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.65;
}
.meta :deep(.n-progress) {
flex: 1;
}
.progress-text {
white-space: nowrap;
}
.locked {
filter: grayscale(1);
opacity: 0.55;
}
.rare {
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
}
</style>
- Step 2: 写
index.vue
<script setup lang="ts">
import {
getAchievements,
getAchievementSummary,
} from "oj/achievement/api"
import { getUserBadges } from "oj/api"
import type { Achievement, AchievementSummary } from "utils/types"
import AchievementCard from "./components/AchievementCard.vue"
const route = useRoute()
const name = computed(() => route.query.name as string | undefined)
const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null)
const badges = ref<any[]>([])
const tab = ref("all")
const loading = ref(true)
const filtered = computed(() => {
if (tab.value === "unlocked")
return achievements.value.filter((a) => a.unlocked)
if (tab.value === "locked")
return achievements.value.filter((a) => !a.unlocked)
return achievements.value
})
async function load() {
loading.value = true
try {
const [list, sum, badgeRes] = await Promise.all([
getAchievements(name.value),
getAchievementSummary(name.value),
getUserBadges(name.value),
])
achievements.value = list.achievements
summary.value = sum
badges.value = badgeRes ?? []
} finally {
loading.value = false
}
}
onMounted(load)
watch(name, load)
</script>
<template>
<div class="hall">
<n-spin :show="loading">
<n-card v-if="summary" class="overview">
<div class="overview-row">
<div class="percent">
<div class="big">{{ summary.percent }}%</div>
<div class="sub">
{{ summary.unlocked }} / {{ summary.total }} 已获得
</div>
</div>
<div class="rarity">
<div v-for="r in summary.rarity" :key="r.rarity" class="rarity-item">
<div class="rarity-label">{{ r.label }}</div>
<div class="rarity-count">{{ r.unlocked }} / {{ r.total }}</div>
</div>
</div>
</div>
</n-card>
<n-tabs v-model:value="tab" type="line" class="tabs">
<n-tab name="all">全部</n-tab>
<n-tab name="unlocked">已获得</n-tab>
<n-tab name="locked">未获得</n-tab>
<n-tab name="badges">题单奖章</n-tab>
</n-tabs>
<div v-if="tab !== 'badges'" class="grid">
<AchievementCard
v-for="a in filtered"
:key="a.id"
:achievement="a"
/>
</div>
<div v-else class="grid">
<n-card v-for="b in badges" :key="b.id" size="small">
<div class="badge-row">
<img v-if="b.badge?.icon" :src="b.badge.icon" class="badge-icon" />
<div>
<div class="badge-name">{{ b.badge?.name }}</div>
<div class="badge-desc">{{ b.badge?.description }}</div>
</div>
</div>
</n-card>
<n-empty v-if="!badges.length" description="还没有获得任何题单奖章" />
</div>
</n-spin>
</div>
</template>
<style scoped>
.hall {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.overview-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.big {
font-size: 40px;
font-weight: 700;
line-height: 1;
}
.sub {
margin-top: 6px;
font-size: 13px;
opacity: 0.7;
}
.rarity {
display: flex;
gap: 20px;
}
.rarity-item {
text-align: center;
}
.rarity-label {
font-size: 12px;
opacity: 0.7;
}
.rarity-count {
font-weight: 600;
margin-top: 2px;
}
.tabs {
margin: 16px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.badge-row {
display: flex;
gap: 12px;
align-items: center;
}
.badge-icon {
width: 40px;
height: 40px;
}
.badge-name {
font-weight: 600;
}
.badge-desc {
font-size: 12px;
opacity: 0.7;
}
</style>
getUserBadges已存在于前端某个 api 文件中(Task 前先grep -rn "getUserBadges" src/确认它的导出位置与参数签名),把上面的 import 路径改成实际路径。
- Step 3: 加路由
在 src/routes.ts 的 ojs 子路由里,user 那一项之后加:
{
path: "achievement",
name: "achievement",
component: () => import("oj/achievement/index.vue"),
meta: { requiresAuth: true },
},
- Step 4: 浏览器验证
cd ojnext
npm start
访问 http://localhost:5173/achievement,逐项确认:
-
顶部显示完成度百分比与四档稀有度计数
-
已解锁成就彩色,显示获得日期与获得率
-
未解锁的公开成就灰度显示且有进度条
-
未解锁的隐藏成就显示
???、描述为「达成条件保密」、图标为❓、没有进度条 -
切到「题单奖章」tab 能看到奖章或空状态
-
访问
/achievement?name=<其他用户名>能看到他人的成就 -
Step 5: 格式化并提交
cd ojnext
npm fmt && npm run build
git add src/oj/achievement src/routes.ts
git commit -m "feat(achievement): 添加奖杯馆页面"
Task 10: 全局解锁弹窗与 pending 拉取
Files:
- Create:
ojnext/src/shared/store/achievement.ts、ojnext/src/shared/components/AchievementToast.vue - Modify:
ojnext/src/shared/layout/default.vue、ojnext/src/oj/problem/composables/useSubmissionMonitor.ts
Interfaces:
-
Consumes:
getPendingAchievements、markAchievementsRead(Task 8),PendingAchievement类型 -
Produces:
useAchievementStore(),含queue、enqueue(items)、fetchPending()、shift() -
Step 1: 写 store
import { defineStore } from "pinia"
import {
getPendingAchievements,
markAchievementsRead,
} from "oj/achievement/api"
import type { PendingAchievement } from "utils/types"
export const useAchievementStore = defineStore("achievement", () => {
const queue = ref<PendingAchievement[]>([])
const current = ref<PendingAchievement | null>(null)
function enqueue(items: PendingAchievement[]) {
// 去重:WebSocket 推来的和 pending 拉来的可能是同一批
const known = new Set([
...queue.value.map((i) => i.id),
...(current.value ? [current.value.id] : []),
])
queue.value.push(...items.filter((i) => !known.has(i.id)))
}
async function fetchPending() {
try {
const items = await getPendingAchievements()
enqueue(items ?? [])
} catch {
// 拉取失败静默处理,下次导航会再拉
}
}
function next() {
current.value = queue.value.shift() ?? null
return current.value
}
async function markRead(id: number) {
try {
await markAchievementsRead([id])
} catch {
// 标记失败下次会重复弹一次,可接受
}
}
return { queue, current, enqueue, fetchPending, next, markRead }
})
- Step 2: 写
AchievementToast.vue
<script setup lang="ts">
import { useAchievementStore } from "shared/store/achievement"
const store = useAchievementStore()
const { current, queue } = storeToRefs(store)
const visible = ref(false)
const RARITY_COLOR: Record<string, string> = {
bronze: "#b87333",
silver: "#9fa6b2",
gold: "#e0a300",
platinum: "#7dd3fc",
}
let timer: ReturnType<typeof setTimeout> | null = null
// 多个同时解锁时排队依次弹出,不重叠堆积
function playNext() {
const item = store.next()
if (!item) return
visible.value = true
timer = setTimeout(async () => {
visible.value = false
await store.markRead(item.id)
setTimeout(playNext, 400)
}, 3000)
}
watch(
() => queue.value.length,
(len) => {
if (len > 0 && !visible.value) playNext()
},
)
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
</script>
<template>
<Transition name="slide">
<div
v-if="visible && current"
class="toast"
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
>
<div class="icon">{{ current.icon }}</div>
<div class="body">
<div class="label">成就解锁</div>
<div class="name">{{ current.name }}</div>
<div class="desc">{{ current.description }}</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 3000;
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-radius: 10px;
border: 2px solid;
background: var(--n-color, rgba(24, 24, 28, 0.95));
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
min-width: 260px;
}
.icon {
font-size: 34px;
}
.label {
font-size: 11px;
letter-spacing: 2px;
opacity: 0.6;
}
.name {
font-weight: 700;
margin-top: 2px;
}
.desc {
font-size: 12px;
opacity: 0.7;
margin-top: 2px;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.35s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
transform: translateX(40px);
}
</style>
- Step 3: 挂到布局层并在路由切换时拉 pending
在 src/shared/layout/default.vue 的 <template> 末尾加:
<AchievementToast />
在 <script setup> 里加:
import AchievementToast from "shared/components/AchievementToast.vue"
import { useAchievementStore } from "shared/store/achievement"
import { useUserStore } from "shared/store/user"
const achievementStore = useAchievementStore()
const userStore = useUserStore()
const currentRoute = useRoute()
// WebSocket 不是常驻连接(只在问题页有提交监听时建连),
// 所以拉取才是主通道:任何页面、任何时刻解锁的成就最终都会弹到
watch(
() => currentRoute.path,
() => {
if (userStore.isAuthed) achievementStore.fetchPending()
},
{ immediate: true },
)
useUserStore的实际导出名与"是否已登录"的字段名以src/shared/store/下的实现为准——先grep -n "isAuthed\|isLogin\|export const useUserStore" src/shared/store/user.ts,用实际的字段名替换userStore.isAuthed。
- Step 4: 补上 WebSocket 分流到 store
回到 src/oj/problem/composables/useSubmissionMonitor.ts,把 Task 8 Step 3 写的类型守卫补全为:
// push_to_user 复用了 submission_update 这个 channel handler,
// 成就通知会走同一条 WebSocket 帧进来,必须先分流出去
if ((data as any).type === "achievement_unlocked") {
useAchievementStore().enqueue((data as any).achievements ?? [])
return
}
if (data.type !== "submission_update") {
return
}
并在文件顶部加 import:
import { useAchievementStore } from "shared/store/achievement"
- Step 5: 端到端验证
后端造一条必然解锁的成就并清掉已读状态:
cd OnlineJudge
python manage.py shell -c "
from achievement.models import Achievement, Operator, UserAchievement
Achievement.objects.create(name='弹窗测试', description='提交一次代码', icon='🎉', metric='submission_count', operator=Operator.GTE, threshold=1, order=99)
UserAchievement.objects.update(notified=False)
print('ready')
"
前端刷新任意页面。Expected:右下角滑入奖杯弹窗,3 秒后淡出;若有多条待弹,依次弹出不重叠。
再验证不重复弹:刷新页面,Expected:不再弹出(已标记 notified=True)。
再验证判题当场即时弹:在问题页提交一次代码,Expected:判题结果出来后不到一秒弹出奖杯(走 WebSocket 而非等下次导航)。
清理测试数据:
python manage.py shell -c "
from achievement.models import Achievement, UserAchievement
a = Achievement.objects.filter(name='弹窗测试')
UserAchievement.objects.filter(achievement__in=a).delete()
a.delete()
print('cleaned')
"
- Step 6: 格式化并提交
cd ojnext
npm fmt && npm run build
git add src/shared/store/achievement.ts src/shared/components/AchievementToast.vue src/shared/layout/default.vue src/oj/problem/composables/useSubmissionMonitor.ts
git commit -m "feat(achievement): 添加全局解锁弹窗与待弹拉取"
Task 11: 炫耀入口(个人主页摘要与排行榜徽章)
Files:
- Modify:
ojnext/src/oj/user/index.vue、ojnext/src/oj/rank/list.vue
Interfaces:
-
Consumes:
getAchievementSummary(Task 8) -
Step 1: 个人主页加成就摘要区
在 src/oj/user/index.vue 的 <script setup> 加:
import { getAchievementSummary } from "oj/achievement/api"
import type { AchievementSummary } from "utils/types"
const achievementSummary = ref<AchievementSummary | null>(null)
async function loadAchievementSummary() {
try {
achievementSummary.value = await getAchievementSummary(
route.query.name as string | undefined,
)
} catch {
achievementSummary.value = null
}
}
把 loadAchievementSummary() 加进该页面现有的数据加载流程(文件里已有一个 promises 数组,把它 push 进去)。
在模板中合适位置(个人信息卡片之后)插入:
<n-card v-if="achievementSummary" size="small" class="achievement-summary">
<div class="achievement-head">
<span class="achievement-title">
成就 {{ achievementSummary.unlocked }} / {{ achievementSummary.total }}
({{ achievementSummary.percent }}%)
</span>
<n-button
text
type="primary"
@click="
router.push({
path: '/achievement',
query: route.query.name ? { name: route.query.name } : {},
})
"
>
查看全部
</n-button>
</div>
<div class="achievement-recent">
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
<template #trigger>
<span class="achievement-icon">{{ a.icon }}</span>
</template>
{{ a.name }}
</n-tooltip>
<n-text v-if="!achievementSummary.recent.length" depth="3">
还没有获得成就
</n-text>
</div>
</n-card>
样式:
.achievement-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.achievement-title {
font-weight: 600;
}
.achievement-recent {
margin-top: 10px;
display: flex;
gap: 10px;
align-items: center;
}
.achievement-icon {
font-size: 24px;
cursor: default;
}
- Step 2: 排行榜挂徽章
后端在排行榜序列化里加成就信息成本较高,改用前端按需拉取:排行榜行数有限(一页几十行),页面加载后对可见用户批量取摘要会产生 N 次请求,不可接受。
因此本步骤改为:在排行榜的用户名后加一个跳转奖杯馆的小图标,不显示徽章内容。 这样零额外请求,仍然提供了炫耀入口。
在 src/oj/rank/list.vue 渲染用户名的列里,在用户名后追加一个按钮:
<n-button
text
size="tiny"
title="查看成就"
@click.stop="router.push({ path: '/achievement', query: { name: row.user.username } })"
>
🏆
</n-button>
该文件用的是 Naive UI DataTable 的 render 函数还是模板,取决于现有实现。先
grep -n "username" src/oj/rank/list.vue确认渲染方式:若是 render 函数,用h(NButton, { text: true, size: "tiny", onClick: ... }, () => "🏆")等价改写。
-
Step 3: 浏览器验证
-
访问
/user,Expected:看到「成就 N / M(X%)」摘要与最近获得的图标,点「查看全部」跳转/achievement -
访问
/user?name=<他人>,Expected:显示他人的成就摘要,「查看全部」跳转带上name参数 -
访问
/rank,Expected:每行用户名后有 🏆 按钮,点击跳到该用户的奖杯馆 -
Step 4: 格式化并提交
cd ojnext
npm fmt && npm run build
git add src/oj/user/index.vue src/oj/rank/list.vue
git commit -m "feat(achievement): 个人主页与排行榜添加成就入口"
Task 12: 管理后台成就配置页
Files:
- Create:
ojnext/src/admin/achievement/list.vue、ojnext/src/admin/achievement/components/AchievementModal.vue、ojnext/src/admin/achievement/api.ts - Modify:
ojnext/src/routes.ts
Interfaces:
-
Consumes: Task 5 的管理端点
-
Step 1: 写
src/admin/achievement/api.ts
import http from "utils/http"
export interface AdminAchievement {
id: number
name: string
description: string
icon: string
rarity: string
hidden: boolean
metric: string
metric_name: string
operator: "gte" | "lte"
threshold: number
visible: boolean
unlock_count: number
order: number
create_time: string
}
export interface MetricOption {
key: string
name: string
help_text: string
}
export function getAdminAchievements() {
return http.get<AdminAchievement[]>("admin/achievement")
}
export function getMetricOptions() {
return http.get<MetricOption[]>("admin/achievement/metrics")
}
export function createAchievement(data: Partial<AdminAchievement>) {
return http.post<AdminAchievement>("admin/achievement", data)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return http.put<AdminAchievement>("admin/achievement", data)
}
export function deleteAchievement(id: number) {
return http.delete("admin/achievement", { params: { id } })
}
路径前缀以
src/admin/下现有 api 文件的写法为准(grep -n "admin/problemset" src/admin/problemset/*.ts看它怎么拼路径),照抄。
- Step 2: 写
AchievementModal.vue
<script setup lang="ts">
import {
createAchievement,
getMetricOptions,
updateAchievement,
type AdminAchievement,
type MetricOption,
} from "admin/achievement/api"
const props = defineProps<{
show: boolean
editing: AdminAchievement | null
}>()
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
const message = useMessage()
const metrics = ref<MetricOption[]>([])
const saving = ref(false)
const form = ref({
name: "",
description: "",
icon: "🏆",
rarity: "bronze",
hidden: false,
metric: "",
operator: "gte" as "gte" | "lte",
threshold: 1,
visible: true,
order: 0,
})
const metricOptions = computed(() =>
metrics.value.map((m) => ({ label: `${m.name}(${m.key})`, value: m.key })),
)
const metricHelp = computed(
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
)
watch(
() => props.show,
async (show) => {
if (!show) return
if (!metrics.value.length) metrics.value = await getMetricOptions()
if (props.editing) {
form.value = { ...form.value, ...props.editing }
} else {
form.value = {
name: "",
description: "",
icon: "🏆",
rarity: "bronze",
hidden: false,
metric: metrics.value[0]?.key ?? "",
operator: "gte",
threshold: 1,
visible: true,
order: 0,
}
}
},
)
async function save() {
if (!form.value.name || !form.value.metric) {
message.error("名称和指标不能为空")
return
}
saving.value = true
try {
if (props.editing) {
await updateAchievement({ ...form.value, id: props.editing.id })
} else {
await createAchievement(form.value)
}
message.success("保存成功")
emit("update:show", false)
emit("saved")
} finally {
saving.value = false
}
}
</script>
<template>
<n-modal
:show="show"
preset="card"
style="width: 560px"
:title="editing ? '编辑成就' : '新建成就'"
@update:show="emit('update:show', $event)"
>
<n-form label-placement="left" :label-width="80">
<n-form-item label="名称" required>
<n-input v-model:value="form.name" placeholder="成就名称" />
</n-form-item>
<n-form-item label="描述" required>
<n-input
v-model:value="form.description"
type="textarea"
placeholder="达成条件的描述,展示给学生看"
/>
</n-form-item>
<n-form-item label="图标">
<n-input v-model:value="form.icon" placeholder="emoji,例如 🦉" />
</n-form-item>
<n-form-item label="稀有度">
<n-select
v-model:value="form.rarity"
:options="[
{ label: '青铜', value: 'bronze' },
{ label: '白银', value: 'silver' },
{ label: '黄金', value: 'gold' },
{ label: '白金', value: 'platinum' },
]"
/>
</n-form-item>
<n-form-item label="指标" required>
<n-select
v-model:value="form.metric"
:options="metricOptions"
filterable
/>
</n-form-item>
<n-form-item v-if="metricHelp" label=" ">
<n-text depth="3">{{ metricHelp }}</n-text>
</n-form-item>
<n-form-item label="条件">
<n-space align="center">
<n-select
v-model:value="form.operator"
style="width: 130px"
:options="[
{ label: '大于等于', value: 'gte' },
{ label: '小于等于', value: 'lte' },
]"
/>
<n-input-number v-model:value="form.threshold" :min="0" />
</n-space>
</n-form-item>
<n-form-item label="隐藏成就">
<n-switch v-model:value="form.hidden" />
<n-text depth="3" style="margin-left: 12px">
未解锁时学生只能看到 ???
</n-text>
</n-form-item>
<n-form-item label="上架">
<n-switch v-model:value="form.visible" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="form.order" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
</n-space>
</template>
</n-modal>
</template>
- Step 3: 写
list.vue
<script setup lang="ts">
import {
deleteAchievement,
getAdminAchievements,
type AdminAchievement,
} from "admin/achievement/api"
import AchievementModal from "./components/AchievementModal.vue"
const message = useMessage()
const dialog = useDialog()
const list = ref<AdminAchievement[]>([])
const loading = ref(false)
const showModal = ref(false)
const editing = ref<AdminAchievement | null>(null)
async function load() {
loading.value = true
try {
list.value = await getAdminAchievements()
} finally {
loading.value = false
}
}
function create() {
editing.value = null
showModal.value = true
}
function edit(row: AdminAchievement) {
editing.value = row
showModal.value = true
}
function remove(row: AdminAchievement) {
dialog.warning({
title: "删除成就",
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteAchievement(row.id)
message.success("已删除")
load()
},
})
}
onMounted(load)
</script>
<template>
<n-card title="成就管理">
<template #header-extra>
<n-button type="primary" @click="create">新建成就</n-button>
</template>
<n-alert type="info" style="margin-bottom: 12px">
「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。
</n-alert>
<n-data-table
:loading="loading"
:data="list"
:columns="[
{ title: '图标', key: 'icon', width: 60 },
{ title: '名称', key: 'name' },
{ title: '稀有度', key: 'rarity', width: 90 },
{ title: '指标', key: 'metric_name' },
{
title: '条件',
key: 'threshold',
width: 110,
render: (row) =>
`${row.operator === 'gte' ? '≥' : '≤'} ${row.threshold}`,
},
{
title: '隐藏',
key: 'hidden',
width: 70,
render: (row) => (row.hidden ? '是' : '—'),
},
{
title: '上架',
key: 'visible',
width: 70,
render: (row) => (row.visible ? '是' : '否'),
},
{ title: '已解锁人数', key: 'unlock_count', width: 110 },
{
title: '操作',
key: 'actions',
width: 130,
render: (row) =>
h(NSpace, {}, () => [
h(NButton, { text: true, type: 'primary', onClick: () => edit(row) }, () => '编辑'),
h(NButton, { text: true, type: 'error', onClick: () => remove(row) }, () => '删除'),
]),
},
]"
:row-key="(row) => row.id"
/>
<AchievementModal
v-model:show="showModal"
:editing="editing"
@saved="load"
/>
</n-card>
</template>
NSpace/NButton/h在 render 函数里使用时,Naive UI 组件由unplugin-vue-components自动导入只对模板生效,render 函数里需要显式import { NButton, NSpace } from "naive-ui"。h由 Vue 自动导入。请在<script setup>顶部加这行 import。
- Step 4: 加管理后台路由
在 src/routes.ts 的 admins 子路由里,参照 problemset/list 那几项的写法加:
{
path: "achievement/list",
name: "admin achievement list",
component: () => import("admin/achievement/list.vue"),
meta: { requiresSuperAdmin: true },
},
meta的实际字段以同文件里 problemset 管理路由的写法为准,照抄它的 meta。若管理后台有侧边栏菜单配置文件(grep -rn "problemset/list" src/shared/layout/admin.vue src/admin/),把成就管理也加进菜单。
- Step 5: 浏览器验证
用超级管理员账号访问管理后台的成就管理页,逐项确认:
-
列表显示图标、名称、稀有度、指标中文名、条件、隐藏、上架、已解锁人数
-
点「新建成就」,指标下拉框能看到 16 个指标,选中后下方显示该指标的说明文字
-
保存后列表刷新出新成就
-
编辑一条成就把阈值调低,保存后等几秒,回到
/achievement页面确认该成就被补发(这验证了 Task 5 的rescan_achievement) -
删除成就有二次确认
-
Step 6: 格式化并提交
cd ojnext
npm fmt && npm run build
git add src/admin/achievement src/routes.ts
git commit -m "feat(achievement): 添加管理后台成就配置页"
Task 13: 上线补发与收尾
Files: 无代码改动,纯运维步骤 + 一条一次性提示
- Step 1: 配置首批成就
用管理后台配出首批成就。建议每个指标至少配一条,累积型设 hidden=False、隐藏型设 hidden=True。参考配置(可直接在后台照着填):
| 名称 | 图标 | 指标 | 条件 | 稀有度 | 隐藏 |
|---|---|---|---|---|---|
| 初出茅庐 | 🌱 | accepted_count | ≥ 1 | 青铜 | 否 |
| 小有所成 | 📗 | accepted_count | ≥ 10 | 青铜 | 否 |
| 百题斩 | 📚 | accepted_count | ≥ 100 | 黄金 | 否 |
| 坚持三天 | 📅 | max_ac_streak_days | ≥ 3 | 青铜 | 否 |
| 坚持一周 | 🗓️ | max_ac_streak_days | ≥ 7 | 白银 | 否 |
| 博采众长 | 🌐 | languages_used | ≥ 3 | 白银 | 否 |
| 题单收藏家 | 🎖️ | badge_count | ≥ 10 | 黄金 | 否 |
| 一发入魂 | 🎯 | first_try_ac_count | ≥ 1 | 青铜 | 是 |
| 神射手 | 🏹 | first_try_ac_count | ≥ 20 | 黄金 | 是 |
| 夜猫子 | 🦉 | midnight_submissions | ≥ 10 | 白银 | 是 |
| 作息已崩坏 | 🌙 | midnight_submissions | ≥ 100 | 黄金 | 是 |
| 与编译器搏斗 | 💥 | compile_error_count | ≥ 50 | 青铜 | 是 |
| 屡败屡战 | 🔥 | max_wa_before_ac | ≥ 20 | 白银 | 是 |
| 高产日 | ⚡ | max_ac_in_one_day | ≥ 10 | 白银 | 是 |
| 极简主义 | ✂️ | min_ac_code_chars | ≤ 50 | 黄金 | 是 |
| 洋洋洒洒 | 📜 | max_code_lines | ≥ 300 | 青铜 | 是 |
| 奖杯收藏家 | 💎 | achievement_unlocked_count | ≥ 12 | 白金 | 否 |
- Step 2: 低峰期执行补发
cd OnlineJudge
python manage.py recompute_achievements --silent
Expected: 输出 完成:N 个用户,新解锁 M 条。必须带 --silent —— 否则学生下次登录会被几十个奖杯连续糊脸。
- Step 3: 抽查补发结果
python manage.py shell -c "
from achievement.models import Achievement, UserAchievement
print('unnotified (应为 0):', UserAchievement.objects.filter(notified=False).count())
for a in Achievement.objects.order_by('-unlock_count')[:5]:
print(f'{a.name}: {a.unlock_count} 人')
for a in Achievement.objects.filter(unlock_count=0):
print(f'!! 零解锁: {a.name} ({a.metric} {a.operator} {a.threshold})')
"
Expected: unnotified 为 0;零解锁的成就逐条核对是不是阈值配错了(隐藏型高门槛成就零解锁是正常的)。
- Step 4: 加上线提示
在 ojnext/src/oj/user/index.vue 的成就摘要卡片里,当 achievementSummary.unlocked > 0 时显示一次性提示。用 localStorage 记住已关闭:
const showLaunchTip = ref(
localStorage.getItem("achievement_launch_tip_dismissed") !== "1",
)
function dismissLaunchTip() {
showLaunchTip.value = false
localStorage.setItem("achievement_launch_tip_dismissed", "1")
}
<n-alert
v-if="showLaunchTip && achievementSummary.unlocked > 0"
type="success"
closable
style="margin-bottom: 10px"
@close="dismissLaunchTip"
>
成就系统上线了,你已解锁 {{ achievementSummary.unlocked }} 个成就
</n-alert>
- Step 5: 提交前端改动
cd ojnext
npm fmt && npm run build
git add src/oj/user/index.vue
git commit -m "feat(achievement): 添加成就系统上线提示"
- Step 6: 全链路回归
在浏览器里完整走一遍:
- 用学生账号提交一道未做过的题并 AC → Expected: 判题结果出来后奖杯弹窗出现
- 打开
/achievement→ Expected: 该成就已点亮,显示获得日期与获得率 - 打开
/achievement?name=<另一个学生>→ Expected: 看到他人成就,隐藏且他未解锁的仍显示??? - 在比赛中提交并 AC → Expected:
accepted_count不增加(比赛题不计入)
比赛不计入的验证:
cd OnlineJudge
python manage.py shell -c "
from account.models import User
from achievement.models import UserStat
u = User.objects.get(username='<测试学生用户名>')
print('accepted_count:', UserStat.objects.get(user=u).metrics.get('accepted_count'))
"
比赛提交前后跑两次,Expected: 数值不变。
Self-Review
Spec 覆盖检查:
| Spec 章节 | 对应任务 |
|---|---|
| 数据模型(三张表) | Task 1 |
| 指标注册表 + 16 个指标 | Task 2 |
| 极小值型初始值陷阱 | Task 2 Step 7 验证、Task 1 模型注释、Task 3 evaluate |
| 比赛提交不计入 | Task 2 _practice_submissions / build_ctx、Task 13 Step 6 验证 |
| 元指标与两轮判定 | Task 2 Step 5、Task 3 run_for_submission |
| 判定流程 + 投递点 | Task 3 |
| 解锁通知推拉结合 | Task 3(notify)、Task 4(pending 端点)、Task 10(前端) |
useSubmissionMonitor 分流 |
Task 8 Step 3、Task 10 Step 4 |
| 前端奖杯馆三种视觉状态 | Task 9 |
| 获得率分母缓存 | Task 4 Step 1 get_active_user_count |
| 炫耀入口 | Task 11 |
| 管理后台 + unlock_count 仪表盘 | Task 5、Task 12 |
| 历史补发 + backfilled + --silent | Task 6、Task 13 |
| 阈值下调补发 + JSONB cast 陷阱 | Task 5 Step 1、Step 5 |
| 题单奖章分层共存 + 修 N+1 | Task 7、Task 9(奖章 tab) |
已知偏离 spec 之处(有意,已在任务内说明):
POST /api/achievements/pending/read改为POST /api/achievements/pending—— 复用同一个视图类的post方法,少一个路由。Task 4 Step 3 已注明,前端 Task 8 按实际路径实现。- 排行榜不显示徽章内容,只放一个跳转按钮 —— spec 原写「每行挂最稀有的 1–2 枚徽章」,但那需要为每行拉一次摘要(N 次请求)或改后端排行榜序列化器。Task 11 Step 2 已说明改为零额外请求的入口按钮。若后续确实需要徽章内容,应单独开一个任务改后端排行榜接口批量返回。
Task 2 的已知取舍: recompute 会丢弃 _active_dates、_languages、_ac_per_day 等下划线前缀的增量辅助 key,重算后这几个指标的辅助状态从零重建。因为相关指标(active_days、languages_used、max_ac_in_one_day、max_ac_streak_days)都是 max/count 语义且 recompute 直接给出终值,成就判定不会回退。Task 6 Step 1 的注释已记录这一点。