diff --git a/achievement/serializers.py b/achievement/serializers.py new file mode 100644 index 0000000..c015e63 --- /dev/null +++ b/achievement/serializers.py @@ -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") diff --git a/achievement/urls/oj.py b/achievement/urls/oj.py new file mode 100644 index 0000000..904ad84 --- /dev/null +++ b/achievement/urls/oj.py @@ -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"), +] diff --git a/achievement/views/oj.py b/achievement/views/oj.py new file mode 100644 index 0000000..c1caabc --- /dev/null +++ b/achievement/views/oj.py @@ -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= 指定他人,不传则为自己。与 /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") diff --git a/oj/urls.py b/oj/urls.py index 3afe193..1a89038 100644 --- a/oj/urls.py +++ b/oj/urls.py @@ -26,4 +26,5 @@ urlpatterns = [ path("api/", include("problemset.urls.oj")), path("api/admin/", include("problemset.urls.admin")), path("api/", include("class_pk.urls.oj")), + path("api/", include("achievement.urls.oj")), ]