feat(achievement): 添加判定核心、通知模块并接入判题链路

This commit is contained in:
2026-08-04 01:02:29 -06:00
parent 9e0bc1fa8c
commit 95bf02efbd
4 changed files with 185 additions and 0 deletions

96
achievement/checker.py Normal file
View File

@@ -0,0 +1,96 @@
"""成就判定核心,被异步任务和管理命令共用。
判定刻意做成"一次查询取全部候选 + 内存比对",与 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 []
# 第二轮:只重算元指标、只判定依赖元指标的成就,不再有第三轮
for key in META_METRICS:
value = METRIC_REGISTRY[key].recompute(user)
if value is None:
stat.metrics.pop(key, None)
else:
stat.metrics[key] = value
stat.save(update_fields=["metrics", "update_time"])
second = unlock(user, evaluate(user, stat.metrics, only_metrics=META_METRICS))
return first + second