ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django选课系统用户模型设计:从AbstractUser扩展到角色权限实战

Django选课系统用户模型设计:从AbstractUser扩展到角色权限实战

1. 从零拆解用户模型的设计思路

1.1 为什么用户模型是整个选课系统的地基

做学生选课管理系统,很多人第一反应是先写课程列表、先做选课逻辑,但我踩过的坑告诉我:用户模型没设计好,后面全是返工。原因很简单,选课系统里所有业务都挂在“人”身上——学生要选课、老师要开课、管理员要管人,这三类角色的权限、字段、行为完全不同。如果一开始用一个粗糙的默认用户表糊弄过去,等到写选课逻辑时你会发现:学生需要学号、班级、入学年份,老师需要工号、职称、所属院系,这些字段往哪塞?

Django 自带的User模型只提供了用户名、密码、邮箱、姓、名这几个基础字段,对于教学管理场景来说远远不够。所以这个实例里我们要做的第一件事,就是扩展用户模型。扩展方式有好几种,选哪种直接决定了后续开发的顺手程度。

1.2 三种扩展方案对比与选型理由

在 Django 里扩展用户体系,主流有三条路,我把它们列出来对比一下,方便你理解我为什么最终选了“继承 AbstractUser”这条路。

方案做法优点缺点适用场景
Profile 扩展新建一张表,外键指向 User不动原生表,最安全每次取字段要跨表查询,代码啰嗦字段很少、临时项目
继承 AbstractUser自定义 User 继承 AbstractUser字段全在一张表,调用直接必须在项目初期就定好中大型业务系统
继承 AbstractBaseUser完全自定义用户体系最灵活工作量大,要自己实现管理器特殊认证需求

选课系统属于典型的“角色分明、字段较多、后期还要扩展”的业务,Profile 方案会让request.user.student.student_id这种链式调用满天飞,写起来难受。而 AbstractBaseUser 又太重,我们并不需要推翻 Django 的认证机制。所以继承 AbstractUser是最平衡的选择:既保留了 Django 内置的密码加密、登录认证、权限系统,又能自由添加业务字段。

提示:一旦你用了自定义 User 模型并且已经执行过数据库迁移,后期再想换方案会非常痛苦,因为 Django 对 AUTH_USER_MODEL 的切换极其敏感。所以这个决定必须在第一次 migrate 之前敲定。

1.3 角色字段的设计取舍

选课系统里有学生、教师、管理员三种身份。怎么区分?我见过两种做法:一种是建三张表分别存三类人,另一种是一张表加一个 role 字段。前者查询时要 union,后者一个字段搞定。我选后者,用一个role字段配合choices参数来约束取值范围。

这里有个细节值得说:为什么用 CharField 而不是 IntegerField 存角色。用数字 1/2/3 存角色,数据库里看着简洁,但调试时你看到role=2得翻代码才知道是老师,日志排查很痛苦。用字符串student/teacher/admin虽然占几个字节,但可读性直接拉满,而且配合 Django 的TextChoices还能拿到枚举提示。这点存储成本换来的开发效率,太值了。

2. 用户模型核心字段的逐项拆解

2.1 基础字段的继承与覆盖

继承AbstractUser之后,我们自动获得了这些字段:username、password、email、first_name、last_name、is_staff、is_active、is_superuser、date_joined、last_login。这些不用自己写,直接用。

但有几个地方我做了调整。first_name和last_name在中文语境下其实不太顺手,中国人的姓名习惯是“姓在前名在后”,而且很多人习惯直接存一个完整姓名。所以我额外加了一个real_name字段专门存真实姓名,first_name/last_name就留着不用或者存拼音。这样前端展示时直接读real_name,不用拼接。

另外email字段在 AbstractUser 里默认是blank=True,也就是可以不填。但选课系统里邮箱往往是找回密码、接收通知的关键渠道,所以我把它设成unique=True并且必填。这里要注意:如果数据库里已经有重复邮箱或者空邮箱的数据,加 unique 约束会迁移失败,所以这个改动也要趁早。

2.2 业务字段的规划

针对选课场景,我规划了这么几组业务字段:

  • 身份标识类:role(角色)、student_id(学号)、teacher_id(工号)
  • 归属信息类:department(院系)、major(专业)、class_name(班级)
  • 时间信息类:enroll_year(入学年份)、title(职称,教师用)
  • 状态类:is_active(继承来的,控制账号是否可用)

学号和工号这两个字段有个坑:它们不能简单地设成unique=True就完事,因为学生没有工号、老师没有学号,如果都设 unique,那空值会冲突(MySQL 里多个 NULL 不冲突,但空字符串会)。我的做法是设null=True, blank=True, unique=True,让不适用的一方存 NULL,这样既保证唯一性又不冲突。

注意:不同数据库对 NULL 和 unique 的处理不一样。SQLite 允许多个 NULL 共存,MySQL 也允许,但如果你用空字符串代替 NULL,就会报唯一性冲突。所以务必用null=True而不是默认空串。

2.3 字段参数背后的考量

每个字段的参数都不是随便填的,我挑几个关键的说说:

role字段用TextChoices定义枚举,好处是代码里可以写User.Role.STUDENT而不是硬编码字符串,IDE 能自动补全,改起来也安全。max_length给 10 足够,因为student才 7 个字符。

student_id的max_length我给了 20。为什么不是 10?因为有些学校的学号包含年份、院系代码、序号,长度可能到 12-16 位,留点余量避免以后改字段。这种“宁可长一点”的思路在数据库设计里很常见,因为改字段长度虽然不难,但生产环境改表有风险。

enroll_year用PositiveSmallIntegerField而不是DateField。因为选课系统里我们通常只关心“哪一届”,不关心具体入学日期。存年份用整数,查询“2023 级学生”时直接filter(enroll_year=2023),比日期范围查询简单得多。

3. 完整模型代码与实操落地

3.1 模型文件的完整实现

假设你的应用叫accounts,在accounts/models.py里这样写:

from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): class Role(models.TextChoices): STUDENT = "student", "学生" TEACHER = "teacher", "教师" ADMIN = "admin", "管理员" role = models.CharField( max_length=10, choices=Role.choices, default=Role.STUDENT, verbose_name="角色", ) real_name = models.CharField(max_length=30, verbose_name="真实姓名") email = models.EmailField(unique=True, verbose_name="邮箱") student_id = models.CharField( max_length=20, null=True, blank=True, unique=True, verbose_name="学号" ) teacher_id = models.CharField( max_length=20, null=True, blank=True, unique=True, verbose_name="工号" ) department = models.CharField(max_length=50, blank=True, verbose_name="院系") major = models.CharField(max_length=50, blank=True, verbose_name="专业") class_name = models.CharField(max_length=30, blank=True, verbose_name="班级") enroll_year = models.PositiveSmallIntegerField( null=True, blank=True, verbose_name="入学年份" ) title = models.CharField(max_length=20, blank=True, verbose_name="职称") class Meta: db_table = "accounts_user" verbose_name = "用户" verbose_name_plural = "用户" ordering = ["-date_joined"] def __str__(self): return f"{self.real_name}({self.username})" @property def display_id(self): if self.role == self.Role.STUDENT: return self.student_id or "-" if self.role == self.Role.TEACHER: return self.teacher_id or "-" return "-"

这段代码里有几个我特意加的东西。db_table显式指定表名,避免 Django 默认生成的accounts_user在某些场景下和别的表重名。ordering让默认查询按注册时间倒序,管理后台列表看着舒服。display_id是个 property,前端展示时不用写 if-else 判断角色,直接调这个方法就行。

3.2 settings 里的关键配置

模型写完了,还得告诉 Django “别用你自带的 User,用我这个”。在settings.py里加一行:

AUTH_USER_MODEL = "accounts.User"

这一行必须加,而且必须在第一次 migrate 之前加。如果你已经 migrate 过了再改,Django 会报错说依赖冲突,处理起来很麻烦,通常要删库重来。我见过太多新手在这里翻车,所以再强调一遍:先配 AUTH_USER_MODEL,再 migrate。

另外,如果你想让自定义 User 在 admin 后台也能正常管理,还需要在accounts/admin.py里注册:

from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import User @admin.register(User) class CustomUserAdmin(UserAdmin): list_display = ("username", "real_name", "role", "display_id", "is_active") list_filter = ("role", "is_active", "department") search_fields = ("username", "real_name", "student_id", "teacher_id") fieldsets = UserAdmin.fieldsets + ( ("业务信息", {"fields": ("role", "real_name", "student_id", "teacher_id", "department", "major", "class_name", "enroll_year", "title")}), )

继承UserAdmin而不是用默认的ModelAdmin,是因为前者已经帮你配好了密码修改、权限分配这些界面,你只需要把自定义字段追加进去就行。

3.3 迁移与验证的完整流程

配置好之后,执行迁移:

python manage.py makemigrations accounts python manage.py migrate

makemigrations会生成一个迁移文件,里面包含建表语句。我建议你打开这个文件看一眼,确认字段类型、约束都符合预期。特别是unique=True和null=True有没有正确生成,有时候 Django 的推断和你想的不一样。

迁移完成后,用 shell 验证一下:

python manage.py shell
from accounts.models import User # 创建一个学生 s = User.objects.create_user( username="stu001", password="test123456", real_name="张三", email="stu001@example.com", role=User.Role.STUDENT, student_id="2023010001", department="计算机学院", major="软件工程", class_name="软工2301", enroll_year=2023, ) print(s.display_id) # 输出 2023010001 print(s.get_role_display()) # 输出 学生

create_user会自动帮你加密密码,千万别用User.objects.create()直接塞明文密码,那样存进去的是明文,登录时验证会失败。这是新手最常犯的错误之一。

4. 常见问题与排查技巧实录

4.1 迁移报错的典型场景

场景一:改了 AUTH_USER_MODEL 之后 migrate 报 “Dependency on app with no migrations”。这通常是因为你的 accounts 应用没有migrations目录,或者目录里没有__init__.py。Django 需要这个目录来存放迁移文件,手动建一个空的__init__.py就行。

场景二:报 “table accounts_user already exists”。说明数据库里已经有这张表了,可能是之前迁移过又回滚不干净。解决办法是进数据库手动 drop 掉这张表,或者删掉整个 db 文件重新来(开发阶段推荐后者,干净利落)。

场景三:unique=True加上之后迁移失败,提示有重复数据。这是因为表里已经有重复的邮箱或学号。开发阶段直接清空数据重来,生产环境就得先写脚本去重再迁移。

4.2 字段设计的常见坑

问题现象原因解决办法
学生和老师学号字段冲突都设了 unique 但用空串填充改用 null=True,不适用时存 NULL
邮箱重复注册email 没设 unique加 unique=True,并在表单层校验
角色判断写死字符串没用 TextChoices用枚举类,避免拼写错误
查询用户要跨表用了 Profile 方案改用继承 AbstractUser
密码存成明文用了 create 而非 create_user统一用 create_user

4.3 我踩过的几个真实坑

第一个坑是忘记加AUTH_USER_MODEL就 migrate 了。结果 Django 建了默认的 auth_user 表,等我发现想换的时候,外键关系已经乱了,最后只能删库重来。所以这个配置一定要在项目初始化阶段就写好。

第二个坑是**real_name字段没设默认值**。我一开始设的是CharField(max_length=30),没加blank=True,结果创建超级用户时命令行提示要填真实姓名,而createsuperuser默认只问 username、email、password,导致创建失败。后来我改成blank=True或者给个默认值,就顺畅了。如果你希望真实姓名必填,那就要自定义createsuperuser的交互流程,稍微麻烦一点。

第三个坑是**display_id用 property 导致 admin 列表排序失效**。因为 property 不是数据库字段,list_display里放它没问题,但如果你想按它排序就会报错。解决办法是要么用admin.display(ordering=...)指定排序字段,要么干脆在列表里分开显示 student_id 和 teacher_id。

提示:自定义 User 模型一旦上线,后期加字段相对容易(加个字段再 migrate 就行),但改字段类型、删字段、改 unique 约束都要谨慎,生产环境最好在低峰期操作并提前备份。

4.4 关于超级用户的处理

用自定义 User 之后,createsuperuser命令依然能用,但要注意:超级用户的role字段会取默认值student。这显然不合理,管理员应该是admin。我的做法是在创建超级用户后,手动进 shell 把 role 改成 admin,或者重写UserManager的create_superuser方法,在里面强制设置role=Role.ADMIN。后者更优雅,但需要额外写一个 Manager 类:

from django.contrib.auth.models import UserManager class CustomUserManager(UserManager): def create_superuser(self, username, email=None, password=None, **extra_fields): extra_fields.setdefault("role", User.Role.ADMIN) return super().create_superuser(username, email, password, **extra_fields)

然后在 User 模型里指定objects = CustomUserManager()。这样创建超级用户时角色自动就是管理员,省心。

5. 模型扩展与后续衔接

5.1 这个模型为后续开发留了哪些口子

用户模型定下来之后,后面的课程模型、选课记录模型都要外键指向它。比如选课记录表里会有student = ForeignKey(User, limit_choices_to={"role": "student"}),用limit_choices_to限制只能选学生角色,避免把老师选进课程里。这个约束在 admin 后台和表单里都会生效,能挡掉很多脏数据。

另外,role字段配合 Django 的权限系统,可以实现“学生只能看自己的课表、老师只能看自己开的课”这类逻辑。具体做法是在视图里判断request.user.role,或者在自定义的权限类里做校验。这些都会在后续章节展开,但前提是用户模型里得有role这个字段。

5.2 关于字段冗余的取舍

有人会问:department、major、class_name这些字段是不是应该单独建表,用外键关联?理论上更规范,符合数据库范式。但在选课系统这种中小型项目里,我倾向于适度冗余。因为院系、专业这些信息变动频率极低,单独建表反而增加 join 查询的复杂度。等系统规模大了、需要统计各院系选课情况时,再考虑抽出来也不迟。

这种“先跑起来再优化”的思路,在教学项目里特别适用。过度设计会让你在还没看到业务全貌时就陷入表结构的纠结,反而拖慢进度。我的建议是:核心业务字段(角色、学号)必须一开始就设计对,辅助信息字段(院系、班级)可以先冗余存着,后期按需重构。

5.3 数据校验该放在哪一层

字段的约束(unique、null、max_length)是数据库层的最后一道防线,但用户体验最好的校验应该放在表单层。比如学号格式“必须是 10 位数字”,这种规则用CharField的validators参数加正则校验,或者写自定义 validator。数据库层只保证“不为空、不重复”,格式校验交给表单,这样报错信息更友好。

我在实际项目里的分层原则是:数据库管“能不能存”,模型管“业务规则”,表单管“用户输入格式”。三层各司其职,排查问题时也能快速定位是哪一层出的错。

5.4 一个容易被忽略的细节:时区与创建时间

AbstractUser自带date_joined,默认用timezone.now()。如果你在 settings 里配了USE_TZ = True(Django 新项目默认就是 True),那存进去的是 UTC 时间,展示时要转成本地时区。选课系统里“选课开放时间”“截止时间”都跟时间强相关,时区没处理好会出现“明明还没到截止时间却提示已结束”的诡异问题。我的习惯是统一用 UTC 存储,在模板或序列化层统一转换,绝不在数据库里存本地时间。

到这里,用户模型这块就算落地了。它看起来只是一个 models.py 文件,但里面的每个字段、每个参数背后都有业务考量。把这一层打扎实,后面写课程管理、选课逻辑时你会感谢现在的自己。我个人在实际操作中的体会是:模型设计阶段多花一小时,编码阶段能省一整天,尤其是用户这种被到处引用的核心表,更是值得反复推敲。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进