Files
OnlineJudge/achievement/views/admin.py
yuetsh cf5c3c5d2f fix(achievement): 管理接口序列化 create_time 为字符串
_serialize 是手写 dict 而非 DRF 序列化器,原始 datetime 交给
JSON 编码器抛 TypeError,导致整个管理接口 500。

表为空时列表接口看着正常(不进循环),一旦有数据就全挂——
生产环境表现为:新建成就实际写库成功,但返回响应时炸掉,
前端弹窗不关、列表刷不出来,看起来像保存失败,于是重复点击建出多条。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:56:16 -06:00

127 lines
5.3 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.
from account.decorators import super_admin_required
from achievement.metrics import METRIC_REGISTRY
from achievement.models import Achievement, Rarity
from achievement.tasks import rescan_achievement
from utils.api import APIView
from utils.shortcuts import check_is_id
class AchievementAdminAPI(APIView):
@super_admin_required
def get(self, request):
achievement_id = request.GET.get("id")
if achievement_id:
try:
achievement = Achievement.objects.get(id=achievement_id)
except Achievement.DoesNotExist:
return self.error("成就不存在")
return self.success(_serialize(achievement))
return self.success([_serialize(a) for a in Achievement.objects.all()])
@super_admin_required
def post(self, request):
data = request.data
error = _validate(data)
if error:
return self.error(error)
achievement = Achievement.objects.create(
name=data["name"],
description=data["description"],
icon=data["icon"],
rarity=data["rarity"],
hidden=data.get("hidden", False),
metric=data["metric"],
operator=data["operator"],
threshold=data["threshold"],
visible=data.get("visible", True),
order=data.get("order", 0),
)
# 新建成就需要补发给已达标的存量用户
rescan_achievement.send(achievement.id)
return self.success(_serialize(achievement))
@super_admin_required
def put(self, request):
data = request.data
if not check_is_id(data.get("id")):
return self.error("参数错误")
try:
achievement = Achievement.objects.get(id=data["id"])
except Achievement.DoesNotExist:
return self.error("成就不存在")
error = _validate(data)
if error:
return self.error(error)
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()
# 只要"谁能达成"这件事可能变了就补发,不去精细判断是否放宽。
# 补发是幂等的后台任务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))
@super_admin_required
def delete(self, request):
achievement_id = request.GET.get("id")
if not check_is_id(achievement_id):
return self.error("参数错误")
Achievement.objects.filter(id=achievement_id).delete()
return self.success("删除成功")
class AchievementMetricAdminAPI(APIView):
@super_admin_required
def get(self, request):
"""供后台指标下拉框使用。这里的列表就是代码里注册了什么。"""
return self.success([{"key": key, "name": m.name, "help_text": m.help_text} for key, m in METRIC_REGISTRY.items()])
def _validate(data):
for field in ("name", "description", "icon", "rarity", "metric", "operator"):
if not data.get(field):
return f"{field} 不能为空"
if data["metric"] not in METRIC_REGISTRY:
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
def _serialize(a):
return {
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"rarity": a.rarity,
"hidden": a.hidden,
"metric": a.metric,
"metric_name": METRIC_REGISTRY[a.metric].name if a.metric in METRIC_REGISTRY else a.metric,
"operator": a.operator,
"threshold": a.threshold,
"visible": a.visible,
# 后台列表必须显示这个:阈值配错时学生永远拿不到也永远不会来问,
# 这个计数器是唯一的仪表盘
"unlock_count": a.unlock_count,
"order": a.order,
# 必须 isoformat():这里是手写的 dict 而不是 DRF 序列化器,
# 原始 datetime 交给 JSON 编码器会抛 TypeError整个管理接口 500。
# 表为空时列表接口看着正常(不进循环),一旦有数据就全挂。
"create_time": a.create_time.isoformat() if a.create_time else None,
}