Files
OnlineJudge/docs/superpowers/plans/2026-08-03-achievement-system.md
yuetsh 73789bda0c fix(plan): 修正元指标回写的丢失更新缺陷
第二轮 stat.save() 在锁外整体写回解锁前的快照,
并发判题时会覆盖掉另一条链路刚提交的指标增量。
改为重新取锁、重读、只合并元指标的 key。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:07:21 -06:00

3152 lines
104 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 成就系统实施计划
> **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: 创建包结构**
```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 <id> 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:005: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: User, submission) -> list[UserAchievement]` — 完整两轮流程(传 User 实例,不是 id
- `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返回实际新建的记录。
刻意逐条 get_or_create 而不是 bulk_createunique_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`**
```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=<username>`
- `GET /api/achievements/summary?name=<username>`
- `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=<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`**
```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 <id>] [--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` 是个小常数(约 23**不随该题单的奖章数量线性增长**。改动前这里会是 `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<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
```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=<username>`
- [ ] **Step 1: 写 `AchievementCard.vue`**
```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`**
```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` 那一项之后加:
```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<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`**
```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>` 末尾加:
```vue
<AchievementToast />
```
`<script setup>` 里加:
```typescript
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 写的类型守卫补全为:
```typescript
// 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
```typescript
import { useAchievementStore } from "shared/store/achievement"
```
- [ ] **Step 5: 端到端验证**
后端造一条必然解锁的成就并清掉已读状态:
```bash
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 而非等下次导航)。
清理测试数据:
```bash
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: 格式化并提交**
```bash
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>` 加:
```typescript
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 进去)。
在模板中合适位置(个人信息卡片之后)插入:
```vue
<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>
```
样式:
```css
.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` 渲染用户名的列里,在用户名后追加一个按钮:
```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 / MX%)」摘要与最近获得的图标,点「查看全部」跳转 `/achievement`
- 访问 `/user?name=<他人>`Expected显示他人的成就摘要「查看全部」跳转带上 `name` 参数
- 访问 `/rank`Expected每行用户名后有 🏆 按钮,点击跳到该用户的奖杯馆
- [ ] **Step 4: 格式化并提交**
```bash
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`**
```typescript
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`**
```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`**
```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` 那几项的写法加:
```typescript
{
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: 格式化并提交**
```bash
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: 低峰期执行补发**
```bash
cd OnlineJudge
python manage.py recompute_achievements --silent
```
Expected: 输出 `完成N 个用户,新解锁 M 条`。**必须带 `--silent`** —— 否则学生下次登录会被几十个奖杯连续糊脸。
- [ ] **Step 3: 抽查补发结果**
```bash
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` 记住已关闭:
```typescript
const showLaunchTip = ref(
localStorage.getItem("achievement_launch_tip_dismissed") !== "1",
)
function dismissLaunchTip() {
showLaunchTip.value = false
localStorage.setItem("achievement_launch_tip_dismissed", "1")
}
```
```vue
<n-alert
v-if="showLaunchTip && achievementSummary.unlocked > 0"
type="success"
closable
style="margin-bottom: 10px"
@close="dismissLaunchTip"
>
成就系统上线了你已解锁 {{ achievementSummary.unlocked }} 个成就
</n-alert>
```
- [ ] **Step 5: 提交前端改动**
```bash
cd ojnext
npm fmt && npm run build
git add src/oj/user/index.vue
git commit -m "feat(achievement): 添加成就系统上线提示"
```
- [ ] **Step 6: 全链路回归**
在浏览器里完整走一遍:
1. 用学生账号提交一道**未做过**的题并 AC → Expected: 判题结果出来后奖杯弹窗出现
2. 打开 `/achievement` → Expected: 该成就已点亮,显示获得日期与获得率
3. 打开 `/achievement?name=<另一个学生>` → Expected: 看到他人成就,隐藏且他未解锁的仍显示 `???`
4. 在**比赛中**提交并 AC → Expected: `accepted_count` 不增加(比赛题不计入)
比赛不计入的验证:
```bash
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 3notify、Task 4pending 端点、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 之处(有意,已在任务内说明):**
1. **`POST /api/achievements/pending/read` 改为 `POST /api/achievements/pending`** —— 复用同一个视图类的 `post` 方法少一个路由。Task 4 Step 3 已注明,前端 Task 8 按实际路径实现。
2. **排行榜不显示徽章内容,只放一个跳转按钮** —— spec 原写「每行挂最稀有的 12 枚徽章」但那需要为每行拉一次摘要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 的注释已记录这一点。