diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 07bef71e..783800c8 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -38,12 +38,13 @@
"RunOnceActivity.OpenDjangoStructureViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
- "git-widget-placeholder": "zyl__branch",
+ "git-widget-placeholder": "master",
"ignore.virus.scanning.warn.message": "true",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
+ "nodejs_package_manager_path": "npm",
"vue.rearranger.settings.migration": "true"
}
}]]>
@@ -90,6 +91,7 @@
1760256904468
+
diff --git a/src/accounts/accounts/__init__.py b/src/accounts/accounts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc
new file mode 100644
index 00000000..b1777587
Binary files /dev/null and b/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/admin.cpython-310.pyc b/src/accounts/accounts/__pycache__/admin.cpython-310.pyc
new file mode 100644
index 00000000..14e3399a
Binary files /dev/null and b/src/accounts/accounts/__pycache__/admin.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/apps.cpython-310.pyc b/src/accounts/accounts/__pycache__/apps.cpython-310.pyc
new file mode 100644
index 00000000..f5bfa32b
Binary files /dev/null and b/src/accounts/accounts/__pycache__/apps.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/forms.cpython-310.pyc b/src/accounts/accounts/__pycache__/forms.cpython-310.pyc
new file mode 100644
index 00000000..1ee6bd7d
Binary files /dev/null and b/src/accounts/accounts/__pycache__/forms.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/models.cpython-310.pyc b/src/accounts/accounts/__pycache__/models.cpython-310.pyc
new file mode 100644
index 00000000..9420b88f
Binary files /dev/null and b/src/accounts/accounts/__pycache__/models.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/urls.cpython-310.pyc b/src/accounts/accounts/__pycache__/urls.cpython-310.pyc
new file mode 100644
index 00000000..06f8bc05
Binary files /dev/null and b/src/accounts/accounts/__pycache__/urls.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc b/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc
new file mode 100644
index 00000000..92350098
Binary files /dev/null and b/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/utils.cpython-310.pyc b/src/accounts/accounts/__pycache__/utils.cpython-310.pyc
new file mode 100644
index 00000000..72874a87
Binary files /dev/null and b/src/accounts/accounts/__pycache__/utils.cpython-310.pyc differ
diff --git a/src/accounts/accounts/__pycache__/views.cpython-310.pyc b/src/accounts/accounts/__pycache__/views.cpython-310.pyc
new file mode 100644
index 00000000..d2002e2b
Binary files /dev/null and b/src/accounts/accounts/__pycache__/views.cpython-310.pyc differ
diff --git a/src/accounts/accounts/admin.py b/src/accounts/accounts/admin.py
new file mode 100644
index 00000000..1a6c0345
--- /dev/null
+++ b/src/accounts/accounts/admin.py
@@ -0,0 +1,101 @@
+from django import forms
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.forms import UserChangeForm
+from django.contrib.auth.forms import UsernameField
+from django.utils.translation import gettext_lazy as _
+
+# 模块级注释——accounts应用的Admin后台表单配置文件,
+# 自定义BlogUser(自定义用户模型)的创建表单、修改表单,以及Admin后台管理配置,
+# 适配Django Admin的用户管理逻辑,支持自定义字段(如nickname、source)的后台操作
+from .models import BlogUser
+
+
+class BlogUserCreationForm(forms.ModelForm):
+ """
+ 自定义用户创建表单(用于Django Admin后台添加新用户)
+ 扩展默认表单,增加密码二次验证逻辑,适配BlogUser模型的字段要求
+ """
+ # 密码输入字段:label支持国际化,使用密码输入控件(隐藏输入内容)
+ password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
+ # 密码二次确认字段:用于验证两次输入密码一致
+ password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
+
+ class Meta:
+ model = BlogUser # 关联的模型:accounts应用的BlogUser(自定义用户模型)
+ fields = ('email',) # 后台创建用户时,必填的核心字段(仅邮箱,用户名可后续补充或自动生成)
+
+ def clean_password2(self):
+ """
+ 密码二次验证的清洁方法(Django表单验证机制)
+ 检查两次输入的密码是否一致,不一致则抛出验证错误
+ """
+ # 获取第一次和第二次输入的密码(已通过表单基础验证的清洁数据)
+ password1 = self.cleaned_data.get("password1")
+ password2 = self.cleaned_data.get("password2")
+ # 若两次密码均存在且不一致,抛出验证错误(错误信息支持国际化)
+ if password1 and password2 and password1 != password2:
+ raise forms.ValidationError(_("passwords do not match"))
+ # 验证通过,返回第二次输入的密码(用于后续保存)
+ return password2
+
+ def save(self, commit=True):
+ """
+ 重写保存方法,实现密码哈希存储和创建来源标记
+ Django默认会对密码进行哈希处理,此处明确调用set_password确保安全性
+ """
+ # 调用父类save方法,先不提交到数据库(commit=False)
+ user = super().save(commit=False)
+ # 对密码进行哈希处理后存储(避免明文存储,符合Django安全规范)
+ user.set_password(self.cleaned_data["password1"])
+ # 若需要提交到数据库(默认commit=True)
+ if commit:
+ user.source = 'adminsite' # 标记用户创建来源:Django Admin后台
+ user.save() # 最终保存用户数据到数据库
+ return user
+
+
+class BlogUserChangeForm(UserChangeForm):
+ """
+ 自定义用户修改表单(用于Django Admin后台编辑用户信息)
+ 继承Django内置UserChangeForm,适配BlogUser模型的所有字段
+ """
+ class Meta:
+ model = BlogUser # 关联的模型:accounts应用的BlogUser
+ fields = '__all__' # 后台可修改的字段:所有模型字段(支持自定义扩展字段)
+ field_classes = {'username': UsernameField} # 用户名字段的类:使用Django内置UsernameField(确保符合用户名验证规则)
+
+ def __init__(self, *args, **kwargs):
+ """
+ 初始化表单,调用父类构造方法保持默认逻辑
+ 若后续需要扩展表单初始化行为(如隐藏字段、设置默认值),可在此方法中添加
+ """
+ super().__init__(*args, **kwargs)
+
+
+class BlogUserAdmin(UserAdmin):
+ """
+ 自定义UserAdmin配置(用于Django Admin后台管理BlogUser模型)
+ 配置后台显示字段、排序规则、搜索字段等,优化用户管理体验
+ """
+ form = BlogUserChangeForm # 关联用户修改表单:使用自定义的BlogUserChangeForm
+ add_form = BlogUserCreationForm # 关联用户创建表单:使用自定义的BlogUserCreationForm
+
+ # 后台列表页显示的字段(按业务优先级排序)
+ list_display = (
+ 'id', # 用户ID(唯一标识)
+ 'nickname', # 用户昵称(自定义扩展字段)
+ 'username', # 用户名(Django用户模型核心字段)
+ 'email', # 邮箱(用于登录和通知,核心字段)
+ 'last_login', # 最后登录时间(安全审计字段)
+ 'date_joined', # 注册时间(业务统计字段)
+ 'source' # 创建来源(区分后台创建/前台注册等,自定义扩展字段)
+ )
+
+ # 后台列表页可点击跳转的字段(用于快速进入编辑页)
+ list_display_links = ('id', 'username')
+
+ # 后台列表页默认排序规则:按ID倒序(新创建的用户排在前面)
+ ordering = ('-id',)
+
+ # 后台搜索支持的字段(支持模糊查询,提升管理效率)
+ search_fields = ('username', 'nickname', 'email')
\ No newline at end of file
diff --git a/src/accounts/accounts/apps.py b/src/accounts/accounts/apps.py
new file mode 100644
index 00000000..92080f74
--- /dev/null
+++ b/src/accounts/accounts/apps.py
@@ -0,0 +1,12 @@
+from django.apps import AppConfig
+
+# 模块级注释——accounts应用的核心配置类文件,
+# 继承Django内置的AppConfig,用于定义应用的基本元信息,
+# 是Django识别和加载accounts应用的关键配置
+class AccountsConfig(AppConfig):
+ """
+ accounts应用的配置类,用于注册应用的核心信息
+ 遵循Django应用配置规范,定义应用的唯一标识名称
+ """
+ name = 'accounts' # 应用的唯一标识名称,与应用目录名一致,
+ # Django通过该名称识别并加载应用,关联应用内的模型、视图等组件
\ No newline at end of file
diff --git a/src/accounts/accounts/forms.py b/src/accounts/accounts/forms.py
new file mode 100644
index 00000000..62f5a88f
--- /dev/null
+++ b/src/accounts/accounts/forms.py
@@ -0,0 +1,166 @@
+from django import forms
+from django.contrib.auth import get_user_model, password_validation
+from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
+from django.core.exceptions import ValidationError
+from django.forms import widgets
+from django.utils.translation import gettext_lazy as _
+from . import utils # 导入自定义工具模块(用于验证码验证等功能)
+from .models import BlogUser # 导入自定义用户模型
+
+# 模块级注释——accounts应用的前台用户交互表单配置文件,
+# 包含登录、注册、忘记密码、验证码获取等核心业务表单,
+# 负责用户输入数据的验证、前端样式适配(如表单控件class、占位符),
+# 确保用户输入合法且符合业务规则(如邮箱唯一性、密码强度、验证码有效性)
+
+
+class LoginForm(AuthenticationForm):
+ """
+ 前台用户登录表单,继承Django内置AuthenticationForm
+ 重写表单控件样式和占位符,适配前端页面布局,提升用户体验
+ """
+ def __init__(self, *args, **kwargs):
+ """
+ 初始化登录表单,重写用户名和密码字段的控件配置
+ """
+ super(LoginForm, self).__init__(*args, **kwargs)
+ # 用户名输入框:设置占位符和Bootstrap表单样式类,适配前端页面
+ self.fields['username'].widget = widgets.TextInput(
+ attrs={'placeholder': "username", "class": "form-control"})
+ # 密码输入框:设置占位符和Bootstrap表单样式类,使用密码隐藏控件
+ self.fields['password'].widget = widgets.PasswordInput(
+ attrs={'placeholder': "password", "class": "form-control"})
+
+
+class RegisterForm(UserCreationForm):
+ """
+ 前台用户注册表单,继承Django内置UserCreationForm
+ 扩展邮箱字段验证,重写表单控件样式,确保注册数据合法(用户名、邮箱唯一)
+ """
+ def __init__(self, *args, **kwargs):
+ """
+ 初始化注册表单,重写用户名、邮箱、密码字段的控件配置
+ """
+ super(RegisterForm, self).__init__(*args, **kwargs)
+
+ # 用户名输入框:占位符+Bootstrap样式
+ self.fields['username'].widget = widgets.TextInput(
+ attrs={'placeholder': "username", "class": "form-control"})
+ # 邮箱输入框:使用EmailInput控件,占位符+Bootstrap样式
+ self.fields['email'].widget = widgets.EmailInput(
+ attrs={'placeholder': "email", "class": "form-control"})
+ # 密码输入框1:密码隐藏控件,占位符+Bootstrap样式
+ self.fields['password1'].widget = widgets.PasswordInput(
+ attrs={'placeholder': "password", "class": "form-control"})
+ # 密码确认框:密码隐藏控件,占位符+Bootstrap样式
+ self.fields['password2'].widget = widgets.PasswordInput(
+ attrs={'placeholder': "repeat password", "class": "form-control"})
+
+ def clean_email(self):
+ """
+ 邮箱字段清洁验证:检查邮箱是否已被注册
+ 若已存在则抛出验证错误,确保邮箱唯一性
+ """
+ email = self.cleaned_data['email'] # 获取经过基础验证的邮箱数据
+ # 查询数据库,判断该邮箱是否已关联用户
+ if get_user_model().objects.filter(email=email).exists():
+ raise ValidationError(_("email already exists")) # 抛出国际化的验证错误
+ return email # 验证通过,返回邮箱数据
+
+ class Meta:
+ model = get_user_model() # 关联Django当前激活的用户模型(此处为BlogUser)
+ fields = ("username", "email") # 注册表单需填写的核心字段:用户名、邮箱(密码字段由父类提供)
+
+
+class ForgetPasswordForm(forms.Form):
+ """
+ 前台用户忘记密码重置表单
+ 包含新密码、密码确认、邮箱、验证码字段,实现密码重置的全流程验证
+ """
+ new_password1 = forms.CharField(
+ label=_("New password"), # 字段标签(支持国际化)
+ widget=forms.PasswordInput(
+ attrs={
+ "class": "form-control", # Bootstrap表单样式类
+ 'placeholder': _("New password") # 占位符(支持国际化)
+ }
+ ),
+ )
+
+ new_password2 = forms.CharField(
+ label="确认密码", # 密码确认字段标签
+ widget=forms.PasswordInput(
+ attrs={
+ "class": "form-control",
+ 'placeholder': _("Confirm password") # 占位符(支持国际化)
+ }
+ ),
+ )
+
+ email = forms.EmailField(
+ label='邮箱', # 邮箱字段标签
+ widget=forms.TextInput(
+ attrs={
+ 'class': 'form-control',
+ 'placeholder': _("Email") # 占位符(支持国际化)
+ }
+ ),
+ )
+
+ code = forms.CharField(
+ label=_('Code'), # 验证码字段标签(支持国际化)
+ widget=forms.TextInput(
+ attrs={
+ 'class': 'form-control',
+ 'placeholder': _("Code") # 占位符(支持国际化)
+ }
+ ),
+ )
+
+ def clean_new_password2(self):
+ """
+ 密码确认字段清洁验证:
+ 1. 检查两次输入的新密码是否一致
+ 2. 验证密码是否符合Django密码强度规则(如长度、复杂度)
+ """
+ password1 = self.data.get("new_password1") # 获取第一次输入的新密码
+ password2 = self.data.get("new_password2") # 获取第二次输入的确认密码
+ # 验证两次密码是否一致
+ if password1 and password2 and password1 != password2:
+ raise ValidationError(_("passwords do not match")) # 抛出密码不匹配错误
+ # 调用Django内置密码验证器,检查密码强度
+ password_validation.validate_password(password2)
+ return password2 # 验证通过,返回确认密码
+
+ def clean_email(self):
+ """
+ 邮箱字段清洁验证:检查输入的邮箱是否已注册
+ 若未注册则抛出错误,确保只有已注册用户能重置密码
+ """
+ user_email = self.cleaned_data.get("email") # 获取经过基础验证的邮箱
+ # 查询数据库,判断邮箱是否关联BlogUser
+ if not BlogUser.objects.filter(email=user_email).exists():
+ # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改(原代码注释保留,提示后续优化隐私保护)
+ raise ValidationError(_("email does not exist")) # 抛出邮箱未注册错误
+ return user_email # 验证通过,返回邮箱
+
+ def clean_code(self):
+ """
+ 验证码字段清洁验证:调用utils模块的verify方法验证验证码有效性
+ 若验证码无效(如过期、不匹配)则抛出错误
+ """
+ code = self.cleaned_data.get("code") # 获取用户输入的验证码
+ # 调用工具函数验证验证码(传入邮箱和验证码,返回错误信息或None)
+ error = utils.verify(email=self.cleaned_data.get("email"), code=code)
+ if error: # 若存在错误信息,抛出验证错误
+ raise ValidationError(error)
+ return code # 验证通过,返回验证码
+
+
+class ForgetPasswordCodeForm(forms.Form):
+ """
+ 前台用户获取忘记密码验证码的表单
+ 仅包含邮箱字段,用于接收用户邮箱并发送验证码
+ """
+ email = forms.EmailField(
+ label=_('Email'), # 字段标签(支持国际化)
+ )
\ No newline at end of file
diff --git a/src/accounts/accounts/migrations/0001_initial.py b/src/accounts/accounts/migrations/0001_initial.py
new file mode 100644
index 00000000..23ba59f7
--- /dev/null
+++ b/src/accounts/accounts/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+#模块级注释——comments应用的初始数据库迁移文件,用于创建`Comment`模型,实现文章评论功能,支持评论层级、文章关联、用户关联等业务逻辑
+class Migration(migrations.Migration):
+
+ initial = True # xxx: 标记该迁移为应用的初始迁移
+
+ dependencies = [
+ ('blog', '0001_initial'), # xxx: 依赖`blog`应用的`0001_initial`迁移,确保`Article`模型已存在
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL), # xxx: 依赖Django可交换的用户模型,支持自定义用户模型场景
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Comment', # xxx: 定义`Comment`模型,用于存储文章评论数据
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # 主键字段,自增大整数类型
+ ('body', models.TextField(max_length=300, verbose_name='正文')), # xxx: 评论正文字段,文本类型,最大长度300
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # 评论创建时间字段,默认当前时间
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # xxx: 评论修改时间字段,默认当前时间
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), # xxx: 控制评论是否显示的布尔字段,默认显示
+ ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), # xxx: 外键关联`blog`应用的`Article`模型,文章删除时评论级联删除
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), # xxx: 外键关联用户模型,用户删除时评论级联删除
+ ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), # xxx: 自外键关联,支持评论层级结构,允许空值,上级评论删除时当前评论级联删除
+ ],
+ options={
+ 'verbose_name': '评论', # xxx: 模型单数显示名称
+ 'verbose_name_plural': '评论', # xxx: 模型复数显示名称
+ 'ordering': ['-id'], # xxx: 数据查询时按ID倒序排列
+ 'get_latest_by': 'id', # xxx: 按ID字段获取最新记录
+ },
+ ),
+ ]
\ No newline at end of file
diff --git a/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
new file mode 100644
index 00000000..0c8bccc9
--- /dev/null
+++ b/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
@@ -0,0 +1,55 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.db import migrations, models
+import django.utils.timezone
+
+# 模块级注释——accounts应用的模型更新迁移文件,用于调整`BlogUser`模型的选项、字段名称及属性,
+# 优化字段命名规范(如时间字段命名统一)、完善字段配置(如允许空值),确保模型设计更规范
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0001_initial'), # 依赖`accounts`应用的初始迁移`0001_initial`,确保`BlogUser`模型已创建
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='bloguser', # 目标模型:`accounts`应用的`BlogUser`(自定义用户模型)
+ options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
+ # 调整模型选项:
+ # 1. get_latest_by: 按`id`字段获取最新记录
+ # 2. ordering: 查询时按`id`倒序排列(新用户在前)
+ # 3. verbose_name/verbose_name_plural: 模型单复数显示名称均为"user"
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='created_time', # 删除原有的"创建时间"字段(字段名称规范调整,后续用`creation_time`替代)
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='last_mod_time', # 删除原有的"修改时间"字段(字段名称规范调整,后续用`last_modify_time`替代)
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='creation_time', # 新增标准化的"创建时间"字段(替代原`created_time`)
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ # 字段配置:默认值为当前时间,后台显示名称为"creation time"
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='last_modify_time', # 新增标准化的"修改时间"字段(替代原`last_mod_time`)
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ # 字段配置:默认值为当前时间,后台显示名称为"last modify time"
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='nickname', # 调整`nickname`(昵称)字段属性
+ field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
+ # 调整内容:允许空值(blank=True),最大长度100,后台显示名称为"nick name"
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='source', # 调整`source`(创建来源)字段属性
+ field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
+ # 调整内容:允许空值(blank=True),最大长度100,后台显示名称为"create source"
+ ),
+ ]
\ No newline at end of file
diff --git a/src/accounts/accounts/migrations/__init__.py b/src/accounts/accounts/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc
new file mode 100644
index 00000000..7f3ec25b
Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc differ
diff --git a/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc
new file mode 100644
index 00000000..75303bdb
Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc differ
diff --git a/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc
new file mode 100644
index 00000000..9dcf2880
Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc differ
diff --git a/src/accounts/accounts/models.py b/src/accounts/accounts/models.py
new file mode 100644
index 00000000..d194f1b3
--- /dev/null
+++ b/src/accounts/accounts/models.py
@@ -0,0 +1,60 @@
+from django.contrib.auth.models import AbstractUser
+from django.db import models
+from django.urls import reverse
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+from djangoblog.utils import get_current_site # 导入项目公共工具函数(获取当前站点域名)
+
+# 模块级注释——accounts应用的核心数据模型文件,
+# 定义自定义用户模型`BlogUser`,继承Django内置`AbstractUser`,
+# 扩展业务所需的自定义字段(如昵称、创建时间、创建来源),
+# 并重写核心方法以适配项目业务逻辑(如用户URL生成、字符串表示)
+# Create your models here.
+
+
+class BlogUser(AbstractUser):
+ """
+ 自定义用户模型,继承Django内置`AbstractUser`(保留用户名、密码、邮箱等核心字段)
+ 扩展项目所需的业务字段,适配博客系统的用户管理需求,支持国际化配置
+ """
+ # 昵称字段:支持国际化标签,最大长度100,允许空值(用户可选择不设置昵称)
+ nickname = models.CharField(_('nick name'), max_length=100, blank=True)
+ # 创建时间字段:支持国际化标签,默认值为当前时间,记录用户注册时间
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ # 最后修改时间字段:支持国际化标签,默认值为当前时间,记录用户信息最后更新时间
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
+ # 创建来源字段:支持国际化标签,最大长度100,允许空值,用于标记用户注册渠道(如"adminsite"/"frontend"/"oauth")
+ source = models.CharField(_('create source'), max_length=100, blank=True)
+
+ def get_absolute_url(self):
+ """
+ 重写Django模型的`get_absolute_url`方法,获取用户的绝对路径URL
+ 关联博客系统的"作者详情页"路由,用于直接访问用户的个人主页
+ """
+ return reverse(
+ 'blog:author_detail', # 路由名称(对应blog应用的作者详情页路由)
+ kwargs={'author_name': self.username} # 路由参数:用户名(作为作者标识)
+ )
+
+ def __str__(self):
+ """
+ 重写模型的字符串表示方法,返回用户邮箱作为标识
+ 相比默认的用户名,邮箱更具唯一性,便于后台管理和日志输出时识别用户
+ """
+ return self.email
+
+ def get_full_url(self):
+ """
+ 扩展方法:获取用户个人主页的完整URL(包含站点域名)
+ 用于需要分享用户主页的场景(如邮件通知、第三方分享)
+ """
+ site = get_current_site().domain # 通过公共工具函数获取当前站点的域名(如"example.com")
+ # 拼接域名和用户绝对路径,生成完整URL(支持HTTPS协议)
+ url = "https://{site}{path}".format(site=site, path=self.get_absolute_url())
+ return url
+
+ class Meta:
+ ordering = ['-id'] # 数据查询时默认按ID倒序排列(新注册用户优先展示)
+ verbose_name = _('user') # 模型单数显示名称(支持国际化)
+ verbose_name_plural = verbose_name # 模型复数显示名称(与单数一致,简化管理)
+ get_latest_by = 'id' # 按ID字段获取最新创建的用户记录
\ No newline at end of file
diff --git a/src/accounts/accounts/templatetags/__init__.py b/src/accounts/accounts/templatetags/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc
new file mode 100644
index 00000000..56ee8dec
Binary files /dev/null and b/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc differ
diff --git a/src/accounts/accounts/tests.py b/src/accounts/accounts/tests.py
new file mode 100644
index 00000000..23e437db
--- /dev/null
+++ b/src/accounts/accounts/tests.py
@@ -0,0 +1,296 @@
+from django.test import Client, RequestFactory, TestCase
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+
+from accounts.models import BlogUser
+from blog.models import Article, Category
+from djangoblog.utils import * # 导入项目公共工具函数(如加密、缓存操作等)
+from . import utils # 导入accounts应用自定义工具函数(如验证码设置/验证、邮件发送)
+
+
+# 模块级注释——accounts应用的单元测试文件,
+# 覆盖用户核心业务流程的测试场景:用户登录、注册、邮箱验证码验证、忘记密码重置等,
+# 基于Django TestCase框架,通过模拟HTTP请求和数据库操作,验证业务逻辑的正确性,
+# 确保用户相关功能稳定可用(如权限控制、数据一致性、错误处理)
+# Create your tests here.
+
+
+class AccountTest(TestCase):
+ """
+ 用户相关核心功能测试类,继承Django TestCase
+ 集中测试用户登录、注册、验证码验证、忘记密码等关键流程,
+ 每个测试方法对应一个独立的业务场景,确保测试隔离性
+ """
+
+ def setUp(self):
+ """
+ 测试初始化方法(每个测试方法执行前自动调用)
+ 初始化测试所需的核心对象和测试数据,避免重复代码
+ """
+ self.client = Client() # 模拟HTTP客户端,用于发送GET/POST请求
+ self.factory = RequestFactory() # 请求工厂,用于构建自定义请求对象(本测试未直接使用)
+ # 创建普通测试用户(用于后续登录、密码重置等测试)
+ self.blog_user = BlogUser.objects.create_user(
+ username="test", # 用户名
+ email="admin@admin.com", # 邮箱
+ password="12345678" # 密码(明文,create_user会自动哈希存储)
+ )
+ self.new_test = "xxx123--=" # 测试用新密码(用于忘记密码重置场景)
+
+ def test_validate_account(self):
+ """
+ 测试用户登录、管理员权限、文章管理访问流程
+ 验证点:超级用户登录成功、管理员页面访问权限、文章创建后管理页访问权限
+ """
+ # 获取当前站点域名(用于URL生成,本测试未直接使用)
+ site = get_current_site().domain
+ # 创建超级用户(用于测试管理员权限)
+ user = BlogUser.objects.create_superuser(
+ email="liangliangyy1@gmail.com",
+ username="liangliangyy1",
+ password="qwer!@#$ggg") # 超级用户密码
+ # 根据用户名查询创建的超级用户(验证用户创建成功)
+ testuser = BlogUser.objects.get(username='liangliangyy1')
+
+ # 模拟超级用户登录,验证登录结果是否成功
+ loginresult = self.client.login(
+ username='liangliangyy1',
+ password='qwer!@#$ggg')
+ self.assertEqual(loginresult, True) # 断言登录成功
+
+ # 访问管理员首页,验证是否有权限(状态码200表示访问成功)
+ response = self.client.get('/admin/')
+ self.assertEqual(response.status_code, 200)
+
+ # 创建测试分类(用于后续文章关联)
+ category = Category()
+ category.name = "categoryaaa" # 分类名称
+ category.creation_time = timezone.now() # 创建时间
+ category.last_modify_time = timezone.now() # 最后修改时间
+ category.save() # 保存到数据库
+
+ # 创建测试文章(关联超级用户和分类)
+ article = Article()
+ article.title = "nicetitleaaa" # 文章标题
+ article.body = "nicecontentaaa" # 文章内容
+ article.author = user # 文章作者(超级用户)
+ article.category = category # 文章分类
+ article.type = 'a' # 文章类型(推测为"article"普通文章)
+ article.status = 'p' # 文章状态(推测为"published"已发布)
+ article.save() # 保存到数据库
+
+ # 访问文章的管理员编辑页,验证超级用户是否有权限
+ response = self.client.get(article.get_admin_url())
+ self.assertEqual(response.status_code, 200) # 断言访问成功
+
+ def test_validate_register(self):
+ """
+ 测试用户注册完整流程
+ 验证点:注册提交、用户创建、验证邮件链接访问、登录后权限升级、文章创建、退出登录、错误密码登录
+ """
+ # 注册前检查目标邮箱是否存在(预期不存在,计数为0)
+ self.assertEquals(
+ 0, len(
+ BlogUser.objects.filter(
+ email='user123@user.com')))
+
+ # 模拟POST请求提交注册表单(调用注册路由)
+ response = self.client.post(reverse('account:register'), {
+ 'username': 'user1233', # 注册用户名
+ 'email': 'user123@user.com', # 注册邮箱
+ 'password1': 'password123!q@wE#R$T', # 注册密码(符合强度要求)
+ 'password2': 'password123!q@wE#R$T', # 密码确认(与密码一致)
+ })
+
+ # 注册后检查目标邮箱是否创建成功(预期计数为1)
+ self.assertEquals(
+ 1, len(
+ BlogUser.objects.filter(
+ email='user123@user.com')))
+
+ # 获取刚注册的用户,生成邮箱验证链接(基于用户ID和加密签名)
+ user = BlogUser.objects.filter(email='user123@user.com')[0]
+ # 生成验证签名(双重SHA256加密,结合SECRET_KEY和用户ID,确保链接安全性)
+ sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+ path = reverse('accounts:result') # 验证结果页路由
+ # 拼接完整的验证链接(包含用户ID和签名)
+ url = '{path}?type=validation&id={id}&sign={sign}'.format(
+ path=path, id=user.id, sign=sign)
+ # 访问验证链接,验证页面是否正常响应(状态码200)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ # 使用注册的用户名和密码登录
+ self.client.login(username='user1233', password='password123!q@wE#R$T')
+ # 将该用户升级为超级用户(用于测试管理员权限)
+ user = BlogUser.objects.filter(email='user123@user.com')[0]
+ user.is_superuser = True # 设为超级用户
+ user.is_staff = True # 允许访问admin后台
+ user.save() # 保存权限变更
+
+ delete_sidebar_cache() # 清除侧边栏缓存(项目自定义缓存操作,避免缓存影响测试)
+
+ # 创建测试分类(用于关联文章)
+ category = Category()
+ category.name = "categoryaaa"
+ category.creation_time = timezone.now()
+ category.last_modify_time = timezone.now()
+ category.save()
+
+ # 创建测试文章(关联升级后的超级用户)
+ article = Article()
+ article.category = category
+ article.title = "nicetitle333"
+ article.body = "nicecontentttt"
+ article.author = user
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+
+ # 访问文章管理页,验证升级后的用户是否有权限
+ response = self.client.get(article.get_admin_url())
+ self.assertEqual(response.status_code, 200)
+
+ # 模拟退出登录
+ response = self.client.get(reverse('account:logout'))
+ # 断言退出登录响应状态码(301/302为跳转,200为成功响应,均符合预期)
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ # 退出后访问文章管理页(预期无权限,可能跳转登录页)
+ response = self.client.get(article.get_admin_url())
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ # 使用错误密码尝试登录
+ response = self.client.post(reverse('account:login'), {
+ 'username': 'user1233',
+ 'password': 'password123' # 错误密码(原密码为password123!q@wE#R$T)
+ })
+ # 断言登录失败后的响应状态码(跳转或重新显示登录页)
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ # 错误密码登录后访问文章管理页(预期无权限)
+ response = self.client.get(article.get_admin_url())
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ def test_verify_email_code(self):
+ """
+ 测试邮箱验证码的验证逻辑
+ 验证点:正确邮箱+正确验证码验证成功、错误邮箱+正确验证码验证失败
+ """
+ to_email = "admin@admin.com" # 测试邮箱(已在setUp中创建关联用户)
+ code = generate_code() # 生成随机验证码(项目工具函数)
+ utils.set_code(to_email, code) # 存储验证码(推测基于缓存或数据库存储)
+ utils.send_verify_email(to_email, code) # 发送验证邮件(模拟邮件发送流程)
+
+ # 验证:正确邮箱+正确验证码 → 预期无错误(返回None)
+ err = utils.verify("admin@admin.com", code)
+ self.assertEqual(err, None)
+
+ # 验证:错误邮箱+正确验证码 → 预期返回错误信息(字符串类型)
+ err = utils.verify("admin@123.com", code)
+ self.assertEqual(type(err), str)
+
+ def test_forget_password_email_code_success(self):
+ """
+ 测试获取忘记密码验证码的成功场景
+ 验证点:输入已注册邮箱,成功获取验证码(响应"ok")
+ """
+ # 模拟POST请求提交邮箱,获取忘记密码验证码
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"), # 忘记密码验证码路由
+ data=dict(email="admin@admin.com") # 已注册的测试邮箱
+ )
+
+ self.assertEqual(resp.status_code, 200) # 断言请求成功
+ self.assertEqual(resp.content.decode("utf-8"), "ok") # 断言响应内容为"ok"(表示验证码发送成功)
+
+ def test_forget_password_email_code_fail(self):
+ """
+ 测试获取忘记密码验证码的失败场景
+ 验证点:无邮箱输入、邮箱格式错误,均返回"错误的邮箱"
+ """
+ # 场景1:不传入邮箱参数 → 预期返回"错误的邮箱"
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict() # 空数据(无邮箱)
+ )
+ self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
+
+ # 场景2:传入格式错误的邮箱(无@后的域名后缀)→ 预期返回"错误的邮箱"
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict(email="admin@com") # 格式错误的邮箱
+ )
+ self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
+
+ def test_forget_password_email_success(self):
+ """
+ 测试忘记密码重置成功的场景
+ 验证点:输入正确邮箱、验证码、新密码,密码成功更新
+ """
+ code = generate_code() # 生成验证码
+ utils.set_code(self.blog_user.email, code) # 绑定验证码到测试用户邮箱
+ # 构建重置密码表单数据
+ data = dict(
+ new_password1=self.new_test, # 新密码
+ new_password2=self.new_test, # 新密码确认(与新密码一致)
+ email=self.blog_user.email, # 测试用户邮箱
+ code=code, # 正确的验证码
+ )
+ # 模拟POST请求提交重置密码表单
+ resp = self.client.post(
+ path=reverse("account:forget_password"), # 忘记密码重置路由
+ data=data
+ )
+ self.assertEqual(resp.status_code, 302) # 断言重置成功后跳转(302为重定向)
+
+ # 验证密码是否真正修改成功
+ blog_user = BlogUser.objects.filter(
+ email=self.blog_user.email,
+ ).first() # 获取测试用户
+ self.assertNotEqual(blog_user, None) # 断言用户存在
+ # 验证新密码是否匹配(check_password会自动哈希比对)
+ self.assertEqual(blog_user.check_password(data["new_password1"]), True)
+
+ def test_forget_password_email_not_user(self):
+ """
+ 测试忘记密码重置的失败场景:输入未注册的邮箱
+ 验证点:未注册邮箱提交重置请求,返回200状态码(表单重新显示错误)
+ """
+ # 构建重置密码表单数据(邮箱未注册)
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email="123@123.com", # 未注册的邮箱
+ code="123456", # 任意验证码
+ )
+ # 模拟POST请求提交重置密码表单
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+
+ self.assertEqual(resp.status_code, 200) # 断言返回表单页面(显示邮箱不存在错误)
+
+ def test_forget_password_email_code_error(self):
+ """
+ 测试忘记密码重置的失败场景:验证码错误
+ 验证点:输入正确邮箱、错误验证码,返回200状态码(表单重新显示错误)
+ """
+ code = generate_code() # 生成正确验证码并绑定到测试用户邮箱
+ utils.set_code(self.blog_user.email, code)
+ # 构建重置密码表单数据(验证码错误)
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email=self.blog_user.email, # 正确邮箱
+ code="111111", # 错误验证码(与绑定的code不一致)
+ )
+ # 模拟POST请求提交重置密码表单
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+
+ self.assertEqual(resp.status_code, 200) # 断言返回表单页面(显示验证码错误)
\ No newline at end of file
diff --git a/src/accounts/accounts/urls.py b/src/accounts/accounts/urls.py
new file mode 100644
index 00000000..22ec0df8
--- /dev/null
+++ b/src/accounts/accounts/urls.py
@@ -0,0 +1,36 @@
+from django.urls import path
+from django.urls import re_path
+
+from . import views # 导入accounts应用的视图模块(包含登录、注册等核心视图)
+from .forms import LoginForm # 导入自定义登录表单(适配前端样式和验证规则)
+
+app_name = "accounts" # 定义应用命名空间,用于反向解析URL时区分不同应用的路由(如`reverse('accounts:login')`)
+
+# URL路由配置:映射用户核心业务的URL路径到对应视图,覆盖登录、注册、退出、密码重置等功能
+urlpatterns = [
+ # 登录路由:使用正则表达式匹配`/login/`路径
+ re_path(r'^login/$',
+ views.LoginView.as_view(success_url='/'), # 关联视图类`LoginView`,登录成功后重定向到网站根目录
+ name='login', # 路由名称,用于反向解析(如模板中`{% url 'accounts:login' %}`)
+ kwargs={'authentication_form': LoginForm}), # 传入自定义登录表单`LoginForm`,替代默认表单
+ # 注册路由:匹配`/register/`路径
+ re_path(r'^register/$',
+ views.RegisterView.as_view(success_url="/"), # 关联视图类`RegisterView`,注册成功后重定向到网站根目录
+ name='register'), # 路由名称,用于反向解析注册页面URL
+ # 退出登录路由:匹配`/logout/`路径
+ re_path(r'^logout/$',
+ views.LogoutView.as_view(), # 关联视图类`LogoutView`(Django内置或自定义,处理退出登录逻辑)
+ name='logout'), # 路由名称,用于反向解析退出登录URL
+ # 账号操作结果页路由:精确匹配`/account/result.html`路径
+ path(r'account/result.html',
+ views.account_result, # 关联函数视图`account_result`,展示账号操作结果(如邮箱验证成功/失败)
+ name='result'), # 路由名称,用于反向解析结果页URL
+ # 忘记密码重置路由:匹配`/forget_password/`路径
+ re_path(r'^forget_password/$',
+ views.ForgetPasswordView.as_view(), # 关联视图类`ForgetPasswordView`,处理密码重置表单提交和验证
+ name='forget_password'), # 路由名称,用于反向解析忘记密码页面URL
+ # 忘记密码验证码路由:匹配`/forget_password_code/`路径
+ re_path(r'^forget_password_code/$',
+ views.ForgetPasswordEmailCode.as_view(), # 关联视图类`ForgetPasswordEmailCode`,处理验证码发送逻辑
+ name='forget_password_code'), # 路由名称,用于反向解析获取验证码的URL
+]
\ No newline at end of file
diff --git a/src/accounts/accounts/user_login_backend.py b/src/accounts/accounts/user_login_backend.py
new file mode 100644
index 00000000..7b6a58ca
--- /dev/null
+++ b/src/accounts/accounts/user_login_backend.py
@@ -0,0 +1,55 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.backends import ModelBackend
+
+
+# 模块级注释——accounts应用的自定义认证后端文件,
+# 继承Django内置ModelBackend,扩展支持"用户名或邮箱"双登录方式,
+# 适配项目中用户可能使用邮箱作为登录凭证的业务需求,保持与Django认证系统的兼容性
+class EmailOrUsernameModelBackend(ModelBackend):
+ """
+ 自定义用户认证后端,继承Django内置ModelBackend
+ 核心功能:允许用户使用「用户名」或「邮箱」两种方式登录,
+ 兼容Django默认认证流程,不破坏原有用户模型和权限体系
+ """
+
+ def authenticate(self, request, username=None, password=None, **kwargs):
+ """
+ 重写认证核心方法,实现用户名/邮箱双登录逻辑
+ :param request: HTTP请求对象(用于传递请求上下文,本方法未直接使用)
+ :param username: 登录输入的标识(可能是用户名或邮箱)
+ :param password: 登录输入的密码(明文,需通过check_password验证)
+ :param kwargs: 额外关键字参数(兼容Django认证系统的扩展需求)
+ :return: 认证成功返回BlogUser对象,失败返回None
+ """
+ # 判断输入的标识是否包含@符号:含@则视为邮箱登录,否则视为用户名登录
+ if '@' in username:
+ kwargs = {'email': username} # 构造邮箱查询条件
+ else:
+ kwargs = {'username': username} # 构造用户名查询条件
+
+ try:
+ # 根据查询条件(用户名/邮箱)从数据库获取用户对象
+ # get_user_model():动态获取当前项目激活的用户模型(此处为accounts.BlogUser)
+ user = get_user_model().objects.get(**kwargs)
+
+ # 验证输入的密码是否正确(check_password会自动比对明文与数据库中哈希后的密码)
+ if user.check_password(password):
+ return user # 密码验证通过,返回用户对象(认证成功)
+
+ # 捕获用户不存在的异常(查询不到时触发)
+ except get_user_model().DoesNotExist:
+ return None # 用户不存在,返回None(认证失败)
+
+ def get_user(self, username):
+ """
+ 重写用户获取方法,根据用户主键(pk)查询用户对象
+ 是Django认证后端必须实现的方法,用于认证成功后获取完整用户信息
+ :param username: 实际为用户的主键ID(Django认证系统默认传递pk作为参数)
+ :return: 查询成功返回BlogUser对象,失败返回None
+ """
+ try:
+ # 根据主键ID查询用户(get_user_model()确保兼容自定义用户模型)
+ return get_user_model().objects.get(pk=username)
+ # 捕获用户不存在的异常
+ except get_user_model().DoesNotExist:
+ return None # 用户不存在,返回None
\ No newline at end of file
diff --git a/src/accounts/accounts/utils.py b/src/accounts/accounts/utils.py
new file mode 100644
index 00000000..4513ef9e
--- /dev/null
+++ b/src/accounts/accounts/utils.py
@@ -0,0 +1,79 @@
+import typing
+from datetime import timedelta
+
+from django.core.cache import cache # Django缓存框架,用于存储验证码(内存/Redis等,由项目配置决定)
+from django.utils.translation import gettext
+from django.utils.translation import gettext_lazy as _ # 国际化支持,用于生成多语言提示文本
+
+from djangoblog.utils import send_email # 导入项目公共邮件发送工具函数
+
+_code_ttl = timedelta(minutes=5) # 验证码有效期:5分钟(全局常量,统一控制验证码过期时间)
+
+
+def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")):
+ """
+ 发送密码重置验证邮件(核心功能:向目标邮箱发送含验证码的邮件)
+ 邮件内容支持国际化,验证码有效期与全局常量`_code_ttl`保持一致(5分钟)
+
+ Args:
+ to_mail: 接收邮件的目标邮箱地址(字符串类型,需符合邮箱格式)
+ code: 生成的随机验证码(字符串类型,用于后续密码重置验证)
+ subject: 邮件主题(可选参数,默认值为国际化的"Verify Email",支持多语言切换)
+ """
+ # 构建邮件HTML内容(国际化模板,通过占位符注入验证码,明确告知有效期)
+ html_content = _(
+ "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it "
+ "properly") % {'code': code}
+ # 调用项目公共邮件发送函数,发送验证码邮件(收件人列表、主题、HTML内容)
+ send_email([to_mail], subject, html_content)
+
+
+def verify(email: str, code: str) -> typing.Optional[str]:
+ """
+ 验证验证码的有效性(核心功能:校验用户输入的验证码与缓存中存储的是否一致)
+
+ Args:
+ email: 待验证的邮箱地址(与验证码绑定的唯一标识,确保验证码针对性)
+ code: 用户输入的验证码(需与缓存中存储的验证码比对)
+
+ Return:
+ 验证失败返回错误提示字符串(支持国际化),验证成功返回None
+
+ Note:
+ 1. 原代码注释保留:当前错误处理逻辑不合理,建议改用raise抛出异常(而非返回错误字符串),
+ 便于调用方统一捕获和处理,减少错误处理冗余;
+ 2. 验证码校验逻辑:通过邮箱作为缓存key,获取存储的验证码,与输入值直接比对(大小写敏感);
+ 3. 若缓存中无该邮箱对应的验证码(如过期、未发送),则默认返回验证码错误提示。
+ """
+ # 从缓存中获取该邮箱对应的验证码(缓存key为邮箱地址,value为之前存储的验证码)
+ cache_code = get_code(email)
+ # 比对缓存中的验证码与用户输入的验证码,不一致则返回错误提示
+ if cache_code != code:
+ return gettext("Verification code error") # 国际化的验证码错误提示
+
+
+def set_code(email: str, code: str):
+ """
+ 将邮箱与验证码绑定并存储到缓存中(核心功能:为后续验证提供数据支持)
+ 存储时自动设置过期时间(与全局`_code_ttl`一致,5分钟),避免验证码永久有效
+
+ Args:
+ email: 验证码绑定的邮箱地址(作为缓存的唯一key,确保一对一关联)
+ code: 需存储的随机验证码(字符串类型,建议由`generate_code`等工具函数生成)
+ """
+ # 缓存存储:key=邮箱地址,value=验证码,timeout=有效期(秒数,由_timedelta转换)
+ cache.set(email, code, _code_ttl.seconds)
+
+
+def get_code(email: str) -> typing.Optional[str]:
+ """
+ 从缓存中获取指定邮箱对应的验证码(核心功能:为验证码校验提供数据源)
+
+ Args:
+ email: 目标邮箱地址(缓存key,用于精准获取绑定的验证码)
+
+ Return:
+ 缓存中存在该邮箱对应的验证码则返回字符串类型的验证码,否则返回None(如过期、未存储)
+ """
+ # 从缓存中获取值:key为邮箱地址,不存在或过期时返回None
+ return cache.get(email)
\ No newline at end of file
diff --git a/src/accounts/accounts/views.py b/src/accounts/accounts/views.py
new file mode 100644
index 00000000..c4e956af
--- /dev/null
+++ b/src/accounts/accounts/views.py
@@ -0,0 +1,306 @@
+import logging
+from django.utils.translation import gettext_lazy as _
+from django.conf import settings
+from django.contrib import auth
+from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.contrib.auth import get_user_model
+from django.contrib.auth import logout
+from django.contrib.auth.forms import AuthenticationForm
+from django.contrib.auth.hashers import make_password
+from django.http import HttpResponseRedirect, HttpResponseForbidden
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.shortcuts import render
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.utils.http import url_has_allowed_host_and_scheme
+from django.views import View
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.debug import sensitive_post_parameters
+from django.views.generic import FormView, RedirectView
+
+# 导入项目公共工具函数:邮件发送、加密、站点信息、验证码生成、缓存删除
+from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
+from . import utils # 导入accounts应用自定义工具(验证码发送/存储)
+from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm # 导入用户交互表单
+from .models import BlogUser # 导入自定义用户模型
+
+logger = logging.getLogger(__name__) # 初始化日志对象,用于记录视图层操作日志
+
+# 模块级注释——accounts应用的核心视图文件,
+# 包含用户注册、登录、退出、邮箱验证、密码重置等核心业务的视图实现,
+# 基于Django类视图(FormView/RedirectView/View)和函数视图,
+# 整合表单验证、邮件发送、缓存操作、权限控制等逻辑,处理用户交互的全流程
+# Create your views here.
+
+
+class RegisterView(FormView):
+ """
+ 用户注册视图,继承Django FormView(专门处理表单提交的类视图)
+ 核心功能:接收注册表单数据、验证合法性、创建未激活用户、发送邮箱验证链接、重定向到结果页
+ """
+ form_class = RegisterForm # 关联注册表单类(处理用户名、邮箱、密码验证)
+ template_name = 'account/registration_form.html' # 注册页面模板路径
+
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ """
+ 重写分发方法,添加CSRF防护装饰器
+ 防止跨站请求伪造攻击,确保注册请求来自本网站合法表单
+ """
+ return super(RegisterView, self).dispatch(*args, **kwargs)
+
+ def form_valid(self, form):
+ """
+ 表单验证通过后的核心处理逻辑(FormView的核心方法)
+ 流程:创建未激活用户 → 生成邮箱验证链接 → 发送验证邮件 → 重定向到注册结果页
+ """
+ if form.is_valid():
+ # 1. 表单验证通过,先不提交到数据库(commit=False),后续补充字段
+ user = form.save(False)
+ user.is_active = False # 默认设置用户为未激活状态(需邮箱验证后激活)
+ user.source = 'Register' # 标记用户创建来源:前台注册(区别于后台创建/第三方登录)
+ user.save(True) # 最终保存用户数据到数据库
+
+ # 2. 生成邮箱验证链接(包含站点域名、用户ID、加密签名,确保链接安全性)
+ site = get_current_site().domain # 获取当前站点域名(如"example.com")
+ # 双重SHA256加密:结合SECRET_KEY和用户ID,防止链接被篡改
+ sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+
+ # 开发环境适配:DEBUG模式下使用本地测试域名(127.0.0.1:8000)
+ if settings.DEBUG:
+ site = '127.0.0.1:8000'
+ path = reverse('account:result') # 验证结果页路由
+ # 拼接完整的验证链接(HTTP协议,开发环境可用;生产环境建议改为HTTPS)
+ url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
+ site=site, path=path, id=user.id, sign=sign)
+
+ # 3. 构建验证邮件内容(HTML格式,包含验证链接)
+ content = """
+
请点击下面链接验证您的邮箱
+
+ {url}
+
+ 再次感谢您!
+
+ 如果上面链接无法打开,请将此链接复制至浏览器。
+ {url}
+ """.format(url=url)
+ # 发送验证邮件到用户注册邮箱
+ send_email(
+ emailto=[user.email], # 收件人(注册时填写的邮箱)
+ title='验证您的电子邮箱', # 邮件标题
+ content=content) # 邮件HTML内容
+
+ # 4. 重定向到注册结果页(告知用户验证邮件已发送)
+ url = reverse('accounts:result') + '?type=register&id=' + str(user.id)
+ return HttpResponseRedirect(url)
+ else:
+ # 表单验证失败(如用户名已存在、密码不匹配),重新渲染注册页面并显示错误
+ return self.render_to_response({'form': form})
+
+
+class LogoutView(RedirectView):
+ """
+ 用户退出登录视图,继承Django RedirectView(专门处理重定向的类视图)
+ 核心功能:执行退出登录逻辑、清理缓存、重定向到登录页
+ """
+ url = '/login/' # 退出后重定向的目标URL(登录页)
+
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ """
+ 重写分发方法,添加never_cache装饰器
+ 禁止浏览器缓存退出页,避免用户后退到已登录状态
+ """
+ return super(LogoutView, self).dispatch(request, *args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ """
+ 处理GET请求(退出登录请求)
+ 流程:执行退出登录 → 清理侧边栏缓存 → 重定向到登录页
+ """
+ logout(request) # Django内置logout函数:清除用户会话,销毁登录状态
+ delete_sidebar_cache() # 清除侧边栏缓存(避免缓存中保留用户相关信息)
+ return super(LogoutView, self).get(request, *args, **kwargs)
+
+
+class LoginView(FormView):
+ """
+ 用户登录视图,继承Django FormView
+ 核心功能:接收登录表单数据、验证合法性、执行登录逻辑、处理"记住我"、重定向到目标页面
+ """
+ form_class = LoginForm # 关联自定义登录表单(适配前端样式)
+ template_name = 'account/login.html' # 登录页面模板路径
+ success_url = '/' # 登录成功后的默认重定向URL(网站根目录)
+ redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名(默认'redirect_to',用于跳转前的目标页面)
+ login_ttl = 2626560 # "记住我"的会话有效期:2626560秒 ≈ 1个月
+
+ @method_decorator(sensitive_post_parameters('password'))
+ @method_decorator(csrf_protect)
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ """
+ 重写分发方法,添加3个核心装饰器:
+ 1. sensitive_post_parameters('password'):保护密码字段,避免在错误报告中泄露
+ 2. csrf_protect:防跨站请求伪造攻击
+ 3. never_cache:禁止浏览器缓存登录页,确保每次请求都是最新状态
+ """
+ return super(LoginView, self).dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ """
+ 补充模板上下文数据(传递重定向地址给模板)
+ 用于登录成功后跳转到登录前的目标页面(如访问需要登录的页面时,先跳转登录,登录后返回原页面)
+ """
+ # 从GET请求中获取重定向地址(如?redirect_to=/article/1/)
+ redirect_to = self.request.GET.get(self.redirect_field_name)
+ if redirect_to is None:
+ redirect_to = '/' # 无重定向地址时,默认跳转到根目录
+ kwargs['redirect_to'] = redirect_to # 将重定向地址添加到上下文
+ return super(LoginView, self).get_context_data(** kwargs)
+
+ def form_valid(self, form):
+ """
+ 表单验证通过后的核心登录逻辑
+ 流程:验证表单 → 清理缓存 → 执行登录 → 处理"记住我" → 重定向
+ """
+ # 重新初始化Django内置AuthenticationForm(确保使用正确的认证逻辑)
+ form = AuthenticationForm(data=self.request.POST, request=self.request)
+
+ if form.is_valid():
+ delete_sidebar_cache() # 清理侧边栏缓存(更新用户相关的缓存数据)
+ logger.info(self.redirect_field_name) # 记录重定向字段名到日志
+
+ # 执行登录:将用户信息存入会话,创建登录状态
+ auth.login(self.request, form.get_user())
+ # 处理"记住我"选项:若勾选,则设置会话有效期为1个月
+ if self.request.POST.get("remember"):
+ self.request.session.set_expiry(self.login_ttl)
+ # 调用父类form_valid方法,执行重定向
+ return super(LoginView, self).form_valid(form)
+ else:
+ # 表单验证失败(如用户名/密码错误),重新渲染登录页并显示错误
+ return self.render_to_response({'form': form})
+
+ def get_success_url(self):
+ """
+ 自定义登录成功后的重定向URL
+ 核心:校验重定向地址的合法性,避免恶意重定向攻击
+ """
+ # 从POST请求中获取目标重定向地址(登录表单中隐藏字段传递)
+ redirect_to = self.request.POST.get(self.redirect_field_name)
+ # 校验重定向地址是否合法(仅允许本网站域名下的地址)
+ if not url_has_allowed_host_and_scheme(
+ url=redirect_to, allowed_hosts=[self.request.get_host()]):
+ redirect_to = self.success_url # 非法地址则使用默认重定向地址(根目录)
+ return redirect_to
+
+
+def account_result(request):
+ """
+ 账号操作结果展示函数视图
+ 处理两种场景:1. 注册成功后的提示 2. 邮箱验证后的激活与提示
+ 核心:根据URL参数区分场景,验证链接合法性,激活用户,渲染结果页面
+ """
+ # 从GET请求中获取操作类型(register/validation)和用户ID
+ type = request.GET.get('type')
+ id = request.GET.get('id')
+
+ # 根据用户ID查询用户,不存在则返回404页面
+ user = get_object_or_404(get_user_model(), id=id)
+ logger.info(type) # 记录操作类型到日志
+
+ # 若用户已激活,直接重定向到首页(避免重复验证)
+ if user.is_active:
+ return HttpResponseRedirect('/')
+
+ # 仅处理注册和验证两种合法操作类型
+ if type and type in ['register', 'validation']:
+ if type == 'register':
+ # 场景1:注册成功提示(告知用户验证邮件已发送)
+ content = '''
+ 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。
+ '''
+ title = '注册成功'
+ else:
+ # 场景2:邮箱验证(校验链接签名合法性,激活用户)
+ # 重新计算签名(与注册时的加密规则一致)
+ c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+ sign = request.GET.get('sign') # 从URL中获取传递的签名
+
+ # 签名不匹配则返回403禁止访问(防止恶意篡改链接)
+ if sign != c_sign:
+ return HttpResponseForbidden()
+
+ # 签名匹配,激活用户(设置is_active为True)
+ user.is_active = True
+ user.save()
+ # 验证成功提示
+ content = '''
+ 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。
+ '''
+ title = '验证成功'
+ # 渲染结果页面,传递标题和内容
+ return render(request, 'account/result.html', {
+ 'title': title,
+ 'content': content
+ })
+ else:
+ # 非法操作类型,重定向到首页
+ return HttpResponseRedirect('/')
+
+
+class ForgetPasswordView(FormView):
+ """
+ 忘记密码重置视图,继承Django FormView
+ 核心功能:接收密码重置表单(新密码、验证码、邮箱)、验证合法性、更新用户密码、重定向到登录页
+ """
+ form_class = ForgetPasswordForm # 关联忘记密码表单(处理密码强度、验证码、邮箱验证)
+ template_name = 'account/forget_password.html' # 密码重置页面模板路径
+
+ def form_valid(self, form):
+ """
+ 表单验证通过后的密码重置逻辑
+ 流程:查询用户 → 哈希新密码 → 保存更新 → 重定向到登录页
+ """
+ if form.is_valid():
+ # 根据表单验证后的邮箱查询用户
+ blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
+ # 哈希处理新密码(make_password自动使用Django配置的哈希算法,避免明文存储)
+ blog_user.password = make_password(form.cleaned_data["new_password2"])
+ blog_user.save() # 保存更新后的密码
+ return HttpResponseRedirect('/login/') # 密码重置成功,重定向到登录页
+ else:
+ # 表单验证失败(如验证码错误、密码不匹配),重新渲染页面并显示错误
+ return self.render_to_response({'form': form})
+
+
+class ForgetPasswordEmailCode(View):
+ """
+ 忘记密码验证码发送视图,继承Django View
+ 核心功能:接收邮箱 → 验证邮箱合法性 → 生成验证码 → 发送验证邮件 → 存储验证码到缓存
+ """
+
+ def post(self, request: HttpRequest):
+ """
+ 处理POST请求(接收邮箱,发送验证码)
+ """
+ # 初始化忘记密码验证码表单,验证请求数据
+ form = ForgetPasswordCodeForm(request.POST)
+ if not form.is_valid():
+ return HttpResponse("错误的邮箱") # 表单验证失败(如邮箱格式错误),返回错误提示
+
+ # 表单验证通过,获取目标邮箱
+ to_email = form.cleaned_data["email"]
+
+ # 生成随机验证码(项目工具函数)
+ code = generate_code()
+ # 发送验证码邮件(调用accounts应用自定义工具函数)
+ utils.send_verify_email(to_email, code)
+ # 存储验证码到缓存(key=邮箱,value=验证码,有效期5分钟,由utils模块的_code_ttl控制)
+ utils.set_code(to_email, code)
+
+ return HttpResponse("ok") # 验证码发送成功,返回"ok"提示n
\ No newline at end of file