v0.2 Preview: Construction Plan.
CI / test (push) Failing after 1m31s

This commit is contained in:
2026-08-08 21:25:27 +08:00
parent b88d15698a
commit 1f98f7152d
822 changed files with 96959 additions and 8 deletions
View File
+35
View File
@@ -0,0 +1,35 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import (
AuditLog,
InviteCode,
InviteCodeUsage,
User,
UserSession,
VisitorMigration,
)
@admin.register(User)
class HulumathUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + (
("葫芦数学", {"fields": ("nickname", "avatar_url", "bio", "track", "rating")}),
)
add_fieldsets = UserAdmin.add_fieldsets + (
("葫芦数学", {"fields": ("nickname", "track")}),
)
list_display = ("username", "nickname", "track", "rating", "is_active", "date_joined")
@admin.register(InviteCode)
class InviteCodeAdmin(admin.ModelAdmin):
list_display = ("code", "group", "used_count", "max_uses", "expires_at", "is_active")
list_filter = ("is_active", "group")
search_fields = ("code", "group")
admin.site.register(InviteCodeUsage)
admin.site.register(UserSession)
admin.site.register(VisitorMigration)
admin.site.register(AuditLog)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
+104
View File
@@ -0,0 +1,104 @@
from django.db import transaction
from django.utils import timezone
from content.models import ContentInteraction, ContentItem
from math_life.models import (
MathBTIAssessment,
MathBTIResult,
MathIdentity,
Story,
StoryRun,
StoryVersion,
)
from math_life.services import score_mathbti
from progression.models import Card, UserCard
from progression.services import initialize_math_identity
from .models import VisitorMigration
def migration_summary(payload):
mathbti = 1 if isinstance(payload.get("mathbti"), dict) else 0
return {
"mathbti_results": mathbti,
"story_saves": min(len(payload.get("story_saves", [])), 20),
"favorites": min(len(payload.get("favorites", [])), 200),
"cards": min(len(payload.get("cards", [])), 100),
}
@transaction.atomic
def apply_visitor_migration(user):
migration = VisitorMigration.objects.select_for_update().get(user=user)
if migration.status == VisitorMigration.Status.APPLIED:
return migration
payload = migration.payload
mathbti = payload.get("mathbti")
if isinstance(mathbti, dict):
assessment = MathBTIAssessment.objects.filter(
version=mathbti.get("version"),
is_published=True,
).first()
if assessment:
code, scores = score_mathbti(assessment.definition, mathbti.get("answers", []))
identity = MathIdentity.objects.get(code=code)
MathBTIResult.objects.create(
user=user,
assessment=assessment,
identity=identity,
answers=mathbti["answers"],
axis_scores=scores,
)
initialize_math_identity(user, identity)
for item in payload.get("story_saves", [])[:20]:
if not isinstance(item, dict):
continue
story = Story.objects.filter(slug=item.get("story_slug")).first()
if story is None:
continue
version = StoryVersion.objects.filter(
story=story,
version=item.get("version"),
is_published=True,
).first()
node_id = item.get("current_node")
if version is None or node_id not in version.content.get("nodes", {}):
continue
StoryRun.objects.get_or_create(
user=user,
story_version=version,
status=StoryRun.Status.ACTIVE,
defaults={
"current_node": node_id,
"state": item.get("state", {}) if isinstance(item.get("state"), dict) else {},
},
)
favorites = ContentItem.objects.filter(
slug__in=payload.get("favorites", [])[:200],
is_published=True,
)
ContentInteraction.objects.bulk_create(
[
ContentInteraction(
user=user,
content=item,
action=ContentInteraction.Action.FAVORITE,
)
for item in favorites
],
ignore_conflicts=True,
)
cards = Card.objects.filter(slug__in=payload.get("cards", [])[:100])
UserCard.objects.bulk_create(
[UserCard(user=user, card=card, source="visitor_migration") for card in cards],
ignore_conflicts=True,
)
migration.status = VisitorMigration.Status.APPLIED
migration.applied_at = timezone.now()
migration.save(update_fields=["status", "applied_at"])
return migration
+104
View File
@@ -0,0 +1,104 @@
# Generated by Django 4.2.23 on 2026-08-08 11:15
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('nickname', models.CharField(max_length=40)),
('avatar_url', models.URLField(blank=True)),
('bio', models.CharField(blank=True, max_length=200)),
('track', models.CharField(choices=[('beginner', '入门'), ('standard', '标准'), ('advanced', '进阶'), ('open', 'Open')], default='standard', max_length=16)),
('rating', models.PositiveIntegerField(default=1000)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='InviteCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=32, unique=True)),
('group', models.CharField(blank=True, max_length=80)),
('max_uses', models.PositiveIntegerField(default=1)),
('used_count', models.PositiveIntegerField(default=0)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invite_codes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='UserSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(max_length=40, unique=True)),
('user_agent', models.CharField(blank=True, max_length=300)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('last_seen_at', models.DateTimeField(auto_now=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_sessions', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='InviteCodeUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('used_at', models.DateTimeField(auto_now_add=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('invite_code', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='usages', to='accounts.invitecode')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invite_usage', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=80)),
('target_type', models.CharField(max_length=80)),
('target_id', models.CharField(blank=True, max_length=80)),
('reason', models.CharField(blank=True, max_length=300)),
('metadata', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 4.2.23 on 2026-08-08 11:28
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='VisitorMigration',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('payload', models.JSONField(default=dict)),
('summary', models.JSONField(default=dict)),
('status', models.CharField(choices=[('pending', '待确认'), ('applied', '已迁移')], default='pending', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('applied_at', models.DateTimeField(blank=True, null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='visitor_migration', to=settings.AUTH_USER_MODEL)),
],
),
]
+90
View File
@@ -0,0 +1,90 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone
class User(AbstractUser):
class Track(models.TextChoices):
BEGINNER = "beginner", "入门"
STANDARD = "standard", "标准"
ADVANCED = "advanced", "进阶"
OPEN = "open", "Open"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
nickname = models.CharField(max_length=40)
avatar_url = models.URLField(blank=True)
bio = models.CharField(max_length=200, blank=True)
track = models.CharField(max_length=16, choices=Track.choices, default=Track.STANDARD)
rating = models.PositiveIntegerField(default=1000)
def __str__(self):
return self.nickname or self.username
class InviteCode(models.Model):
code = models.CharField(max_length=32, unique=True)
group = models.CharField(max_length=80, blank=True)
max_uses = models.PositiveIntegerField(default=1)
used_count = models.PositiveIntegerField(default=0)
expires_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(
User, null=True, blank=True, on_delete=models.SET_NULL, related_name="invite_codes"
)
created_at = models.DateTimeField(auto_now_add=True)
@property
def is_available(self):
return (
self.is_active
and self.used_count < self.max_uses
and (self.expires_at is None or self.expires_at > timezone.now())
)
def __str__(self):
return self.code
class InviteCodeUsage(models.Model):
invite_code = models.ForeignKey(InviteCode, on_delete=models.PROTECT, related_name="usages")
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="invite_usage")
used_at = models.DateTimeField(auto_now_add=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
class UserSession(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="login_sessions")
session_key = models.CharField(max_length=40, unique=True)
user_agent = models.CharField(max_length=300, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
last_seen_at = models.DateTimeField(auto_now=True)
revoked_at = models.DateTimeField(null=True, blank=True)
class VisitorMigration(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "待确认"
APPLIED = "applied", "已迁移"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="visitor_migration")
payload = models.JSONField(default=dict)
summary = models.JSONField(default=dict)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
created_at = models.DateTimeField(auto_now_add=True)
applied_at = models.DateTimeField(null=True, blank=True)
class AuditLog(models.Model):
actor = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
action = models.CharField(max_length=80)
target_type = models.CharField(max_length=80)
target_id = models.CharField(max_length=80, blank=True)
reason = models.CharField(max_length=300, blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
+72
View File
@@ -0,0 +1,72 @@
import json
from django.contrib.auth.password_validation import validate_password
from django.db import transaction
from rest_framework import serializers
from .migration_service import migration_summary
from .models import InviteCode, InviteCodeUsage, User, VisitorMigration
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ("id", "username", "nickname", "avatar_url", "bio", "track", "rating")
read_only_fields = ("id", "rating")
class RegisterSerializer(serializers.Serializer):
invite_code = serializers.CharField(max_length=32)
username = serializers.RegexField(r"^[A-Za-z0-9_]{3,30}$")
password = serializers.CharField(write_only=True, min_length=8)
nickname = serializers.CharField(max_length=40)
track = serializers.ChoiceField(choices=User.Track.choices, default=User.Track.STANDARD)
visitor_data = serializers.JSONField(required=False, default=dict, write_only=True)
def validate_username(self, value):
if User.objects.filter(username__iexact=value).exists():
raise serializers.ValidationError("用户名已存在")
return value
def validate_password(self, value):
validate_password(value)
return value
def validate_visitor_data(self, value):
if not isinstance(value, dict):
raise serializers.ValidationError("游客数据必须是对象")
for field in ("story_saves", "favorites", "cards"):
if field in value and not isinstance(value[field], list):
raise serializers.ValidationError(f"{field} 必须是数组")
if "mathbti" in value and not isinstance(value["mathbti"], dict):
raise serializers.ValidationError("mathbti 必须是对象")
if len(json.dumps(value, ensure_ascii=False).encode("utf-8")) > 256 * 1024:
raise serializers.ValidationError("游客数据不能超过 256KB")
return value
@transaction.atomic
def create(self, validated_data):
code_text = validated_data.pop("invite_code")
visitor_data = validated_data.pop("visitor_data", {})
try:
invite = InviteCode.objects.select_for_update().get(code__iexact=code_text)
except InviteCode.DoesNotExist as exc:
raise serializers.ValidationError({"invite_code": "邀请码无效"}) from exc
if not invite.is_available:
raise serializers.ValidationError({"invite_code": "邀请码已过期或已用完"})
user = User.objects.create_user(**validated_data)
InviteCodeUsage.objects.create(
invite_code=invite,
user=user,
ip_address=self.context.get("ip_address"),
)
invite.used_count += 1
invite.save(update_fields=["used_count"])
if visitor_data:
VisitorMigration.objects.create(
user=user,
payload=visitor_data,
summary=migration_summary(visitor_data),
)
return user
@@ -0,0 +1,99 @@
import pytest
from accounts.migration_service import apply_visitor_migration, migration_summary
from accounts.models import User, VisitorMigration
from content.models import ContentInteraction, ContentItem
from math_life.models import (
MathBTIAssessment,
MathBTIResult,
MathIdentity,
Story,
StoryRun,
StoryVersion,
)
from progression.models import Card, UserCard
@pytest.mark.django_db
def test_apply_visitor_migration_确认后事务化导入且可重复调用():
user = User.objects.create_user(
username="visitor_user",
password="StrongPass_2026",
nickname="游客用户",
)
definition = {
"axes": [{"id": "style"}],
"scoring": {"axes": ["style"], "cutoff": 0},
"questions": [
{"id": 1, "axis": "style", "options": [{"score": 0}, {"score": 1}]}
],
}
MathBTIAssessment.objects.create(
version="test-1",
title="测试 MathBTI",
definition=definition,
is_published=True,
)
MathIdentity.objects.create(
code="1",
name="测试身份",
clan="信仰者",
mathematician="高斯",
)
story = Story.objects.create(slug="visitor-story", title="游客人生")
StoryVersion.objects.create(
story=story,
version=1,
is_published=True,
content={
"start_node": "start",
"nodes": {
"start": {"scene": "开始", "choices": []},
"saved": {"scene": "存档", "choices": []},
},
},
)
content = ContentItem.objects.create(
slug="favorite-content",
title="收藏内容",
kind=ContentItem.Kind.KNOWLEDGE,
is_published=True,
)
legacy_card = Card.objects.create(
slug="legacy-card",
name="旧人物卡",
mathematician="欧拉",
description="游客阶段获得",
)
payload = {
"mathbti": {"version": "test-1", "answers": [1]},
"story_saves": [
{
"story_slug": "visitor-story",
"version": 1,
"current_node": "saved",
"state": {"energy": 3},
}
],
"favorites": ["favorite-content"],
"cards": ["legacy-card"],
}
VisitorMigration.objects.create(
user=user,
payload=payload,
summary=migration_summary(payload),
)
first = apply_visitor_migration(user)
second = apply_visitor_migration(user)
assert first.status == VisitorMigration.Status.APPLIED
assert second.status == VisitorMigration.Status.APPLIED
assert MathBTIResult.objects.filter(user=user).count() == 1
assert StoryRun.objects.get(user=user).current_node == "saved"
assert ContentInteraction.objects.filter(
user=user,
content=content,
action=ContentInteraction.Action.FAVORITE,
).count() == 1
assert UserCard.objects.filter(user=user, card=legacy_card).count() == 1
+76
View File
@@ -0,0 +1,76 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework import serializers
from accounts.models import InviteCode, InviteCodeUsage, User
from accounts.serializers import RegisterSerializer
def registration_data(code="VALID-CODE", username="math_user"):
return {
"invite_code": code,
"username": username,
"password": "StrongPass_2026",
"nickname": "数学少年",
"track": User.Track.STANDARD,
}
@pytest.mark.django_db
def test_register_serializer_有效邀请码创建用户并原子消费():
invite = InviteCode.objects.create(code="VALID-CODE", max_uses=1)
serializer = RegisterSerializer(
data=registration_data(),
context={"ip_address": "127.0.0.1"},
)
assert serializer.is_valid(), serializer.errors
user = serializer.save()
invite.refresh_from_db()
assert invite.used_count == 1
assert user.check_password("StrongPass_2026")
usage = InviteCodeUsage.objects.get(user=user)
assert usage.invite_code == invite
assert usage.ip_address == "127.0.0.1"
@pytest.mark.django_db
def test_register_serializer_过期邀请码拒绝创建用户():
InviteCode.objects.create(
code="EXPIRED",
max_uses=1,
expires_at=timezone.now() - timedelta(seconds=1),
)
serializer = RegisterSerializer(data=registration_data(code="EXPIRED"))
assert serializer.is_valid(), serializer.errors
with pytest.raises(serializers.ValidationError, match="已过期或已用完"):
serializer.save()
assert User.objects.count() == 0
@pytest.mark.django_db
def test_register_serializer_用户名大小写重复时校验失败():
User.objects.create_user(
username="Math_User",
password="StrongPass_2026",
nickname="已有用户",
)
serializer = RegisterSerializer(data=registration_data(username="math_user"))
assert not serializer.is_valid()
assert "用户名已存在" in str(serializer.errors["username"])
@pytest.mark.django_db
def test_register_serializer_游客迁移字段类型错误时拒绝():
InviteCode.objects.create(code="VALID-CODE", max_uses=1)
data = registration_data()
data["visitor_data"] = {"favorites": None}
serializer = RegisterSerializer(data=data)
assert not serializer.is_valid()
assert "favorites 必须是数组" in str(serializer.errors["visitor_data"])
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from .views import LoginView, LogoutView, MeView, RegisterView, VisitorMigrationView
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("login/", LoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
path("me/", MeView.as_view(), name="me"),
path("visitor-migration/", VisitorMigrationView.as_view(), name="visitor-migration"),
]
+111
View File
@@ -0,0 +1,111 @@
from django.contrib.auth import authenticate, login, logout
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .migration_service import apply_visitor_migration
from .models import UserSession, VisitorMigration
from .serializers import RegisterSerializer, UserSerializer
def client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
return forwarded.split(",")[0].strip() if forwarded else request.META.get("REMOTE_ADDR")
class RegisterView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = RegisterSerializer(
data=request.data,
context={"ip_address": client_ip(request)},
)
serializer.is_valid(raise_exception=True)
user = serializer.save()
login(request, user)
self._record_session(request, user)
payload = UserSerializer(user).data
payload["visitor_migration_pending"] = hasattr(user, "visitor_migration")
return Response(payload, status=status.HTTP_201_CREATED)
@staticmethod
def _record_session(request, user):
if not request.session.session_key:
request.session.save()
UserSession.objects.update_or_create(
session_key=request.session.session_key,
defaults={
"user": user,
"user_agent": request.headers.get("User-Agent", "")[:300],
"ip_address": client_ip(request),
},
)
class LoginView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
user = authenticate(
request,
username=request.data.get("username", ""),
password=request.data.get("password", ""),
)
if user is None or not user.is_active:
return Response(
{"error": {"code": "invalid_credentials", "message": "用户名或密码错误"}},
status=status.HTTP_400_BAD_REQUEST,
)
login(request, user)
RegisterView._record_session(request, user)
return Response(UserSerializer(user).data)
class LogoutView(APIView):
def post(self, request):
if request.session.session_key:
UserSession.objects.filter(session_key=request.session.session_key).delete()
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
class MeView(APIView):
def get(self, request):
return Response(UserSerializer(request.user).data)
def patch(self, request):
serializer = UserSerializer(request.user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class VisitorMigrationView(APIView):
def get(self, request):
migration = VisitorMigration.objects.filter(user=request.user).first()
if migration is None:
return Response({"pending": False, "summary": {}})
return Response(
{
"pending": migration.status == VisitorMigration.Status.PENDING,
"status": migration.status,
"summary": migration.summary,
}
)
def post(self, request):
migration = VisitorMigration.objects.filter(user=request.user).first()
if migration is None:
return Response(
{"error": {"code": "no_visitor_data", "message": "没有待迁移的游客数据"}},
status=status.HTTP_404_NOT_FOUND,
)
migration = apply_visitor_migration(request.user)
return Response(
{
"pending": False,
"status": migration.status,
"summary": migration.summary,
}
)