diff --git a/docs/superpowers/plans/2026-08-03-achievement-system.md b/docs/superpowers/plans/2026-08-03-achievement-system.md new file mode 100644 index 0000000..3dd8aab --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-achievement-system.md @@ -0,0 +1,3136 @@ +# 成就系统实施计划 + +> **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`。 +- 成就为纯荣誉,**不发放任何可消费奖励**,不接入积分/道具/权限体系。 +- 判定任务内所有异常必须捕获并记日志,绝不允许影响判题结果。 +- 指标未产生过有效值时,其 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: 创建包结构** + +```bash +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 再补上注册钩子。 + +```python +from django.apps import AppConfig + + +class AchievementConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "achievement" +``` + +- [ ] **Step 3: 写 `achievement/models.py`** + +```python +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",` 之后)加一行: + +```python + "achievement", +``` + +- [ ] **Step 5: 生成并应用迁移** + +```bash +cd OnlineJudge +python manage.py makemigrations achievement +python manage.py migrate achievement +``` + +Expected: 输出 `Create model Achievement / UserStat / UserAchievement`,migrate 成功无报错。 + +- [ ] **Step 6: 验证模型可用** + +```bash +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 bronze True 0` 与 `ok`。 + +- [ ] **Step 7: Lint 并提交** + +```bash +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` — 原地修改 `metrics` + - `BaseMetric.recompute(self, user) -> int | None` — 返回 `None` 表示该用户此指标无有效值(调用方应删除该 key) + - `build_ctx(user_id: int, submission) -> dict` — 判题后预查一次的共享上下文 + - `META_METRICS: set[str]` — 元指标 key 集合(第二轮判定用) + +- [ ] **Step 1: 写指标基类与注册表** + +创建 `achievement/metrics.py`,先写骨架: + +```python +"""成就指标注册表。 + +这里定义"能测量什么",后台定义"多少算达成"。管理员在后台看到的指标下拉框 +就是 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`: + +```python +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`: + +```python +@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`: + +```python +@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`: + +```python +@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` 改成: + +```python +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: 验证注册表与全量重算** + +```bash +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'}`。 + +拿一个真实用户跑一遍全量重算,确认每个指标都能算出值且不抛异常: + +```bash +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 并提交** + +```bash +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_count` + - `checker.run_for_submission(user_id: int, submission) -> list[UserAchievement]` — 完整两轮流程 + - `notify.notify_achievements(user_id: int, records: list[UserAchievement]) -> None` + - `notify.notify_badges(user_id: int, badges: list) -> None` + - `tasks.check_achievements.send(user_id, submission_id)` + +- [ ] **Step 1: 写 `achievement/checker.py`** + +```python +"""成就判定核心,被异步任务和管理命令共用。 + +判定刻意做成"一次查询取全部候选 + 内存比对",与 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,返回实际新建的记录。""" + if not achievements: + return [] + records = [ + UserAchievement(user=user, achievement=a, backfilled=backfilled, notified=notified) + for a in achievements + ] + with transaction.atomic(): + UserAchievement.objects.bulk_create(records, ignore_conflicts=True) + created = list( + UserAchievement.objects.filter(user=user, achievement__in=achievements).select_related("achievement") + ) + Achievement.objects.filter(id__in=[a.id for a in achievements]).update(unlock_count=F("unlock_count") + 1) + 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 [] + + # 第二轮:只重算元指标、只判定依赖元指标的成就,不再有第三轮 + for key in META_METRICS: + value = METRIC_REGISTRY[key].recompute(user) + 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`** + +```python +"""解锁通知。 + +通知走推拉结合,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`** + +```python +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` 两条链路。 + +改成: + +```python +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: 手动验证判定流程** + +先造一个必然达成的成就,再拿一条已有的练习提交跑一遍: + +```bash +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`。 + +再验证比赛提交被跳过: + +```bash +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 并提交** + +```bash +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=` + - `GET /api/achievements/summary?name=` + - `GET /api/achievements/pending` + - `POST /api/achievements/pending/read`,body `{"ids": [1, 2]}` + +- [ ] **Step 1: 写 `achievement/serializers.py`** + +隐藏成就的打码必须在序列化层完成——未解锁的隐藏成就绝不能把真实 `name`/`description` 下发到前端,否则打开 DevTools 就能看光。 + +```python +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`** + +```python +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= 指定他人,不传则为自己。与 /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`** + +```python +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` 那行之后)加: + +```python + path("api/", include("achievement.urls.oj")), +``` + +- [ ] **Step 5: 验证端点** + +启动开发服务器: + +```bash +cd OnlineJudge +python dev.py +``` + +另开一个终端,先造两条成就(一条普通、一条隐藏),然后用浏览器登录后访问接口。取一个已登录会话的 cookie 最简单的办法是直接在浏览器地址栏打开: + +``` +http://localhost:8000/api/achievements +http://localhost:8000/api/achievements/summary +http://localhost:8000/api/achievements/pending +``` + +造数据: + +```bash +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 并提交** + +```bash +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/achievement` + - `GET /api/admin/achievement/metrics` + - `tasks.rescan_achievement.send(achievement_id)` + +- [ ] **Step 1: 加补发任务到 `achievement/tasks.py`** + +追加: + +```python +@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`** + +```python +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`** + +```python +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` 加: + +```python + path("api/admin/", include("achievement.urls.admin")), +``` + +- [ ] **Step 5: 验证补发逻辑** + +```bash +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 并提交** + +```bash +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 ] [--silent]` + +- [ ] **Step 1: 写命令** + +```python +"""全量重算指标并补发成就。 + +用途: +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: 单用户试跑** + +```bash +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: + +```bash +python manage.py recompute_achievements --user <上面的id> +``` + +Expected: 输出 `完成:1 个用户,新解锁 N 条`,无 traceback。 + +- [ ] **Step 3: 检查重算结果** + +```bash +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`** + +```bash +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 并提交** + +```bash +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` 方法整体替换为: + +```python + 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 区加: + +```python +from achievement.notify import notify_badges +``` + +- [ ] **Step 3: 验证奖章逻辑仍正确且不再 N+1** + +```bash +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: 验证重复调用不抛异常** + +```bash +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 并提交** + +```bash +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`** + +追加: + +```typescript +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`** + +```typescript +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("achievements/summary", { + params: name ? { name } : {}, + }) +} + +export function getPendingAchievements() { + return http.get("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: + +```bash +cd ojnext +grep -n "handleSubmissionUpdate" -A 20 src/oj/problem/composables/useSubmissionMonitor.ts +``` + +在 `handleSubmissionUpdate` 函数体**最开头**插入类型守卫。成就 store 要到 Task 10 才存在,所以本步骤先只丢弃非提交帧,Task 10 Step 4 再把成就帧分流给 store: + +```typescript + // push_to_user 复用了 submission_update 这个 channel handler, + // 其他类型的消息(如成就通知)会走同一条 WebSocket 帧进来,必须先挡掉 + if (data.type !== "submission_update") { + return + } +``` + +- [ ] **Step 4: 验证类型检查与格式化** + +```bash +cd ojnext +npm run build +npm fmt +``` + +Expected: 构建成功,无 TypeScript 报错。 + +- [ ] **Step 5: 提交** + +```bash +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=` + +- [ ] **Step 1: 写 `AchievementCard.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: 写 `index.vue`** + +```vue + + + + + +``` + +> `getUserBadges` 已存在于前端某个 api 文件中(Task 前先 `grep -rn "getUserBadges" src/` 确认它的导出位置与参数签名),把上面的 import 路径改成实际路径。 + +- [ ] **Step 3: 加路由** + +在 `src/routes.ts` 的 `ojs` 子路由里,`user` 那一项之后加: + +```typescript + { + path: "achievement", + name: "achievement", + component: () => import("oj/achievement/index.vue"), + meta: { requiresAuth: true }, + }, +``` + +- [ ] **Step 4: 浏览器验证** + +```bash +cd ojnext +npm start +``` + +访问 `http://localhost:5173/achievement`,逐项确认: + +- 顶部显示完成度百分比与四档稀有度计数 +- 已解锁成就彩色,显示获得日期与获得率 +- 未解锁的公开成就**灰度显示**且有进度条 +- 未解锁的隐藏成就显示 `???`、描述为「达成条件保密」、图标为 `❓`、**没有进度条** +- 切到「题单奖章」tab 能看到奖章或空状态 +- 访问 `/achievement?name=<其他用户名>` 能看到他人的成就 + +- [ ] **Step 5: 格式化并提交** + +```bash +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** + +```typescript +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([]) + const current = ref(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`** + +```vue + + + + + +``` + +- [ ] **Step 3: 挂到布局层并在路由切换时拉 pending** + +在 `src/shared/layout/default.vue` 的 `