feat(achievement): 添加用户侧成就 API

新增 achievements 列表、summary、pending 三个用户侧端点,隐藏未解锁成就
在序列化层打码(name/description/icon 替换为占位符,不下发 progress)。
This commit is contained in:
2026-08-04 01:12:16 -06:00
parent 8ce3660c87
commit 1d66e2be1f
4 changed files with 190 additions and 0 deletions

100
achievement/views/oj.py Normal file
View File

@@ -0,0 +1,100 @@
from django.db.models import Count
from account.decorators import login_required
from account.models import User
from achievement.models import Achievement, Rarity, UserAchievement, UserStat
from achievement.serializers import AchievementSerializer, PendingAchievementSerializer
from utils.api import APIView
def _resolve_user(request):
"""?name=<username> 指定他人,不传则为自己。与 /api/profile 的约定一致。"""
username = request.GET.get("name")
if username:
return User.objects.filter(username=username, is_disabled=False).first()
return request.user
def _decorate(achievements, unlocked_map, metrics):
"""给成就对象挂上该用户的解锁状态与进度,供序列化器读取。"""
for a in achievements:
record = unlocked_map.get(a.id)
a.unlocked = record is not None
a.unlock_time = record.unlock_time if record else None
a.backfilled = record.backfilled if record else False
value = metrics.get(a.metric)
# 隐藏且未解锁的成就不下发进度,否则能反推出条件
a.progress = None if (a.hidden and not a.unlocked) else (value or 0)
return achievements
class AchievementListAPI(APIView):
@login_required
def get(self, request):
user = _resolve_user(request)
if user is None:
return self.error("用户不存在")
achievements = list(Achievement.objects.filter(visible=True))
unlocked_map = {r.achievement_id: r for r in UserAchievement.objects.filter(user=user)}
stat = UserStat.objects.filter(user=user).first()
metrics = stat.metrics if stat else {}
_decorate(achievements, unlocked_map, metrics)
return self.success(
{
"username": user.username,
"achievements": AchievementSerializer(achievements, many=True).data,
}
)
class AchievementSummaryAPI(APIView):
@login_required
def get(self, request):
user = _resolve_user(request)
if user is None:
return self.error("用户不存在")
total_by_rarity = dict(Achievement.objects.filter(visible=True).values_list("rarity").annotate(c=Count("id")))
unlocked_by_rarity = dict(UserAchievement.objects.filter(user=user, achievement__visible=True).values_list("achievement__rarity").annotate(c=Count("id")))
total = sum(total_by_rarity.values())
unlocked = sum(unlocked_by_rarity.values())
recent = list(UserAchievement.objects.filter(user=user, achievement__visible=True).select_related("achievement").order_by("-unlock_time")[:5])
return self.success(
{
"username": user.username,
"total": total,
"unlocked": unlocked,
"percent": round(unlocked / total * 100, 1) if total else 0.0,
"rarity": [
{
"rarity": value,
"label": label,
"total": total_by_rarity.get(value, 0),
"unlocked": unlocked_by_rarity.get(value, 0),
}
for value, label in Rarity.choices
],
"recent": PendingAchievementSerializer(recent, many=True).data,
}
)
class AchievementPendingAPI(APIView):
@login_required
def get(self, request):
"""返回尚未弹过的解锁记录。前端在布局层路由切换时拉取。"""
records = UserAchievement.objects.filter(user=request.user, notified=False, achievement__visible=True).select_related("achievement").order_by("unlock_time")
return self.success(PendingAchievementSerializer(records, many=True).data)
@login_required
def post(self, request):
"""弹完后标记已读避免下次导航重复弹。body: {"ids": [成就 id]}"""
ids = request.data.get("ids") or []
if not isinstance(ids, list):
return self.error("参数错误")
UserAchievement.objects.filter(user=request.user, achievement_id__in=ids).update(notified=True)
return self.success("ok")