diff --git a/achievement/__init__.py b/achievement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/achievement/apps.py b/achievement/apps.py new file mode 100644 index 0000000..3c6023f --- /dev/null +++ b/achievement/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AchievementConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "achievement" diff --git a/achievement/management/__init__.py b/achievement/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/achievement/management/commands/__init__.py b/achievement/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/achievement/migrations/0001_initial.py b/achievement/migrations/0001_initial.py new file mode 100644 index 0000000..33ab7d4 --- /dev/null +++ b/achievement/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 6.0.4 on 2026-08-04 06:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Achievement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField(verbose_name='成就名称')), + ('description', models.TextField(verbose_name='成就描述')), + ('icon', models.TextField(verbose_name='图标')), + ('rarity', models.TextField(choices=[('bronze', '青铜'), ('silver', '白银'), ('gold', '黄金'), ('platinum', '白金')], default='bronze', verbose_name='稀有度')), + ('hidden', models.BooleanField(db_default=False, default=False, verbose_name='是否隐藏')), + ('metric', models.TextField(verbose_name='指标名')), + ('operator', models.TextField(choices=[('gte', '大于等于'), ('lte', '小于等于')], default='gte', verbose_name='比较符')), + ('threshold', models.IntegerField(verbose_name='阈值')), + ('visible', models.BooleanField(db_default=True, default=True, verbose_name='是否上架')), + ('unlock_count', models.IntegerField(db_default=0, default=0, verbose_name='已解锁人数')), + ('order', models.IntegerField(db_default=0, default=0, verbose_name='排序')), + ('create_time', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': '成就', + 'verbose_name_plural': '成就', + 'db_table': 'achievement', + 'ordering': ('order', 'id'), + }, + ), + migrations.CreateModel( + name='UserStat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('metrics', models.JSONField(db_default=models.Value({}, output_field=models.JSONField()), default=dict)), + ('update_time', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='achievement_stat', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'user_stat', + }, + ), + migrations.CreateModel( + name='UserAchievement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('unlock_time', models.DateTimeField(auto_now_add=True)), + ('backfilled', models.BooleanField(db_default=False, default=False)), + ('notified', models.BooleanField(db_default=False, default=False)), + ('achievement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='achievement.achievement')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='achievements', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'user_achievement', + 'ordering': ('-unlock_time',), + 'indexes': [models.Index(fields=['user', '-unlock_time'], name='user_achv_time_idx'), models.Index(fields=['user', 'notified'], name='user_achv_notified_idx')], + 'constraints': [models.UniqueConstraint(fields=('user', 'achievement'), name='unique_user_achievement')], + }, + ), + ] diff --git a/achievement/migrations/__init__.py b/achievement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/achievement/models.py b/achievement/models.py new file mode 100644 index 0000000..e81a812 --- /dev/null +++ b/achievement/models.py @@ -0,0 +1,73 @@ +from django.db import models + +from account.models import User +from utils.models import JSONField + + +class Rarity(models.TextChoices): + BRONZE = "bronze", "青铜" + SILVER = "silver", "白银" + GOLD = "gold", "黄金" + PLATINUM = "platinum", "白金" + + +class Operator(models.TextChoices): + GTE = "gte", "大于等于" + LTE = "lte", "小于等于" + + +class Achievement(models.Model): + name = models.TextField(verbose_name="成就名称") + description = models.TextField(verbose_name="成就描述") + icon = models.TextField(verbose_name="图标") + rarity = models.TextField(default=Rarity.BRONZE, choices=Rarity.choices, verbose_name="稀有度") + hidden = models.BooleanField(default=False, db_default=False, verbose_name="是否隐藏") + metric = models.TextField(verbose_name="指标名") + operator = models.TextField(default=Operator.GTE, choices=Operator.choices, verbose_name="比较符") + threshold = models.IntegerField(verbose_name="阈值") + visible = models.BooleanField(default=True, db_default=True, verbose_name="是否上架") + unlock_count = models.IntegerField(default=0, db_default=0, verbose_name="已解锁人数") + order = models.IntegerField(default=0, db_default=0, verbose_name="排序") + create_time = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "achievement" + ordering = ("order", "id") + verbose_name = "成就" + verbose_name_plural = "成就" + + +class UserStat(models.Model): + """成就系统唯一的指标源,不复用 UserProfile 的计数器,避免两处口径漂移。 + + metrics 为 {指标名: 数值};指标从未产生过有效值时 key 不存在(而非置 0), + 否则 min_ac_code_chars 这类极小值指标会对新用户恒成立。 + """ + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="achievement_stat") + metrics = JSONField(default=dict, db_default=models.Value({}, output_field=models.JSONField())) + update_time = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "user_stat" + + +class UserAchievement(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="achievements") + achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE) + unlock_time = models.DateTimeField(auto_now_add=True) + # 上线补发的记录:前端不显示具体日期,只显示"已获得" + backfilled = models.BooleanField(default=False, db_default=False) + # 是否已向用户弹过奖杯,pending 端点据此查询 + notified = models.BooleanField(default=False, db_default=False) + + class Meta: + db_table = "user_achievement" + ordering = ("-unlock_time",) + constraints = [ + models.UniqueConstraint(fields=["user", "achievement"], name="unique_user_achievement"), + ] + indexes = [ + models.Index(fields=["user", "-unlock_time"], name="user_achv_time_idx"), + models.Index(fields=["user", "notified"], name="user_achv_notified_idx"), + ] diff --git a/achievement/urls/__init__.py b/achievement/urls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/achievement/views/__init__.py b/achievement/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oj/settings.py b/oj/settings.py index 1d39a51..3185e75 100644 --- a/oj/settings.py +++ b/oj/settings.py @@ -61,6 +61,7 @@ LOCAL_APPS = [ "flowchart", "problemset", "class_pk", + "achievement", ] INSTALLED_APPS = VENDOR_APPS + LOCAL_APPS