utils/tasks.py 整个文件(delete_files) utils/async_helpers.py async_cache_delete problem/utils.py build_problem_template utils/shortcuts.py build_query_string achievement/checker.py get_or_create_stat problem/serializers.py TemplateSerializer delete_files 的唯一调用者是上一个 commit 删掉的 DownloadContestSubmissions (`delete_files.send_with_options(args=(zip_path,), delay=300_000)`,生成 zip 后 延时清理)。django_dramatiq 按约定自动发现各 app 的 tasks.py,缺一个文件不报错, 已实跑确认 judge / achievement / flowchart 三处的 actor 都还在。 ## 扫出来但**不能删**的,记在这里免得下次又查一遍 **achievement/metrics.py 的 18 个指标类**:静态扫描报"零引用",因为类名确实 从不被引用 —— 它们靠 `@metric(key, name)` 装饰器在**定义时**注册进 METRIC_REGISTRY。删掉会静默丢掉成就维度,而且不报错。运行时确认注册表里 18 个一个不少。 **async_cache_get / async_cache_set**:各 6 处引用,只有 delete 那个是死的。 **parse_problem_template**:5 处引用,和被删的 build_problem_template 只是名字像。 **User 模型上的 open_api / open_api_appkey / two_factor_auth / session_keys**: 删字段要迁移,会改生产库结构 —— 而新后端正是靠"库结构与旧后端逐列一致"才能 回滚(阶段 5 演练验证过)。切换完成之前不动。 ## 验证 manage.py check 通过;起本地 Django + PostgreSQL 实跑 14 个端点全部 200 (前台 10 个 + 后台 4 个);ruff 全绿;行尾未被改动。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
4.1 KiB
Python
100 lines
4.1 KiB
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 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、配 lte 用的那种)会对新用户恒成立
|
||
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_create:unique_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
|