Files
OnlineJudge/achievement/checker.py

105 lines
4.2 KiB
Python
Raw Permalink 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.
"""成就判定核心,被异步任务和管理命令共用。
判定刻意做成"一次查询取全部候选 + 内存比对",与 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