fix(plan): 修正补发触发条件与 backfilled 标记

三处缺陷:rescan 从未传 backfilled=True(字段形同虚设);
loosened 谓词漏掉改 metric 和从下架转上架两种情况。
改为只要 metric/operator/threshold/visible 有变化就补发——
补发幂等,多跑一次只花一次扫描,漏跑是学生该得没得。
另补 rarity 的取值校验。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 01:27:09 -06:00
parent 46e5d774f1
commit 054d4d5c32

View File

@@ -1310,7 +1310,10 @@ def rescan_achievement(achievement_id):
if stat.user_id in already:
continue
try:
records = checker.unlock(stat.user, [achievement])
# backfilled=True这是补发不是学生刚刚挣到的。
# 前端据此只显示"已获得"而不显示具体日期——否则一次补发会给几百人
# 盖上同一个时间戳,把"最近获得"板块彻底冲垮
records = checker.unlock(stat.user, [achievement], backfilled=True)
notify_achievements(stat.user_id, records)
except Exception as e:
logger.error(f"rescan_achievement failed for user {stat.user_id}: {e}")
@@ -1374,20 +1377,19 @@ class AchievementAdminAPI(APIView):
if error:
return self.error(error)
old_threshold = achievement.threshold
old_operator = achievement.operator
before = (achievement.metric, achievement.operator, achievement.threshold, achievement.visible)
for field in ("name", "description", "icon", "rarity", "hidden", "metric", "operator", "threshold", "visible", "order"):
if field in data:
setattr(achievement, field, data[field])
achievement.save()
# 条件放宽gte 调低阈值 / lte 调高阈值 / 换了比较符)时补发
loosened = (
achievement.operator != old_operator
or (achievement.operator == "gte" and achievement.threshold < old_threshold)
or (achievement.operator == "lte" and achievement.threshold > old_threshold)
)
if loosened and achievement.visible:
# 只要"谁能达成"这件事可能变了就补发,不去精细判断是否放宽。
# 补发是幂等的后台任务unlock 用 get_or_create多跑一次只花一次扫描
# 漏跑却是学生已达标却拿不到,两个方向代价不对称。
# 早先的 loosened 谓词只看 operator/threshold会漏掉两种情况
# 换了 metric换了维度、以及从下架改成上架草稿期已达标的人
after = (achievement.metric, achievement.operator, achievement.threshold, achievement.visible)
if achievement.visible and before != after:
rescan_achievement.send(achievement.id)
return self.success(_serialize(achievement))
@@ -1418,6 +1420,10 @@ def _validate(data):
return "指标不存在"
if data["operator"] not in ("gte", "lte"):
return "比较符不合法"
# rarity 不校验的话,一个乱填的值会让 AchievementSummaryAPI 的四档统计
# 对不上:它按 Rarity.choices 遍历,野值算进总数却不出现在任何一档里
if data["rarity"] not in Rarity.values:
return "稀有度不合法"
if not isinstance(data.get("threshold"), int):
return "阈值必须是整数"
return None