feat(achievement): 添加用户侧成就 API
新增 achievements 列表、summary、pending 三个用户侧端点,隐藏未解锁成就 在序列化层打码(name/description/icon 替换为占位符,不下发 progress)。
This commit is contained in:
80
achievement/serializers.py
Normal file
80
achievement/serializers.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from django.core.cache import cache
|
||||
from rest_framework import serializers
|
||||
|
||||
from account.models import User
|
||||
from achievement.models import Achievement, UserAchievement
|
||||
|
||||
|
||||
def get_active_user_count():
|
||||
"""获得率的分母:未禁用用户总数,缓存 1 小时。
|
||||
|
||||
不用"有过提交的用户数"这类动态口径:分母波动会让同一个成就的获得率
|
||||
忽高忽低,学生会当成 bug。
|
||||
"""
|
||||
count = cache.get("achievement_active_user_count")
|
||||
if count is None:
|
||||
count = User.objects.filter(is_disabled=False).count()
|
||||
cache.set("achievement_active_user_count", count, 3600)
|
||||
return count
|
||||
|
||||
|
||||
class AchievementSerializer(serializers.ModelSerializer):
|
||||
unlocked = serializers.BooleanField(read_only=True)
|
||||
unlock_time = serializers.DateTimeField(read_only=True, allow_null=True)
|
||||
backfilled = serializers.BooleanField(read_only=True)
|
||||
progress = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
unlock_rate = serializers.SerializerMethodField()
|
||||
name = serializers.SerializerMethodField()
|
||||
description = serializers.SerializerMethodField()
|
||||
icon = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Achievement
|
||||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"icon",
|
||||
"rarity",
|
||||
"hidden",
|
||||
"metric",
|
||||
"operator",
|
||||
"threshold",
|
||||
"unlocked",
|
||||
"unlock_time",
|
||||
"backfilled",
|
||||
"progress",
|
||||
"unlock_rate",
|
||||
)
|
||||
|
||||
def _masked(self, obj):
|
||||
return obj.hidden and not getattr(obj, "unlocked", False)
|
||||
|
||||
def get_name(self, obj):
|
||||
return "???" if self._masked(obj) else obj.name
|
||||
|
||||
def get_description(self, obj):
|
||||
return "达成条件保密" if self._masked(obj) else obj.description
|
||||
|
||||
def get_icon(self, obj):
|
||||
return "❓" if self._masked(obj) else obj.icon
|
||||
|
||||
def get_unlock_rate(self, obj):
|
||||
total = get_active_user_count()
|
||||
if not total:
|
||||
return 0.0
|
||||
return round(obj.unlock_count / total * 100, 1)
|
||||
|
||||
|
||||
class PendingAchievementSerializer(serializers.ModelSerializer):
|
||||
"""待弹窗的解锁记录,无需打码(已解锁)。"""
|
||||
|
||||
id = serializers.IntegerField(source="achievement_id", read_only=True)
|
||||
name = serializers.CharField(source="achievement.name", read_only=True)
|
||||
description = serializers.CharField(source="achievement.description", read_only=True)
|
||||
icon = serializers.CharField(source="achievement.icon", read_only=True)
|
||||
rarity = serializers.CharField(source="achievement.rarity", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = UserAchievement
|
||||
fields = ("id", "name", "description", "icon", "rarity")
|
||||
9
achievement/urls/oj.py
Normal file
9
achievement/urls/oj.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from achievement.views.oj import AchievementListAPI, AchievementPendingAPI, AchievementSummaryAPI
|
||||
|
||||
urlpatterns = [
|
||||
path("achievements", AchievementListAPI.as_view(), name="achievement_list_api"),
|
||||
path("achievements/summary", AchievementSummaryAPI.as_view(), name="achievement_summary_api"),
|
||||
path("achievements/pending", AchievementPendingAPI.as_view(), name="achievement_pending_api"),
|
||||
]
|
||||
100
achievement/views/oj.py
Normal file
100
achievement/views/oj.py
Normal 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")
|
||||
Reference in New Issue
Block a user