@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user