diff --git a/achievement/checker.py b/achievement/checker.py index 0ed7fa3..7ce0c5e 100644 --- a/achievement/checker.py +++ b/achievement/checker.py @@ -31,7 +31,7 @@ def evaluate(user, metrics, only_metrics=None): for achievement in qs: value = metrics.get(achievement.metric) # 指标从未产生有效值时 key 不存在,直接跳过: - # 否则 min_ac_code_chars 这类极小值指标会对新用户恒成立 + # 否则极小值型指标(求 min、配 lte 用的那种)会对新用户恒成立 if value is None: continue if achievement.operator == Operator.GTE and value >= achievement.threshold: diff --git a/achievement/management/commands/check_achievement_deploy.py b/achievement/management/commands/check_achievement_deploy.py index 56a6371..c1e287e 100644 --- a/achievement/management/commands/check_achievement_deploy.py +++ b/achievement/management/commands/check_achievement_deploy.py @@ -16,7 +16,7 @@ from django.db.models.functions import Cast from account.models import User from achievement.metrics import META_METRICS, METRIC_REGISTRY -from achievement.models import Achievement, UserAchievement, UserStat +from achievement.models import Achievement, Operator, UserAchievement, UserStat # on_submission 依赖的辅助键 -> 它应该与哪个顶层指标保持一致 STATE_PAIRS = { @@ -85,20 +85,20 @@ class Command(BaseCommand): if count == 0: self._fail(title, "METRIC_REGISTRY 是空的,AppConfig.ready() 没有导入 metrics") return - if "min_ac_code_chars" not in METRIC_REGISTRY: - self._fail(title, f"注册了 {count} 个指标,但缺少 min_ac_code_chars") - return self._ok(title, f"{count} 个指标,元指标 {sorted(META_METRICS)}") def _check_min_metric_absent(self): - """极小值型指标对没有 AC 记录的用户必须返回 None。 + """lte 类成就用的指标,对没有 AC 记录的用户必须返回 None。 - 返回 0 的话,"最短 AC 代码 ≤ 50 字符"会白送给每一个从没做出过题的新生。 + 返回 0 的话,"最短 AC 代码 ≤ 50 字符"这类成就会白送给每一个从没做出过题的新生。 + 线上目前没有 lte 成就(min_ac_code_chars 已删),本项因此会 SKIP; + 将来配了任何 lte 成就,它会自动开始检查对应的指标。 """ title = "极小值指标对零 AC 用户返回 None" - metric = METRIC_REGISTRY.get("min_ac_code_chars") - if metric is None: - self._skip(title, "指标未注册") + metric_keys = sorted(set(Achievement.objects.filter(operator=Operator.LTE, visible=True).values_list("metric", flat=True))) + metrics = [(k, METRIC_REGISTRY[k]) for k in metric_keys if k in METRIC_REGISTRY] + if not metrics: + self._skip(title, "没有上架的 lte 类成就") return user = User.objects.filter(is_disabled=False, userprofile__accepted_number=0).first() @@ -106,14 +106,15 @@ class Command(BaseCommand): self._skip(title, "找不到 accepted_number=0 的用户,无法验证") return - value = metric.recompute(user) - if value is None: - self._ok(title, f"用户 {user.username}") - else: + bad = [(key, value) for key, m in metrics if (value := m.recompute(user)) is not None] + if bad: + detail = "、".join(f"{key} 得到 {value!r}" for key, value in bad) self._fail( title, - f"用户 {user.username} 得到 {value!r},应为 None。\n 现在配任何 lte 类成就都会白送给全部零 AC 用户。", + f"用户 {user.username}:{detail},都应为 None。\n 这些 lte 成就正在白送给全部零 AC 用户。", ) + else: + self._ok(title, f"用户 {user.username},检查了 {len(metrics)} 个指标") def _check_jsonb_cast(self): """JSONB 数字必须按整数比较,不能按字符串序。 diff --git a/achievement/metrics.py b/achievement/metrics.py index abe85a3..12d0a34 100644 --- a/achievement/metrics.py +++ b/achievement/metrics.py @@ -14,6 +14,7 @@ from django.db.models import Count, Q from django.utils import timezone from submission.models import JudgeStatus, Submission, is_accepted +from utils.constants import Difficulty logger = logging.getLogger(__name__) @@ -81,6 +82,16 @@ def build_ctx(user_id, sub): ) local_now = timezone.localtime(sub.create_time) sub_is_accepted = is_accepted(sub.result) + is_first_ac_of_problem = sub_is_accepted and prior_stats["accepted"] == 0 + + # 难度只有首次 AC 时才用得上,其余情况不查这一次库—— + # 绝大多数提交都不是首次 AC,放在外面等于给每次判题白加一条 SQL + difficulty = None + if is_first_ac_of_problem: + from problem.models import Problem + + difficulty = Problem.objects.filter(id=sub.problem_id).values_list("difficulty", flat=True).first() + return { "skip": False, "is_accepted": sub_is_accepted, @@ -88,9 +99,11 @@ def build_ctx(user_id, sub): "prior_count": prior_stats["total"], "prior_accepted": prior_stats["accepted"], # 首次 AC 这道题(此前从未 AC 过) - "is_first_ac_of_problem": sub_is_accepted and prior_stats["accepted"] == 0, + "is_first_ac_of_problem": is_first_ac_of_problem, # 一发入魂:此前无任何提交且本次 AC "is_first_try_ac": sub_is_accepted and prior_stats["total"] == 0, + # 本题难度,仅首次 AC 时有值 + "problem_difficulty": difficulty, "local_date": local_now.date().isoformat(), "local_hour": local_now.hour, } @@ -106,6 +119,33 @@ class AcceptedCount(BaseMetric): return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by().values("problem_id").distinct().count() +class _DifficultyAcCount(BaseMetric): + """按难度去重统计 AC 题数。子类只需指定 difficulty。 + + 增量靠 ctx["problem_difficulty"],它只在首次 AC 时才有值——与 + is_first_ac_of_problem 是同一个条件,所以两者一起判即可。 + """ + + difficulty = "" + + def on_submission(self, metrics, sub, ctx): + if ctx["is_first_ac_of_problem"] and ctx["problem_difficulty"] == self.difficulty: + metrics[self.key] = metrics.get(self.key, 0) + 1 + + def recompute(self, user): + return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS, problem__difficulty=self.difficulty).order_by().values("problem_id").distinct().count() + + +@metric("mid_ac_count", "中等题 AC 数", "去重后通过的中等难度题目数(不含比赛)") +class MidAcCount(_DifficultyAcCount): + difficulty = Difficulty.MID + + +@metric("hard_ac_count", "困难题 AC 数", "去重后通过的困难题目数(不含比赛)") +class HardAcCount(_DifficultyAcCount): + difficulty = Difficulty.HIGH + + @metric("submission_count", "提交总数", "提交次数(不含比赛)") class SubmissionCount(BaseMetric): def on_submission(self, metrics, sub, ctx): @@ -328,18 +368,10 @@ class MaxAcInOneDay(BaseMetric): return {"_ac_per_day": counts} -@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__in=ACCEPTED_RESULTS).values_list("code", flat=True)] - return min(lengths) if lengths else None +# 曾经这里有 min_ac_code_chars(最短 AC 代码)。线上实测 1314 个用户的分布, +# 最小值 8、p5=10:有道题 8 个字符就能通过,于是它测的是"谁做过那道水题" +# 而不是"谁写得简洁",配不出有意义的成就,2026-08-05 删除。 +# 要重新引入,得先按题目难度加权,或排除掉那类水题。 @metric("max_code_lines", "最长代码行数", "提交过的最长代码有多少行") diff --git a/achievement/models.py b/achievement/models.py index 7e4f965..1fe7307 100644 --- a/achievement/models.py +++ b/achievement/models.py @@ -43,7 +43,7 @@ class UserStat(models.Model): """成就系统唯一的指标源,不复用 UserProfile 的计数器,避免两处口径漂移。 metrics 为 {指标名: 数值};指标从未产生过有效值时 key 不存在(而非置 0), - 否则 min_ac_code_chars 这类极小值指标会对新用户恒成立。 + 否则极小值型指标(求 min、配 lte 用的那种)会对新用户恒成立。 """ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="achievement_stat") diff --git a/docs/ops/achievement-deploy.md b/docs/ops/achievement-deploy.md index fec6446..7f8282c 100644 --- a/docs/ops/achievement-deploy.md +++ b/docs/ops/achievement-deploy.md @@ -40,7 +40,7 @@ python manage.py check_achievement_deploy 只读,可反复跑,七项检查。此刻大部分是 `[SKIP]`(还没配成就、还没重算),**但这两项必须现在就 PASS**: -- **极小值指标对零 AC 用户返回 None** —— 不 PASS 就别配任何 `lte` 类成就。`min_ac_code_chars` 若返回 `0`,「最短 AC 代码 ≤ 50 字符」会白送给每一个从没做出过题的新生。 +- **极小值指标对零 AC 用户返回 None** —— 不 PASS 就别配任何 `lte` 类成就。这类指标若返回 `0`,「最短 AC 代码 ≤ 50 字符」之流会白送给每一个从没做出过题的新生。线上目前没有 `lte` 成就,本项会 SKIP,配了才开始检查。 - **JSONB 阈值比较按整数** —— 不 PASS 的话,以后在后台调低阈值触发的补发会发给错误的人群。JSONB 里的数字若不显式 cast,Postgres 按字符串序比较,`"9" > "50"` 成立。 ### 第三步 单人试跑重算