From 054d4d5c3220218a86b3f06d46f078b15e4859bc Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 4 Aug 2026 01:27:09 -0600 Subject: [PATCH] =?UTF-8?q?fix(plan):=20=E4=BF=AE=E6=AD=A3=E8=A1=A5?= =?UTF-8?q?=E5=8F=91=E8=A7=A6=E5=8F=91=E6=9D=A1=E4=BB=B6=E4=B8=8E=20backfi?= =?UTF-8?q?lled=20=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处缺陷:rescan 从未传 backfilled=True(字段形同虚设); loosened 谓词漏掉改 metric 和从下架转上架两种情况。 改为只要 metric/operator/threshold/visible 有变化就补发—— 补发幂等,多跑一次只花一次扫描,漏跑是学生该得没得。 另补 rarity 的取值校验。 Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-03-achievement-system.md | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-08-03-achievement-system.md b/docs/superpowers/plans/2026-08-03-achievement-system.md index 21d069f..7ad0433 100644 --- a/docs/superpowers/plans/2026-08-03-achievement-system.md +++ b/docs/superpowers/plans/2026-08-03-achievement-system.md @@ -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