docs: 成就系统上线与运维文档

部署步骤、日常运维(加成就/加指标/定期检查)、
出问题的处置、已知未验证部分与遗留问题。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 07:30:51 -06:00
parent 8ada9f72ed
commit f95c3a8bae

View File

@@ -0,0 +1,137 @@
# 成就系统上线与运维
首次上线日期:待填(部署当天补)
设计文档:`docs/superpowers/specs/2026-08-03-achievement-system-design.md`
实施计划:`docs/superpowers/plans/2026-08-03-achievement-system.md`
## 这套系统在做什么
学生刷题时后台异步累积一批**指标**AC 题数、连续 AC 天数、凌晨提交次数……),管理员在后台把「指标 + 比较符 + 阈值」组合成**成就**。判题完成后投递一个 dramatiq 任务重算该用户的指标并判定解锁,解锁后弹奖杯。
关键分工,配置时必须理解:
| | 定义在哪 | 改动成本 |
|---|---|---|
| **能测量什么**(指标) | 代码 `achievement/metrics.py` | 改代码 + 部署 + 跑重算 |
| **多少算达成、叫什么、藏不藏** | 管理后台表单 | 纯配置,即时生效 |
所以「凌晨提交 10 次 → 夜猫子」和「凌晨提交 100 次 → 作息已崩坏」是两条成就、同一个指标,纯后台加即可。「代码里写了 goto」则是新维度必须先改代码。
## 部署
`deploy/entrypoint.sh` 会自动 `migrate --no-input``deploy/supervisord.conf` 的 dramatiq 随容器重启。**正常重新部署即可完成迁移和 worker 加载,不需要手动做这两步**,但必须确认它们成功了。
前后端尽量一起发:前端在每次路由切换时请求 `/api/achievements/pending`,后端没上会在日志里刷一片 404失败是静默吞掉的不会白屏
### 第一步 确认基础设施
```bash
python manage.py showmigrations achievement # 应为 [X] 0001_initial
grep -c "check_achievements" /data/log/dramatiq.log # 应 > 0
```
**worker 日志里没有 `check_achievements` 这个 actor说明它没加载新代码——成就永远不会判定而且不报任何错。** 这是最容易被忽略的失败模式。
### 第二步 跑自检
```bash
python manage.py check_achievement_deploy
```
只读,可反复跑,七项检查。此刻大部分是 `[SKIP]`(还没配成就、还没重算),**但这两项必须现在就 PASS**
- **极小值指标对零 AC 用户返回 None** —— 不 PASS 就别配任何 `lte` 类成就。`min_ac_code_chars` 若返回 `0`,「最短 AC 代码 ≤ 50 字符」会白送给每一个从没做出过题的新生。
- **JSONB 阈值比较按整数** —— 不 PASS 的话以后在后台调低阈值触发的补发会发给错误的人群。JSONB 里的数字若不显式 castPostgres 按字符串序比较,`"9" > "50"` 成立。
### 第三步 单人试跑重算
```bash
python manage.py recompute_achievements --user <某活跃学生id>
python manage.py check_achievement_deploy
```
「重算重建了增量辅助键」这项应从 SKIP 变 PASS。不 PASS 的话,那个学生**下次提交时活跃天数会从几十掉回 1**,并且要等到下一次重算才恢复。
### 第四步 配成就并全量补发
在管理后台「成就」页配置。参考配置表见实施计划文档 Task 1317 条,累积型 `hidden=False`、隐藏型 `hidden=True`)。
```bash
python manage.py recompute_achievements --silent
python manage.py check_achievement_deploy # 七项应全部 PASS
```
**`--silent` 不能省。** 省了的话,几百个学生下次登录会被几十个奖杯连续糊脸,一个接一个弹三秒。
`--silent` 只在首次上线用。以后加了新指标再跑重算时不带这个参数,新解锁会正常弹出。
## 日常运维
### 加一条成就
后台直接配。若指标已存在,不需要部署。
新建和「放宽条件」(改 metric / 改比较符 / 调阈值 / 从下架转上架)会自动触发 `rescan_achievement` 补发给已达标的存量用户——判定只在判题时发生,不补发的话他们要等到下次提交才拿到。
### 加一个新指标
必须改 `achievement/metrics.py`
1. 写一个 `@metric(...)` 类,实现 `on_submission`(增量)和 `recompute`(全量)
2. **如果 `on_submission` 依赖 `_` 前缀的辅助键,必须同时实现 `recompute_state`** —— 否则全量重算丢掉辅助键后,用户下一次提交会把该指标打回初始值
3. 部署后跑 `python manage.py recompute_achievements`(不带 `--silent`,让新解锁正常弹出)
4.`check_achievement_deploy` 确认
### 定期检查
每周跑一次自检,重点看「疑似配错阈值的成就」。**公开成就配置一周后仍然零解锁,多半是阈值配错了而不是太难**——学生拿不到不会来问你,管理后台列表的「已解锁人数」列是唯一的信号。
## 出问题了怎么办
### 配错了想重来
```bash
python manage.py shell -c "
from achievement.models import Achievement, UserAchievement
UserAchievement.objects.all().delete()
Achievement.objects.update(unlock_count=0) # 这行别忘
"
python manage.py recompute_achievements --silent
```
**`unlock_count` 是独立的计数器,删解锁记录不会清零它。** 忘了这行再重跑,获得率会翻倍显示。`check_achievement_deploy` 的「unlock_count 与实际解锁人数一致」这项就是在防这个。
### 某个学生的指标看起来不对
```bash
python manage.py recompute_achievements --user <id>
```
全量重算是增量逻辑的安全网,怀疑漂移时对单人跑一次即可。
### 想临时关掉某条成就
后台把它改成下架(`visible=False`)。已解锁记录保留,只是不再展示也不再判定。重新上架会自动补发给期间达标的人。
## 已知未验证的部分
**这套系统开发全程没有可用的数据库**,后端全部逻辑只经过静态审查。开发期修掉的六个缺陷里没有一个是转录错误,全是设计疏漏——这说明纸面审查有效,但它抓不到运行期行为。
`check_achievement_deploy` 就是为这一层准备的。下面这些只有真跑起来才能确认,**都属于"错了也不报错、只是悄悄发错奖杯"**
- JSONB 阈值比较是否真按整数(自检第 4 项)
- 极小值指标对零 AC 用户是否返回 `None`(自检第 3 项)
- 重算是否重建了增量辅助键(自检第 5 项)
- 并发判题时 `unlock_count` 是否重复累加(自检第 6 项)
另有一份逐条的验证清单在 `.superpowers/sdd/2026-08-03-achievement-system/deferred-verification.md`(附命令与期望输出)。
## 已知遗留问题
按影响排序,都不阻塞上线:
1. **题单奖章的管理员重算路径仍然静默发放。** `problemset/models.py:recalculate_user_badges` 不调 `notify_badges`,学生不会收到弹窗。提交触发的路径(`_check_badges`)已经接了通知。
2. **奖章的条件判断逻辑有两份。** `problemset/views/oj.py:_check_badges``problemset/models.py:_is_eligible` 各自实现了同样三个条件,今天一致。将来加条件类型时只改一处,`recalculate_user_badges` 会把另一条路径发出的奖章当作"不再符合条件"而删掉。
3. **全量重算时 4 个指标的查询跑两遍**`recompute``recompute_state` 各一次)。离线批处理命令,不影响正确性。
4. **`POST /api/achievements/pending``ids` 未做元素类型校验。** 传非整数会在 ORM 层抛异常,表现为不透明的 500。需要登录无越权风险。
5. **`threshold` 的校验用 `isinstance(x, int)`Python 里 `bool``int` 的子类**,所以 `{"threshold": true}` 会被存成 1。仅管理员可达。