用户与权限管理(1):从本地登录到项目角色(v2.0.0)
用户与权限管理(1):从本地登录到项目角色(v2.0.0)
教程版本:v2.0.0;定稿日期:2026-10-08。
本篇从认证与授权的区别开始,保留 Django 已验证的密码、Session、CSRF 和 request.user,再亲手建立项目自己的 Capability、Role、RoleCapability 与 UserRole。完成后可以用本地用户名密码登录,并看到同一个页面如何因业务能力不同而允许或拒绝访问。
| 篇次 | 固定地址 | 内容 |
|---|---|---|
| 第 1 篇 | https://www.cnblogs.com/lizexiong/p/22694083 | 本文:本地登录、自研能力、角色、用户角色与第一次授权 |
| 第 2 篇 | https://www.cnblogs.com/lizexiong/p/22712552 | Policy API、管理后端、并发复检与 CMDB 全局能力 |
| 第 3 篇 | https://www.cnblogs.com/lizexiong/p/22712579 | 完整管理模板与 Automation 对象授权 |
| 第 4 篇 | https://www.cnblogs.com/lizexiong/p/22716152 | 第三方身份、迁移、完整源码与校验 |
1. 先把两个问题分开:认证与授权
1.1 认证回答“你是谁”
认证(Authentication)处理身份确认。本文使用 Django 的本地用户名和密码流程:
- 用户提交用户名与密码;
AuthenticationForm调用配置好的认证后端;- 密码只以哈希形式保存在数据库中,登录时做安全校验;
LoginView登录成功后把用户标识写入 Session;SessionMiddleware与AuthenticationMiddleware在后续请求中恢复request.user;- 模板和视图可以读取
request.user.is_authenticated与request.user.is_active; - 退出登录通过 POST 请求调用
logout(),清理当前 Session。
认证成功只说明系统知道当前请求属于哪个本地用户,不说明这个用户可以查看管理首页,更不说明他可以创建用户或配置角色。
1.2 授权回答“你能做什么”
授权(Authorization)处理业务动作是否允许。本项目把“可执行动作”表达为稳定的 Capability.key,例如 accounts.dashboard.view。能力先绑定到角色,再通过显式中间表把角色绑定给用户。判断时只沿下面这条链读取:
| 节点 | 职责 | 为什么单独建模 |
|---|---|---|
User | 本地登录主体 | 保存用户名、密码哈希、启用状态和业务资料,不直接堆放业务能力。 |
UserRole | 用户与角色的关联 | 能记录分配人和分配时间,并用唯一约束拒绝重复绑定。 |
Role | 一组能力的业务集合 | 角色名称可以面向人展示,稳定编码可以被程序和初始化命令引用。 |
RoleCapability | 角色与能力的关联 | 能记录分配人和分配时间,并用唯一约束拒绝重复能力。 |
Capability | 最小业务动作 | 页面、装饰器、初始化目录之间共享同一个稳定编码。 |
本项目不提供“给用户直接塞一个业务能力”的旁路。所有普通用户的业务能力都必须来自角色,这样审计、撤销和委派边界才有统一入口。
1.3 登录、启用和有权访问是三道门
| 检查 | 失败结果 | 负责组件 |
|---|---|---|
| 是否已经认证 | 302 跳转到登录页,并携带 next | login_required |
| 账号是否启用 | 能力集合为空;本地认证后端也会拒绝停用账号登录 | PolicySnapshot 与 Django 认证后端 |
| 是否拥有目标能力 | 403 | 项目自有策略函数与能力装饰器 |
302 与 403 必须区分:匿名访问者尚未证明身份,先去登录;已经登录却没有能力的用户身份是明确的,因此返回拒绝访问,而不是再次跳登录造成循环。
1.4 本文与后续文章的文件边界
- 第 1 篇:给出阶段专用且完整的
forms.py、views.py、urls.py、base.html、login.html、home.html。它们只引用本文已经存在的路由和模型。 - 第 2 篇:替换或扩展上述文件,接入用户、部门、角色与能力管理页面,并把事务内重检和防越权委派落到写操作。
- 第 3 篇:再次扩展模型、表单、视图、路由与模板,引入外部身份、登录事务和审计事件。第 4 篇才允许出现外部身份导入与反向解析。
因此,不要从最终版本仓库直接复制完整 views.py 或最终 base.html 到本文阶段:那些文件会引用尚不存在的身份路由,最典型的结果就是模板渲染时抛出 NoReverseMatch。本文给出的阶段文件可以直接运行,并且不会偷偷依赖后续内容。
2. 创建隔离环境、项目和应用
2.1 版本与目录约定
本文使用 Python 3.10 或更高版本、Django 5.2.17、SQLite 和 python-dotenv 1.x。示例项目目录名为 devopsx-accounts。文章中的“项目根目录”始终指包含 manage.py 的目录;所有命令都会明确执行位置。
2.2 创建目录和虚拟环境
执行目录:你准备存放练习项目的工作区目录。目的:创建一个全新的项目目录和隔离解释器。预期输出:目录中出现 devopsx-accounts 与虚拟环境目录;激活后 python --version 指向虚拟环境。常见错误:如果 python 不存在,请安装受支持的 Python 并确认它进入 PATH;如果目录已经包含同名项目,不要覆盖旧文件。
Windows PowerShell:
mkdir devopsx-accounts
Set-Location devopsx-accounts
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python --version
macOS/Linux shell:
mkdir devopsx-accounts
cd devopsx-accounts
python3 -m venv .venv
. .venv/bin/activate
python --version
如果 PowerShell 报脚本执行策略错误,只为当前终端选择合适的激活方式,不要关闭系统安全策略来“图省事”。也可以直接使用 .venv\Scripts\python.exe 执行后续命令。
2.3 固定依赖
在项目根目录创建完整的 requirements.txt:
Django==5.2.17
python-dotenv>=1,<2
执行目录:项目根目录。目的:安装锁定的 Django 版本与环境文件加载器。预期输出:pip 最后显示成功安装,且版本检查输出 5.2.17。常见错误:证书或镜像错误属于本机 Python/pip 配置问题;不要通过把第三方包源码复制进项目来绕过包管理。
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m django --version
2.4 生成 Django 项目与 accounts 应用
执行目录:仍然是空的项目根目录。目的:让 Django 生成标准入口,再创建用户与权限应用。预期输出:根目录出现 manage.py、devopsX/ 和 accounts/。常见错误:django-admin startproject devopsX . 末尾的点表示写入当前目录,不能漏掉;如果提示文件冲突,说明目录并非空项目,应先核对而不是强制覆盖。
django-admin startproject devopsX .
python manage.py startapp accounts
再创建模板、静态文件、模板标签和管理命令目录。下面分别给出 PowerShell 与 POSIX 命令;二选一执行。
执行目录:项目根目录。目的:准备本文会写入的包和资源目录。预期输出:所有目录存在,空的 __init__.py 使 Python 能导入模板标签和管理命令。常见错误:不要把 templates 放进 devopsX 包;本文的设置按项目根目录寻找它。
New-Item -ItemType Directory -Force templates\accounts | Out-Null
New-Item -ItemType Directory -Force accounts\static\accounts | Out-Null
New-Item -ItemType Directory -Force accounts\templatetags | Out-Null
New-Item -ItemType Directory -Force accounts\management\commands | Out-Null
New-Item -ItemType File -Force accounts\templatetags\__init__.py | Out-Null
New-Item -ItemType File -Force accounts\management\__init__.py | Out-Null
New-Item -ItemType File -Force accounts\management\commands\__init__.py | Out-Null
mkdir -p templates/accounts accounts/static/accounts accounts/templatetags accounts/management/commands
touch accounts/templatetags/__init__.py accounts/management/__init__.py accounts/management/commands/__init__.py
3. 环境驱动设置:密钥必填,布尔值严格解析
3.1 为什么不能在 settings.py 写开发密钥
SECRET_KEY 参与签名,不能把一个教程字符串提交到仓库后让所有部署共享。v2.0.0 要求 DEVOPSX_SECRET_KEY 必须由环境提供;缺失、空字符串都立即抛出 ImproperlyConfigured。这比“没有配置时退回一个固定默认值”安全,因为固定默认值会让错误部署看似正常启动。
同理,布尔环境变量不能使用 bool(os.getenv(...))。字符串 "false" 在 Python 中也是真值,会把本想关闭的开关打开。本文的 _env_bool() 只接受明确的真值集合和假值集合,其他拼写直接拒绝启动。
3.2 .env.example(可提交,不含秘密)
在项目根目录创建完整的 .env.example:
# 复制本文件为 .env,再替换所有占位值。不要提交真实 .env。
DEVOPSX_SECRET_KEY=REPLACE_WITH_A_LONG_RANDOM_DJANGO_SECRET_KEY
DEVOPSX_DEBUG=true
DEVOPSX_ALLOWED_HOSTS=127.0.0.1,localhost
# 第 1 篇没有外部身份模型、路由或提供方适配器,因此明确关闭。
DEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER=false
.env.example 里的值只是占位说明,不是可用凭据。真正的 .env 只留在本机,并应加入版本控制忽略列表。
3.3 在本机生成随机 SECRET_KEY
执行目录:项目根目录。目的:本机生成随机 Django 密钥,并写出本文阶段的环境变量;命令不会把生成结果发送到远程。预期输出:当前目录出现 .env,命令本身不打印秘密。常见错误:不要把 .env 内容贴进文章、工单或提交记录;如果文件已存在,先备份并核对,下面命令会覆盖它。
python -c "from django.core.management.utils import get_random_secret_key; from pathlib import Path; content = 'DEVOPSX_SECRET_KEY=%s\nDEVOPSX_DEBUG=true\nDEVOPSX_ALLOWED_HOSTS=127.0.0.1,localhost\nDEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER=false\n' % get_random_secret_key(); Path('.env').write_text(content, encoding='utf-8')"
如果你使用 Git,请让项目自己的忽略文件包含 .env 与 db.sqlite3。本文不要求初始化远程仓库,也不读写任何远程状态。
3.4 完整 devopsX/settings.py
用下面内容完整替换 devopsX/settings.py。该文件保留后续 Fake Provider 的环境开关,但本文没有任何代码消费身份提供方;开关为 false 时只是一个关闭状态的预留配置。
# devopsX/settings.py
# os 提供 getenv(),让设置从进程环境读取密钥和开关,而不是把敏感值写进代码。
import os
# Path 把路径表示为可用 / 拼接的对象,避免手工处理 Windows 与 Linux 的分隔符差异。
from pathlib import Path
# ImproperlyConfigured 是 Django 的配置错误;启动时抛出它能明确阻止错误配置继续运行。
from django.core.exceptions import ImproperlyConfigured
# load_dotenv 仅为本地开发把 .env 内容载入进程环境,之后仍统一通过 os.getenv() 读取。
from dotenv import load_dotenv
# __file__ 是当前 settings.py 的路径;两次 parent 得到项目根目录,后续路径都以它为基准。
BASE_DIR = Path(__file__).resolve().parent.parent
# 只从项目根目录读取本机 .env;真实部署也可以直接注入进程环境变量。
load_dotenv(BASE_DIR / ".env")
def _env_bool(name, default=False):
"""严格读取一个布尔环境变量,拒绝含糊拼写。"""
# def 定义可复用函数;default=False 是调用者不传第二个参数时使用的默认值。
# 输入:name 是环境变量名、default 是缺省布尔值;输出:解析后的 bool;调用者:本设置模块;失败:非法文本抛出 ImproperlyConfigured。
# 读取阶段:os.getenv 只读取当前进程环境,不会修改操作系统或 .env 文件。
raw_value = os.getenv(name)
# 业务判断:变量不存在时使用调用者传入的 default;不存在与“传入了空字符串”不是一回事。
if raw_value is None:
# 返回:default 本身已经是布尔值,不需要再调用 bool() 转换。
return default
# strip() 去首尾空白,lower() 统一大小写,因此 TRUE、true 和两侧有空格的写法按同一值处理。
normalized = raw_value.strip().lower()
# 花括号创建 set(集合);in 做不关心顺序的成员判断,比连续多个 == 更清楚。
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
# 不能用 bool("false"),因为任何非空字符串都会被 Python 当成 True。
raise ImproperlyConfigured("环境变量 %s 必须是布尔值。" % name)
# SECRET_KEY 用于签名 Session、CSRF 等安全数据;strip() 防止复制环境变量时带入首尾空白。
SECRET_KEY = os.getenv("DEVOPSX_SECRET_KEY", "").strip()
if not SECRET_KEY:
# 拒绝硬编码回退值,避免多个环境意外共享公开密钥。
raise ImproperlyConfigured("必须通过 DEVOPSX_SECRET_KEY 配置 Django 密钥。")
# DEBUG 控制调试页面;ALLOWED_HOSTS 把逗号分隔的主机名拆开并忽略空项。
DEBUG = _env_bool("DEVOPSX_DEBUG", False)
# 方括号中的“表达式 for 元素 in 可迭代对象 if 条件”是列表推导式:逐项清理并只保留非空主机。
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv("DEVOPSX_ALLOWED_HOSTS", "").split(",")
if host.strip()
]
# INSTALLED_APPS 决定 Django 加载哪些模型、模板标签、迁移和后台功能;accounts 是本教程业务应用。
# 前六项依次提供后台、认证、内容类型、Session、一次性消息和静态文件;点分路径最后一项加载 AccountsConfig。
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"accounts.apps.AccountsConfig",
]
# 中间件按请求自上而下、响应自下而上执行;顺序保证 Session 先于认证、认证先于消息可用。
# Security 添加安全响应头;Session 读取会话;Common 处理通用 URL;CSRF 校验跨站请求伪造令牌。
# Authentication 把 user 放入 request;Message 提供一次性提示;XFrameOptions 限制页面被其他站点嵌入。
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
# ROOT_URLCONF 指向全站入口路由;Django 收到请求后从 devopsX.urls 开始匹配。
ROOT_URLCONF = "devopsX.urls"
# 模板配置先搜索项目级 templates,再因 APP_DIRS=True 搜索每个应用的 templates 目录。
# TEMPLATES 是列表,因为 Django 支持多个模板后端;其中字典用“键: 值”集中描述一个后端。
# BACKEND 是实现类路径,DIRS 是额外搜索目录,APP_DIRS 开启应用模板搜索,OPTIONS 传给后端。
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
# context_processors 中每个函数都会给模板上下文补充常用值;这里依次提供 request、user/perms 和 messages。
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
# WSGI/ASGI 都暴露同一 Django 应用;部署服务器按同步或异步协议选择对应入口。
WSGI_APPLICATION = "devopsX.wsgi.application"
ASGI_APPLICATION = "devopsX.asgi.application"
# 第 1 篇使用本地 SQLite;没有数据库服务、缓存服务或身份平台的远程状态。
# DATABASES 是以连接别名为键的嵌套字典;default 是 Django 未指定 using 时采用的数据库。
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# 创建或修改密码时依次执行相似度、最短长度、常见密码和纯数字密码检查。
# 每个 NAME 是验证器类的点分导入路径;这些规则用于表单/命令校验,不会把明文密码存入数据库。
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# 界面使用简体中文;数据库时间按时区感知方式保存,展示时转换到上海时区。
# USE_I18N 开启翻译机制;USE_TZ=True 让 Django 内部使用带时区时间,减少服务器时区不同造成的歧义。
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
# STATIC_URL 是静态资源公开前缀;未显式声明主键的模型默认使用 64 位自增整数。
STATIC_URL = "/static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# 必须在第一次 migrate 之前声明自定义用户模型。
AUTH_USER_MODEL = "accounts.User"
# 使用 Django 默认 ModelBackend 校验本地用户名密码,并支持框架自带的后台权限接口;本项目业务授权仍只读取自有角色。
# 三个命名路由分别控制匿名用户被拦截时、登录成功后和通用 LogoutView 退出后的跳转目标。
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "accounts:home"
LOGOUT_REDIRECT_URL = "accounts:login"
# 这是第 3 篇使用的本地学习开关。第 1 篇保留配置但不引入提供方代码。
ACCOUNTS_ENABLE_FAKE_PROVIDER = _env_bool(
"DEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER",
DEBUG,
)
if ACCOUNTS_ENABLE_FAKE_PROVIDER and not DEBUG:
# 本地确定性测试工具不能在生产模式下被误开。
raise ImproperlyConfigured("生产模式禁止启用本地 Fake Provider。")
# 第 1 篇没有任何外部身份请求;这个标志仅说明运行环境是否允许后续本地工具。
ACCOUNTS_FAKE_PROVIDER_ALLOWED = DEBUG
# TTL 以秒为单位;300 秒即 5 分钟,供后续文章限制一次外部登录状态的有效期,第 1 篇不会使用它。
ACCOUNTS_EXTERNAL_LOGIN_TTL_SECONDS = 300
3.5 _env_bool 的函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
load_dotenv() 的输入是项目根目录下的 .env 路径,输出表现为把尚未存在的键加载到进程环境。设置模块是调用者;文件不存在时不会替你生成秘密,因此后面的必填检查仍会失败,这是预期的 fail-closed 行为。
4. 定义稳定能力目录
4.1 为什么能力编码在代码中声明
数据库保存可查询的能力行,代码保存稳定契约。视图装饰器不能依赖可随意修改的中文名称;它应引用 accounts.dashboard.view 这样的键。目录集中声明还能让初始化命令做精确同步、检测重复、检测角色引用了不存在的能力。
创建完整的 accounts/capabilities.py:
# accounts/capabilities.py
"""项目自有能力目录与内置角色定义。"""
# 以下常量只保存稳定的能力编码;业务代码引用常量可避免在多个文件中重复手写字符串。
# 编码采用“应用.资源.动作”结构,数据库中的 Capability.key 与这里的值一一对应。
# 能力编码是页面、策略和初始化数据之间的稳定接口。
DASHBOARD_VIEW = "accounts.dashboard.view"
USER_VIEW = "accounts.user.view"
USER_CREATE = "accounts.user.create"
USER_CHANGE = "accounts.user.change"
USER_DELETE = "accounts.user.delete"
USER_STATUS = "accounts.user.status"
USER_ASSIGN_ROLE = "accounts.user.assign_role"
DEPARTMENT_VIEW = "accounts.department.view"
DEPARTMENT_MANAGE = "accounts.department.manage"
ROLE_VIEW = "accounts.role.view"
ROLE_MANAGE = "accounts.role.manage"
CAPABILITY_VIEW = "accounts.capability.view"
# 外层圆括号创建不可变 tuple,适合表达代码发布后才变化的固定目录;其中每项是用键取值的 dict。
# 每个能力字典包含机器使用的 key、界面显示的 name 和面向管理员的 description。
CAPABILITY_CATALOG = (
{
"key": DASHBOARD_VIEW,
"name": "查看管理首页",
"description": "访问用户与权限管理首页。",
},
{
"key": USER_VIEW,
"name": "查看用户",
"description": "查看用户列表和用户详情。",
},
{
"key": USER_CREATE,
"name": "创建用户",
"description": "创建普通用户账号。",
},
{
"key": USER_CHANGE,
"name": "编辑用户",
"description": "编辑用户业务资料。",
},
{
"key": USER_DELETE,
"name": "删除用户",
"description": "删除用户账号,但不能破坏最后一个激活超级用户。",
},
{
"key": USER_STATUS,
"name": "管理用户状态",
"description": "启用或停用用户账号。",
},
{
"key": USER_ASSIGN_ROLE,
"name": "分配用户角色",
"description": "为用户替换项目自有角色,不提供直接权限分配。",
},
{
"key": DEPARTMENT_VIEW,
"name": "查看部门",
"description": "查看部门层级和部门用户统计。",
},
{
"key": DEPARTMENT_MANAGE,
"name": "管理部门",
"description": "创建、编辑和删除部门。",
},
{
"key": ROLE_VIEW,
"name": "查看角色",
"description": "查看项目自有角色及其能力。",
},
{
"key": ROLE_MANAGE,
"name": "管理角色",
"description": "创建、编辑和删除项目自有角色及能力关联。",
},
{
"key": CAPABILITY_VIEW,
"name": "查看能力目录",
"description": "只读查看项目自有能力目录。",
},
)
# 角色能力集合集中声明,bootstrap 命令据此精确同步数据库。
ROLE_CATALOG = (
{
"key": "platform_admin",
"name": "平台管理员",
"description": "拥有本项目全部管理能力。",
# “item["key"] for item in ...”是生成器表达式,逐项产出 key;tuple() 把结果一次固定为不可变元组。
"capabilities": tuple(item["key"] for item in CAPABILITY_CATALOG),
},
{
"key": "user_manager",
"name": "用户管理员",
"description": "负责用户资料、状态、角色分配及相关只读查询。",
"capabilities": (
DASHBOARD_VIEW,
USER_VIEW,
USER_CREATE,
USER_CHANGE,
USER_DELETE,
USER_STATUS,
USER_ASSIGN_ROLE,
DEPARTMENT_VIEW,
ROLE_VIEW,
CAPABILITY_VIEW,
),
},
{
"key": "permission_manager",
"name": "权限管理员",
"description": "负责角色能力配置和用户角色分配。",
"capabilities": (
DASHBOARD_VIEW,
USER_VIEW,
USER_ASSIGN_ROLE,
DEPARTMENT_VIEW,
ROLE_VIEW,
ROLE_MANAGE,
CAPABILITY_VIEW,
),
},
{
"key": "readonly_viewer",
"name": "只读查看员",
"description": "只能查看管理数据和能力目录。",
"capabilities": (
DASHBOARD_VIEW,
USER_VIEW,
DEPARTMENT_VIEW,
ROLE_VIEW,
CAPABILITY_VIEW,
),
},
)
# frozenset 是不可变集合,适合做快速成员判断,也能防止运行时意外增删目录键。
# 这里的生成器表达式逐项读取字典 key,frozenset() 消除重复并冻结结果。
CAPABILITY_KEYS = frozenset(item["key"] for item in CAPABILITY_CATALOG)
ROLE_KEYS = frozenset(item["key"] for item in ROLE_CATALOG)
ROLE_NAMES = frozenset(item["name"] for item in ROLE_CATALOG)
4.2 四个内置角色不是用户
platform_admin、user_manager、permission_manager 和 readonly_viewer 只是角色目录。创建这些行不会创建账号,也不会让任何现有账号自动获得能力。角色目录回答“系统认可哪些标准角色以及它们包含什么”;UserRole 才回答“哪个用户实际持有哪些角色”。
5. 当前模型核心:只到 UserRole 为止
创建完整的 accounts/models.py。这个第 1 篇版本与 v2.0.0 当前模型的 RBAC 核心一致,但有一个刻意的阶段差异:文件在 UserRole 结束。它不包含 ExternalIdentity、LoginTransaction、身份提供方枚举或审计事件类;那些属于第 4 篇。
# accounts/models.py
# AbstractUser 是 Django 已实现并经过验证的用户基类;继承它可直接复用用户名、密码哈希、启用状态等认证字段。
from django.contrib.auth.models import AbstractUser
# ValidationError 用来报告可展示到表单字段旁的业务校验错误,而不是返回 True/False 后让调用者猜原因。
from django.core.exceptions import ValidationError
# models 模块提供 Model、字段类型、删除策略和数据库约束,是本文件定义 ORM 模型的入口。
from django.db import models
# 点号表示从当前 accounts 应用导入;这里只取系统保留的角色编码和名称,避免模型反向导入初始化命令。
from .capabilities import ROLE_KEYS, ROLE_NAMES
# 继承 models.Model 后,Department 才会成为 Django ORM 模型,并自动获得 objects、save()、pk 等能力。
class Department(models.Model):
# CharField 保存短文本;"部门编码" 是表单标签;max_length=50 同时限制校验长度并参与数据库字段定义。
# unique=True 建立唯一约束;未写 null=True/blank=True,所以数据库和表单都不允许为空;普通字段随本行一起删除。
code = models.CharField("部门编码", max_length=50, unique=True)
# 部门名称也是必填短文本;名称允许重复,因为真正稳定且唯一的机器标识是 code。
# max_length=100 防止无限输入;未设置 unique=True,所以两个不同编码可暂时使用相同展示名称。
name = models.CharField("部门名称", max_length=100)
# ForeignKey 在当前表保存一个上级部门主键;多行可指向同一个上级,因此形成“一对多”的部门树。
parent = models.ForeignKey(
# "self" 表示目标模型就是 Department 自己;使用字符串可在类尚未创建完时声明自关联。
"self",
# verbose_name 是表单、错误信息和管理界面使用的人类可读字段名,不是数据库列名。
verbose_name="上级部门",
# null=True 允许数据库外键列保存 SQL NULL;这里的 NULL 表示该部门没有上级,是顶级部门。
null=True,
# blank=True 允许 Django 表单不填写该字段;它控制表单校验,和上面的数据库 null 是两层规则。
blank=True,
# PROTECT 表示仍有子部门引用父部门时拒绝删除父部门,避免子部门失去层级归属。
on_delete=models.PROTECT,
# related_name 创建反向查询 parent.children;不用它时只能使用不直观的 department_set。
related_name="children",
)
# BooleanField 只表达启用/停用;第一个位置参数 "启用" 是 verbose_name(界面字段名称);default=True 为新行提供默认值,因此字段不允许 NULL。
# 停用用于保留历史关系,不等于删除部门;普通字段没有独立删除行为,会随部门行一起删除。
is_active = models.BooleanField("启用", default=True)
# PositiveIntegerField 只接受非负整数;第一个位置参数 "排序" 是 verbose_name;default=0 表示没有额外排序优先级,不表示“未知”。
# 它不使用 NULL,避免排序时同时处理数字和空值;删除部门时该值随本行删除。
sort_order = models.PositiveIntegerField("排序", default=0)
# 第一个位置参数是 verbose_name(界面字段名称);auto_now_add=True 只在第一次 INSERT 时写入当前时间,后续 save() 不会修改创建时间。
# 调用者不需要填写该字段;它不可为 NULL,并随部门行删除。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
# 第一个位置参数是 verbose_name;auto_now=True 在每次通过模型 save() 保存时更新当前时间,用来表示最近一次模型写入。
# QuerySet.update() 不会自动执行这套 save() 行为;字段不可为 NULL,并随部门行删除。
updated_at = models.DateTimeField("更新时间", auto_now=True)
# 内部 Meta 类不产生数据库记录;Django 在创建模型类时读取它,配置默认查询和显示名称。
class Meta:
# 没有显式 order_by() 时先按 sort_order 升序,再用唯一 code 稳定处理相同排序值。
ordering = ["sort_order", "code"]
# verbose_name 是模型单数名称,Django 在表单和管理界面需要描述一条记录时使用它。
verbose_name = "部门"
# verbose_name_plural 是模型复数名称;中文通常不变,所以仍写“部门”,避免框架自动拼接英文复数。
verbose_name_plural = "部门"
def clean(self):
"""在表单或显式 full_clean() 时拒绝部门层级环。"""
# 输入:待校验的 Department 实例 self;输出:校验通过时返回 None;调用者:ModelForm 或显式 full_clean();失败:层级成环时抛 ValidationError。
# 先调用父类 clean(),保留 Django Model 的清理扩展链;当前基础实现不替代 clean_fields()/validate_unique() 等 full_clean() 其他阶段。
super().clean()
# 业务判断:parent_id 是外键列的原始主键;没有上级时合法,有上级时继续检查直接自指、后代回指和旧祖先环。
if not self.parent_id:
# 没有上级就是合法顶级部门,后续没有父链可检查,立即结束并隐式返回 None。
return
# self.pk 是当前部门主键;新对象保存前为 None,所以只有已保存对象才能比较“是否把自己设为上级”。
if self.pk and self.parent_id == self.pk:
# 字典键 parent 会把错误绑定到上级部门字段旁,而不是只显示一条无法定位的全局错误。
raise ValidationError({"parent": "上级部门不能是当前部门。"})
# 访问 self.parent 得到第一个上级对象;若尚未缓存,Django ORM 可能在这里查询数据库。
ancestor = self.parent
# visited 保存已经走过的祖先主键,用于发现数据库里原本就存在的 A→B→A 循环。
visited = set()
# 每轮把 ancestor 向更高一级推进;到顶级部门时 parent 为 None,循环自然结束。
while ancestor is not None:
# 如果祖先链重新遇到当前部门,保存后会形成“当前部门是自己祖先”的环。
if self.pk and ancestor.pk == self.pk:
raise ValidationError({"parent": "上级部门不能是当前部门的后代。"})
# 如果祖先主键此前已经出现,说明旧父链自身已有循环,继续向上会无限遍历。
if ancestor.pk in visited:
raise ValidationError({"parent": "部门层级中存在循环关系。"})
# 本轮确认安全后记录该主键,供下一轮检测是否重复。
visited.add(ancestor.pk)
# 移到更高一级;访问关联对象时 ORM 可能再执行一次查询。
ancestor = ancestor.parent
def __str__(self):
"""返回同时适合日志和下拉框显示的稳定标签。"""
# 输入:Department 实例 self;输出:“编码 - 名称”字符串;调用者:模板、表单选项、后台、日志及 str();失败:正常持久化字段下不抛业务异常。
# 同时显示唯一编码和可重复名称,管理员遇到同名部门时仍能区分具体记录。
return "%s - %s" % (self.code, self.name)
# 继承 AbstractUser 表示“扩展 Django 安全用户”,而不是重新实现密码哈希、认证后端和 Session 格式。
class User(AbstractUser):
# display_name 是业务页面姓名;blank=True 允许表单留空,未写 null=True 所以数据库用空字符串而不是 NULL。
# max_length=100 限制展示值长度;字段不是外键,删除用户时它随用户行一起删除。
display_name = models.CharField("姓名", max_length=100, blank=True)
# 工号是可选但填写后必须唯一的短文本;"工号" 是表单标签,max_length=50 限制长度。
employee_number = models.CharField(
"工号",
max_length=50,
# unique=True 为非空工号建立唯一约束,数据库是并发写入时阻止重复工号的最终防线。
unique=True,
# null=True 允许数据库保存 NULL;多个未填写用户可各自保存 NULL,而不会互相占用同一个空字符串。
null=True,
# blank=True 允许 ModelForm 留空;clean() 会把可能出现的空字符串统一成上面的 NULL。
blank=True,
)
# 手机号在本教程只作为联系资料,不参与登录、账号合并或授权;第一个位置参数 "手机号" 是 verbose_name,blank=True 允许表单留空。
# 未写 null=True,所以未填写时保存空字符串;max_length=20 限制存储长度,删除用户时随行删除。
mobile = models.CharField("手机号", max_length=20, blank=True)
# 每个用户至多属于一个部门,所以这里使用 ForeignKey;同一部门可以被多个用户引用。
department = models.ForeignKey(
# 直接传 Department 类,告诉 ORM 目标表就是上面已经定义完成的部门模型。
Department,
# 该名称用于生成表单标签和错误提示,不改变 Python 属性名 department。
verbose_name="部门",
# null=True 允许数据库列为 NULL,表示用户暂时没有部门归属。
null=True,
# blank=True 允许 ModelForm 不选择部门;否则即使数据库允许 NULL,网页表单仍会提示必填。
blank=True,
# PROTECT 在部门仍有用户时拒绝删除,防止用户归属在没有确认的情况下静默消失。
on_delete=models.PROTECT,
# Department.users 提供反向查询,例如 department.users.filter(is_active=True)。
related_name="users",
)
# 这是项目自有角色查询入口;Django AbstractUser 中的 groups/user_permissions 仅为框架兼容保留,业务策略不读取它们。
roles = models.ManyToManyField(
# 使用字符串 "Role" 是因为 Role 类写在 User 后面;字符串让 Django 在所有模型加载后再解析它。
"Role",
# verbose_name 供表单和管理界面显示,不创建额外字段。
verbose_name="角色",
# through 指定显式中间模型 UserRole;这样可以保存 assigned_by、created_at,并添加唯一约束。
through="UserRole",
# UserRole 有 user、role、assigned_by 三个外键;through_fields 明确哪两个才是多对多的两端,避免歧义。
through_fields=("user", "role"),
# Role.users 创建角色到用户的反向查询;User.roles 则是正向查询入口。
related_name="users",
# blank=True 只表示表单允许角色集合为空;多对多本身存中间表,不会在 User 表保存 NULL。
blank=True,
)
def clean(self):
"""规范可空唯一工号,避免空字符串占用唯一值。"""
# 输入:待校验的 User 实例 self;输出:规范化后返回 None;调用者:ModelForm 或显式 full_clean();失败:完整 full_clean() 的字段/唯一性阶段仍可能另外抛 ValidationError。
# AbstractUser.clean() 会规范用户名并规范邮箱;先调用它,不能用自定义逻辑替换父类认证模型清理钩子。
super().clean()
# None 和空字符串都满足 not;这里只把“未提供工号”规范成数据库可重复保存的 NULL。
if not self.employee_number:
self.employee_number = None
def __str__(self):
"""优先显示业务姓名,空姓名时回退到登录名。"""
# 输入:User 实例 self;输出:姓名或用户名字符串;调用者:模板、表单选项、后台、日志及 str();失败:正常字段值下不抛业务异常。
# strip() 去掉只由空格组成的姓名;or 只在左侧为空字符串时返回必填 username。
return self.display_name.strip() or self.username
# Capability 表示一个最小业务动作;它继承 models.Model,因而获得 ORM 查询、主键和 save() 等模型能力;代码只引用稳定 key,中文名称和说明可独立调整。
class Capability(models.Model):
# key 是全局唯一短文本;max_length=150 限制长度,unique=True 建立数据库唯一约束,未设置 blank/null 所以必填。
# 普通字段没有 on_delete;删除能力记录时 key 随整行删除,关联行的行为由 RoleCapability 决定。
key = models.CharField("能力编码", max_length=150, unique=True)
# name 是管理员可读名称;max_length=100,允许不同 key 暂时使用相同名称,因此不设置 unique=True。
# 未设置 blank/null,所以表单和数据库都要求非空;删除能力时随本行删除。
name = models.CharField("能力名称", max_length=100)
# TextField 适合长度不固定的说明;blank=True 允许表单留空,未写 null=True 所以空值保存为空字符串。
# 说明不参与授权判断;删除能力时它随本行删除。
description = models.TextField("说明", blank=True)
# is_system 标记是否由代码目录维护;第一个位置参数 "系统内置" 是 verbose_name;default=False 让手工新建能力默认不是系统内置,字段不允许 NULL。
is_system = models.BooleanField("系统内置", default=False)
# is_active 是授权开关;第一个位置参数 "启用" 是 verbose_name;default=True 让新能力默认启用,False 时策略查询会忽略它但不会删除历史关联。
is_active = models.BooleanField("启用", default=True)
# 第一个位置参数是 verbose_name;auto_now_add=True 只在创建时写入,字段不由表单填写,不允许 NULL,删除能力时随行删除。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
# 第一个位置参数是 verbose_name;auto_now=True 在模型每次 save() 时更新时间;它不是独立审计日志,删除能力时随行删除。
updated_at = models.DateTimeField("更新时间", auto_now=True)
# Meta 只配置 Capability 模型的默认行为,不新增字段,也不会生成一张 Meta 表。
class Meta:
# 默认按稳定 key 升序,确保未显式排序的列表和测试仍有确定顺序。
ordering = ["key"]
# 单数显示名称用于描述一条 Capability 记录。
verbose_name = "能力"
# verbose_name_plural 是模型复数显示名称;中文不变化,所以仍写“能力”,避免框架自动拼接英文复数形式。
verbose_name_plural = "能力"
def clean(self):
"""供表单与 full_clean() 统一能力编码。"""
# 输入:待校验的 Capability 实例;输出:规范 key 后返回 None;调用者:ModelForm/full_clean();失败:父类字段或唯一性校验失败时抛 ValidationError。
# 先保留父类清理钩子,再执行项目自己的稳定编码规范;字段长度和唯一性由 full_clean() 的其他阶段处理。
super().clean()
# strip() 去首尾空白,lower() 统一大小写,避免同一能力出现肉眼相同但字节不同的键。
self.key = self.key.strip().lower()
def save(self, *args, **kwargs):
"""在所有保存入口再次统一编码,然后返回父类保存结果。"""
# 输入:实例及 Django save() 的标准参数;输出:父类 save() 返回值;调用者:表单、脚本、命令和 ORM;失败:数据库连接/约束错误向上抛出。
# save() 不会自动调用 full_clean(),所以 shell 或脚本直接保存时仍必须在模型边界规范 key。
self.key = self.key.strip().lower()
# *args/**kwargs 原样转交,保留 update_fields、using、force_insert 等 Django 标准保存选项。
return super().save(*args, **kwargs)
def __str__(self):
"""同时显示人类名称和程序编码。"""
# 输入:Capability 实例;输出:“名称(编码)”;调用者:模板、表单选项、后台、日志及 str();失败:正常持久化字段下不抛业务异常。
return "%s(%s)" % (self.name, self.key)
# Role 把多个 Capability 组合成职责集合;它继承 models.Model 获得 ORM 能力,但不是 Django Group 的改名。
class Role(models.Model):
# key 是程序与 bootstrap 使用的唯一短文本;max_length=100,unique=True,且数据库/表单都不允许空。
key = models.CharField("角色编码", max_length=100, unique=True)
# name 是管理页面显示的唯一名称;unique=True 防止两个角色显示成同名而让管理员无法区分。
name = models.CharField("角色名称", max_length=100, unique=True)
# description 是可选长文本;blank=True 允许不填,数据库以空字符串表示,不参与策略计算。
description = models.TextField("说明", blank=True)
# 系统角色由目录命令维护;手工创建默认 False,且字段不允许 NULL。
is_system = models.BooleanField("系统内置", default=False)
# 停用角色保留成员和能力历史,但 PolicySnapshot 不再从它授予能力;默认启用且不允许 NULL。
is_active = models.BooleanField("启用", default=True)
# 第一个位置参数 "创建时间" 是 verbose_name;auto_now_add=True 让 Django 在首次插入时写入时间,后续保存不变;删除角色时字段随行删除。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
# 第一个位置参数 "更新时间" 是 verbose_name;auto_now=True 在每次模型 save() 时改成当前时间,删除角色时随行删除。
updated_at = models.DateTimeField("更新时间", auto_now=True)
# 一个角色可含多个能力,一个能力也可属于多个角色,所以使用 ManyToManyField。
capabilities = models.ManyToManyField(
# Capability 类已经定义,可以直接传类对象作为多对多另一端。
Capability,
# verbose_name 是该多对多字段在表单和界面中的显示名称,不会创建额外数据库列。
verbose_name="能力",
# 显式中间模型保存分配人、分配时间和唯一约束;不能让 Django 生成匿名中间表。
through="RoleCapability",
# Capability.roles 提供“哪些角色包含此能力”的反向查询。
related_name="roles",
# blank=True 允许模型表单提交空能力集合;多对多关系保存在中间表,所以这里没有数据库 NULL。
blank=True,
)
# Meta 配置 Role 的查询顺序和界面名称,不参与角色授权计算。
class Meta:
# 先按名称便于人阅读;同名虽被唯一约束阻止,key 仍作为稳定的第二排序键。
ordering = ["name", "key"]
# verbose_name 是一条 Role 记录的单数显示名称。
verbose_name = "角色"
# verbose_name_plural 是多条记录的复数显示名称;中文仍使用“角色”。
verbose_name_plural = "角色"
def clean(self):
"""规范编码并拒绝自定义角色占用系统保留键或名称。"""
# 输入:Role 实例;输出:规范化且校验通过后返回 None;调用者:ModelForm/full_clean();失败:占用保留值时抛字段级 ValidationError。
# 先调用父类清理钩子,再把 key 统一成目录使用的小写形式;字段/唯一性检查属于 full_clean() 的其他阶段。
super().clean()
self.key = self.key.strip().lower()
# 非系统角色不能占用代码目录中的稳定键;字典键 key 让错误显示在角色编码字段旁。
if self.key in ROLE_KEYS and not self.is_system:
raise ValidationError({"key": "该角色编码由系统内置角色保留。"})
# 名称也要保护,否则自定义同名角色会让 bootstrap 无法创建或修复真正的系统角色。
if self.name.strip() in ROLE_NAMES and not self.is_system:
raise ValidationError({"name": "该角色名称由系统内置角色保留。"})
def save(self, *args, **kwargs):
"""在任意保存路径执行同一保留值检查。"""
# 输入:Role 实例及标准 save 参数;输出:父类 save() 返回值;调用者:表单、脚本、命令和 ORM;失败:保留值冲突抛 ValidationError,数据库错误继续上抛。
# save() 不会自动调用 clean();直接从 shell、脚本或未来 API 保存时仍必须守住系统保留值。
self.key = self.key.strip().lower()
self.name = self.name.strip()
if self.key in ROLE_KEYS and not self.is_system:
raise ValidationError("该角色编码由系统内置角色保留。")
if self.name in ROLE_NAMES and not self.is_system:
raise ValidationError("该角色名称由系统内置角色保留。")
# 检查通过后才让 ORM 执行 INSERT/UPDATE,并原样传递调用者的保存选项。
return super().save(*args, **kwargs)
def __str__(self):
"""角色在界面中显示人类可读名称。"""
# 输入:Role 实例;输出:角色名称;调用者:模板、表单选项、后台、日志及 str();失败:正常持久化字段下不抛业务异常。
return self.name
# RoleCapability 继承 models.Model,成为可查询和可约束的显式中间模型;一行只表达一个角色与一个能力的关系。
class RoleCapability(models.Model):
# role 指向关系所属角色;它是必填外键,未设置 null/blank,所以数据库和表单都不允许空。
role = models.ForeignKey(
# 目标模型是 Role;数据库实际保存 role_id,而 Python 访问 self.role 时得到 Role 对象。
Role,
# verbose_name="角色" 是表单和错误信息使用的字段显示名称。
verbose_name="角色",
# 删除角色后这些授权关系已无主体,CASCADE 自动删除对应中间行。
on_delete=models.CASCADE,
# Role.role_capabilities 提供角色到中间行的反向查询,不与 Role.capabilities 混淆。
related_name="role_capabilities",
)
# capability 指向被授予的能力;它同样必填。
capability = models.ForeignKey(
# 目标模型是 Capability,数据库保存 capability_id。
Capability,
# verbose_name="能力" 是表单和错误信息使用的字段显示名称。
verbose_name="能力",
# 删除能力后关系失去目标,CASCADE 清理对应中间行。
on_delete=models.CASCADE,
# Capability.role_capabilities 用于从能力反查全部中间授权行。
related_name="role_capabilities",
)
# assigned_by 是审计指针,记录谁执行分配;它不能决定授权结果。
assigned_by = models.ForeignKey(
# 操作人也是本项目 User。
User,
# verbose_name="分配人" 是表单和界面使用的字段显示名称。
verbose_name="分配人",
# null=True 允许数据库为 NULL,表示系统初始化、历史迁移或原操作人已删除。
null=True,
# blank=True 允许表单不选择分配人;系统命令创建关系时也可以留空。
blank=True,
# 删除操作人不应撤销别人现有能力,因此 SET_NULL 只清空 assigned_by_id,保留授权行。
on_delete=models.SET_NULL,
# User.assigned_role_capabilities 反查该用户曾建立的角色能力关系。
related_name="assigned_role_capabilities",
)
# 第一个位置参数 "分配时间" 是 verbose_name;auto_now_add=True 在关系首次创建时记时,字段不可为 NULL,并随关系行删除。
created_at = models.DateTimeField("分配时间", auto_now_add=True)
# Meta 为中间模型设置稳定顺序、数据库约束和界面名称。
class Meta:
# 默认按外键整数列排序;使用 role_id/capability_id 不需要为排序读取关联对象。
ordering = ["role_id", "capability_id"]
# constraints 是交给数据库执行的规则列表;它比只在表单中查重更能防住并发写入。
constraints = [
models.UniqueConstraint(
# role 与 capability 的组合必须唯一;同一角色不能重复拥有同一能力。
fields=["role", "capability"],
# 固定名称会写入迁移并方便数据库报错、排障和未来修改约束。
name="accounts_unique_role_capability",
)
]
# 单条中间记录的人类可读名称。
verbose_name = "角色能力"
# verbose_name_plural 是多条中间记录的复数显示名称;中文仍使用“角色能力”。
verbose_name_plural = "角色能力"
def __str__(self):
"""返回角色与能力编码组成的关联标签。"""
# 输入:RoleCapability 实例;输出:“角色:能力编码”;调用者:模板、后台、日志及 str();失败:关联对象缺失或查询失败时异常向上抛出。
# self.role 与 self.capability 可能触发延迟查询;使用能力 key 而非名称可保留稳定审计标签。
return "%s:%s" % (self.role, self.capability.key)
# UserRole 继承 models.Model,成为可查询和可约束的“用户拥有角色”显式中间模型;业务授权链从这里进入 Role。
class UserRole(models.Model):
# user 是被授权主体,必填且一行只能指向一个用户。
user = models.ForeignKey(
# 目标模型是自定义 User,数据库实际保存 user_id。
User,
# verbose_name="用户" 是界面和表单使用的字段显示名称。
verbose_name="用户",
# 删除用户后成员关系没有独立意义,CASCADE 自动删除对应 UserRole 行。
on_delete=models.CASCADE,
# User.user_roles 反查显式中间行;User.roles 则直接取得角色对象。
related_name="user_roles",
)
# role 是授予用户的项目角色,必填。
role = models.ForeignKey(
# 目标模型是 Role,数据库实际保存 role_id。
Role,
# verbose_name="角色" 是界面和表单使用的字段显示名称。
verbose_name="角色",
# 删除角色后所有成员关系都失去目标,CASCADE 自动清理。
on_delete=models.CASCADE,
# Role.user_roles 反查该角色对应的中间成员行。
related_name="user_roles",
)
# assigned_by 记录执行授权的人;它与上面的 user 可能指向同一张用户表,但语义不同。
assigned_by = models.ForeignKey(
# 操作人仍然是 User,所以 UserRole 内有两个指向 User 的外键。
User,
# verbose_name="分配人" 是界面和表单使用的字段显示名称。
verbose_name="分配人",
# null=True 允许数据库为空,表示系统初始化、历史迁移或操作人已被删除。
null=True,
# blank=True 允许表单不填写;它与 null=True 分别控制表单层和数据库层。
blank=True,
# 删除操作人不能撤销目标用户已有角色,SET_NULL 只清空审计指针。
on_delete=models.SET_NULL,
# 必须使用不同 related_name,避免和 user 外键的反向访问器冲突。
related_name="assigned_user_roles",
)
# 第一个位置参数 "分配时间" 是 verbose_name;auto_now_add=True 在关系首次创建时自动记时,字段不可为空,并随中间行删除。
created_at = models.DateTimeField("分配时间", auto_now_add=True)
# Meta 配置成员关系的默认顺序、唯一约束和显示名称。
class Meta:
# 默认按 user_id、role_id 排序,结果稳定且无需读取关联对象参与排序。
ordering = ["user_id", "role_id"]
# 数据库约束是并发情况下防止重复分配同一角色的最终防线。
constraints = [
models.UniqueConstraint(
# user 与 role 的组合必须唯一;一个用户不能重复绑定同一角色。
fields=["user", "role"],
# 固定约束名进入迁移,方便识别数据库错误和维护。
name="accounts_unique_user_role",
)
]
# verbose_name 是单条关系的显示名称。
verbose_name = "用户角色"
# verbose_name_plural 是多条关系的复数显示名称;中文仍使用“用户角色”。
verbose_name_plural = "用户角色"
def __str__(self):
"""返回用户与角色组成的关联标签。"""
# 输入:UserRole 实例;输出:“用户:角色”;调用者:模板、后台、日志及 str();失败:关联对象缺失或查询失败时异常向上抛出。
# self.user 和 self.role 可能触发延迟查询;二者各自的 __str__() 决定最终可读文字。
return "%s:%s" % (self.user, self.role)
5.1 Department 字段逐项说明
字段类型、构造参数、空值语义和关联删除行为已经逐项写在上方代码旁;这里不再把同一信息重复抄成表格。
5.2 User 字段逐项说明
User 继承 AbstractUser。本文直接使用的框架字段与自定义字段都列在下面。框架内部还保留管理站点兼容关系,但本系列的业务策略不会读取它们。
字段类型、构造参数、空值语义和关联删除行为已经逐项写在上方代码旁;这里不再把同一信息重复抄成表格。
密码必须通过 create_user()、set_password() 或框架表单写入,不能执行 user.password = raw_password。后者会把原文当作已经编码的值保存,既不安全也无法按预期登录。
5.3 Capability 字段逐项说明
字段类型、构造参数、空值语义和关联删除行为已经逐项写在上方代码旁;这里不再把同一信息重复抄成表格。
5.4 Role 字段逐项说明
字段类型、构造参数、空值语义和关联删除行为已经逐项写在上方代码旁;这里不再把同一信息重复抄成表格。
5.5 RoleCapability 字段逐项说明
字段类型、构造参数、空值语义和关联删除行为已经逐项写在上方代码旁;这里不再把同一信息重复抄成表格。
数据库唯一约束 (role, capability) 保证同一能力不会在同一角色中重复出现。应用层的 get_or_create() 提供幂等体验,数据库约束仍是并发情况下的最终防线。
5.6 UserRole 字段逐项说明
字段类型、构造参数、空值语义和关联删除行为已经逐项写在上方代码旁;这里不再把同一信息重复抄成表格。
数据库唯一约束 (user, role) 拒绝重复成员关系。它同时让后面的 get_or_create() 可以安全重复执行。
5.7 模型方法的输入、输出、调用者与失败
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
重要:Django 的 save() 不会自动调用 full_clean()。因此,角色的系统保留键检查同时写在 clean() 和 save() 中;只在网页表单里校验会让 shell、脚本或未来 API 绕过边界。
6. 迁移边界:0001~0003,且 0003 只是升级机械
6.1 为什么新教程仍保留三段历史
v2.0.0 的权威迁移链保留发布历史:
0001_initial建立Department与自定义User。它是早期版本的历史快照。0002_custom_rbac增加Capability、Role、RoleCapability、UserRole以及两个多对多查询入口,并清理旧模型选项。0003_migrate_legacy_access只负责把旧教程已经存在的数据搬入新链,并创建初始目录。它是升级机械,不是 v2.0.0 的学习模型,也不是运行时授权 API。
全新数据库执行这三份迁移同样安全:没有旧成员就没有成员可搬;目录行会被创建,随后 bootstrap_rbac 再按当前代码精确同步。不要在运行时从 0003 导入任何对象,也不要复制 0003 的历史数据访问方式到视图、表单、模板或策略层。
关于 0003 中的历史名称:为了让旧版数据库可升级,迁移必须通过 apps.get_model() 读取当时的历史模型。那段代码只在迁移执行时运行。本文展示它是为了让迁移链完整可运行,不是在教授旧授权接口。新的业务判断始终只使用 User → UserRole → Role → RoleCapability → Capability。
6.2 完整 accounts/migrations/0001_initial.py
# accounts/migrations/0001_initial.py
# Generated by Django 5.2.17 on 2026-08-26 04:17
# django.contrib.auth.models 提供 UserManager;迁移需保存当时绑定到 User.objects 的管理器定义。
import django.contrib.auth.models
# auth.validators 提供 UnicodeUsernameValidator,用于还原用户名字段当时的校验器。
import django.contrib.auth.validators
# deletion 保存 PROTECT 等 on_delete 策略;迁移直接引用它们以重建相同外键行为。
import django.db.models.deletion
# timezone.now 是 date_joined 的可调用默认值,建用户时才取当前时间。
import django.utils.timezone
# migrations 提供迁移步骤类,models 提供迁移状态中的字段构造器。
from django.db import migrations, models
# 每个迁移文件都定义一个继承 migrations.Migration 的 Migration;父类让 Django 读取依赖和操作图。
class Migration(migrations.Migration):
# initial=True 标记这是 accounts 应用的第一份迁移,Django 可据此处理首次建表。
initial = True
# dependencies 是“应用名、迁移名”二元组列表;先完成 auth 的用户字段迁移,才能引用其模型状态。
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
# operations 是按顺序执行的操作列表;CreateModel 会据迁移状态生成 CREATE TABLE 等数据库语句。
# 迁移中的字段是 models.py 在生成迁移当时的历史快照;以后模型代码改变,也不能回头改写这里的语义。
# 常见参数:verbose_name 是界面标签,blank 控制表单空值,null 控制数据库 NULL,default 提供新值,unique 建唯一约束。
# 主键的 auto_created 表示框架生成,primary_key 表示唯一行标识,serialize=False 表示普通模型序列化时省略这个自动字段。
operations = [
migrations.CreateModel(
# name 是历史模型类名;fields 是“字段名、字段对象”二元组列表;options 对应模型 Meta 快照。
name="Department",
fields=[
# 字段构造器的第一个位置参数通常是 verbose_name;key=value 是有名称的关键字参数,顺序不影响含义。
# 类型:BigAutoField;用途:作为部门自增主键;空值:不允许 NULL;删除:删除部门记录时主键随记录一并删除。
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
# 类型:CharField;用途:保存唯一部门编码;空值:不允许 NULL;删除:随部门记录一并删除。
(
"code",
models.CharField(
max_length=50,
unique=True,
verbose_name="部门编码",
),
),
# 类型:CharField;用途:保存部门名称;空值:不允许 NULL;删除:随部门记录一并删除。
("name", models.CharField(max_length=100, verbose_name="部门名称")),
# 类型:BooleanField;用途:记录部门启用状态;空值:不允许 NULL,默认 True;删除:随部门记录一并删除。
("is_active", models.BooleanField(default=True, verbose_name="启用")),
# 类型:PositiveIntegerField;用途:记录部门排序值;空值:不允许 NULL,默认 0;删除:随部门记录一并删除。
("sort_order", models.PositiveIntegerField(default=0, verbose_name="排序")),
# 类型:DateTimeField;用途:自动记录部门创建时间;空值:不允许 NULL;删除:随部门记录一并删除。
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
# 类型:DateTimeField;用途:自动记录部门更新时间;空值:不允许 NULL;删除:随部门记录一并删除。
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
# 类型:ForeignKey(Department);用途:关联上级部门;空值:允许 NULL 和表单空值;删除:PROTECT 阻止删除仍有下级的部门。
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="children",
to="accounts.department",
verbose_name="上级部门",
),
),
],
options={
# verbose_name/verbose_name_plural 分别是单数和复数显示名;中文不需要英语式复数变化。
"verbose_name": "部门",
"verbose_name_plural": "部门",
# ordering 是没有显式 order_by() 时采用的默认排序,先 sort_order,再用唯一 code 保持稳定。
"ordering": ["sort_order", "code"],
},
),
migrations.CreateModel(
# User 的字段同时包含 AbstractUser 继承来的认证字段和本项目新增业务字段。
name="User",
fields=[
# 类型:BigAutoField;用途:作为用户自增主键;空值:不允许 NULL;删除:删除用户记录时主键随记录一并删除。
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
# 类型:CharField;用途:保存哈希后的密码表示;空值:不允许 NULL;删除:随用户记录一并删除。
("password", models.CharField(max_length=128, verbose_name="password")),
# 类型:DateTimeField;用途:记录最近一次登录时间;空值:允许 NULL 和表单空值;删除:随用户记录一并删除。
(
"last_login",
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
),
# 类型:BooleanField;用途:标记用户是否拥有 Django 超级用户身份;空值:不允许 NULL,默认 False;删除:随用户记录一并删除。
(
"is_superuser",
models.BooleanField(
# default 是新对象未显式赋值时的值;help_text 是管理表单提示;verbose_name 是字段标签。
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
# 类型:CharField;用途:保存唯一登录名;空值:不允许 NULL;删除:随用户记录一并删除。
(
"username",
models.CharField(
# error_messages 覆盖指定校验码的提示;validators 是保存于迁移状态的校验器列表。
error_messages={
"unique": "A user with that username already exists."
},
# help_text 只解释输入要求;真正允许哪些字符由下面的 UnicodeUsernameValidator 检查。
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",
),
),
# 类型:CharField;用途:保存 Django 兼容名字字段;空值:数据库不存 NULL,但允许表单空值;删除:随用户记录一并删除。
("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")),
# 类型:CharField;用途:保存 Django 兼容姓氏字段;空值:数据库不存 NULL,但允许表单空值;删除:随用户记录一并删除。
("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")),
# 类型:EmailField;用途:保存电子邮箱;空值:数据库不存 NULL,但允许表单空值;删除:随用户记录一并删除。
("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")),
# 类型:BooleanField;用途:标记用户能否进入 Django 后台;空值:不允许 NULL,默认 False;删除:随用户记录一并删除。
(
"is_staff",
models.BooleanField(
# default=False 表示普通用户默认不能进后台;help_text/verbose_name 只影响可读说明和标签。
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
# 类型:BooleanField;用途:标记账号是否可用;空值:不允许 NULL,默认 True;删除:随用户记录一并删除。
(
"is_active",
models.BooleanField(
# default=True 让新账号默认可认证;停用时保留记录而不是删除用户。
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
# 类型:DateTimeField;用途:记录账号加入时间;空值:不允许 NULL,默认取当前时间;删除:随用户记录一并删除。
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="date joined",
),
),
# 类型:CharField;用途:保存业务显示姓名;空值:数据库不存 NULL,但允许表单空值;删除:随用户记录一并删除。
("display_name", models.CharField(blank=True, max_length=100, verbose_name="姓名")),
# 类型:CharField;用途:保存唯一工号;空值:允许 NULL 和表单空值;删除:随用户记录一并删除。
(
"employee_number",
models.CharField(
blank=True,
max_length=50,
null=True,
unique=True,
verbose_name="工号",
),
),
# 类型:CharField;用途:保存手机号;空值:数据库不存 NULL,但允许表单空值;删除:随用户记录一并删除。
("mobile", models.CharField(blank=True, max_length=20, verbose_name="手机号")),
# 类型:ManyToManyField(Group);用途:保留 Django 组兼容关系;空值:允许没有组且中间表不存 NULL;删除:删除用户或组时清理中间关系。
(
"groups",
models.ManyToManyField(
# blank=True 允许集合为空;related_name 是 Group 到用户对象的反向属性。
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 是从 Group 查询用户时使用的过滤路径;to 是关联模型标签。
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
# 类型:ManyToManyField(Permission);用途:保留 Django 用户直接权限兼容关系;空值:允许没有权限且中间表不存 NULL;删除:删除用户或权限时清理中间关系。
(
"user_permissions",
models.ManyToManyField(
# 参数含义与 groups 相同,只是关联目标换成单条 Permission。
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
# 类型:ForeignKey(Department);用途:关联用户所属部门;空值:允许 NULL 和表单空值;删除:PROTECT 阻止删除仍有用户的部门。
(
"department",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="users",
to="accounts.department",
verbose_name="部门",
),
),
],
# options 是当时 User.Meta 的字典快照;permissions 列表中的二元组是“权限编码、显示名称”。
# verbose_name/verbose_name_plural 继承 AbstractUser 的单数/复数界面名称。
# 若数据库曾停在 0001,post_migrate 会据 permissions 创建这两项旧教程 Permission;0002 从后续模型状态移除,0003 清理已存在的旧行。
# abstract=False 表示这是要建表的实体模型,不是只供子类继承、自己不建表的抽象模型。
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"permissions": [
("set_user_status", "可以启用或停用用户"),
("assign_user_roles", "可以分配用户角色和直接权限"),
],
"abstract": False,
},
# managers 保存模型管理器快照;objects 将继续提供 create_user() 等 UserManager 方法。
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
这份文件是不可回写的历史状态,不代表当前业务设计。0002 会移除旧模型选项,0003 会清理旧数据。迁移文件一旦在环境中执行过,就不应为了“看起来更干净”而直接改写;升级路径依赖历史快照稳定。
6.3 完整 accounts/migrations/0002_custom_rbac.py
# accounts/migrations/0002_custom_rbac.py
# Generated by Django 5.2.17 on 2026-09-22 02:22
# deletion 提供 SET_NULL、CASCADE 等数据库关联删除策略。
import django.db.models.deletion
# settings.AUTH_USER_MODEL 延迟指向项目配置的用户模型,避免把 accounts.User 硬编码进可复用迁移定义。
from django.conf import settings
# migrations 提供 CreateModel/AddField/AddConstraint,models 提供对应字段和约束构造器。
from django.db import migrations, models
# 继承 Migration 后,Django 才能把本类加入迁移依赖图并依次执行 operations。
class Migration(migrations.Migration):
# 依赖二元组要求先建立 0001 中的 Department 和 User 表。
dependencies = [
("accounts", "0001_initial"),
]
# operations 是有序迁移步骤列表:先建表,再补多对多字段,最后添加数据库唯一约束。
# 字段参数沿用模型语义:verbose_name 是界面标签,blank 控制表单空值,null 控制数据库 NULL,default 提供默认值。
# BigAutoField 的 auto_created/primary_key/serialize=False 表示框架生成的自增行标识,普通模型序列化时省略这个自动字段。
operations = [
migrations.CreateModel(
# CreateModel 的 name 指模型名;fields 是“名称、字段构造器”二元组列表;options 保存 Meta 选项。
name="Capability",
fields=[
# 位置参数通常用于 verbose_name,max_length=100 等 key=value 是关键字参数;迁移显式保存这些值以便重放。
# 类型:BigAutoField;用途:作为能力自增主键;空值:不允许 NULL;删除:删除能力记录时主键随记录一并删除。
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
# 类型:CharField;用途:保存唯一能力编码;空值:不允许 NULL;删除:随能力记录一并删除。
("key", models.CharField(max_length=150, unique=True, verbose_name="能力编码")),
# 类型:CharField;用途:保存能力名称;空值:不允许 NULL;删除:随能力记录一并删除。
("name", models.CharField(max_length=100, verbose_name="能力名称")),
# 类型:TextField;用途:保存能力说明;空值:数据库不存 NULL,但允许表单空值;删除:随能力记录一并删除。
("description", models.TextField(blank=True, verbose_name="说明")),
# 类型:BooleanField;用途:标记能力是否由系统维护;空值:不允许 NULL,默认 False;删除:随能力记录一并删除。
("is_system", models.BooleanField(default=False, verbose_name="系统内置")),
# 类型:BooleanField;用途:标记能力是否启用;空值:不允许 NULL,默认 True;删除:随能力记录一并删除。
("is_active", models.BooleanField(default=True, verbose_name="启用")),
# 类型:DateTimeField;用途:自动记录能力创建时间;空值:不允许 NULL;删除:随能力记录一并删除。
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
# 类型:DateTimeField;用途:自动记录能力更新时间;空值:不允许 NULL;删除:随能力记录一并删除。
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
],
options={
# 单数/复数名称供后台和表单显示;ordering 是未显式排序时按稳定能力 key 升序。
"verbose_name": "能力",
"verbose_name_plural": "能力",
"ordering": ["key"],
},
),
migrations.CreateModel(
# Role 与 Capability 一样先创建独立表;多对多字段要等显式中间模型存在后再用 AddField 加入迁移状态。
name="Role",
fields=[
# 类型:BigAutoField;用途:作为角色自增主键;空值:不允许 NULL;删除:删除角色记录时主键随记录一并删除。
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
# 类型:CharField;用途:保存唯一角色编码;空值:不允许 NULL;删除:随角色记录一并删除。
("key", models.CharField(max_length=100, unique=True, verbose_name="角色编码")),
# 类型:CharField;用途:保存唯一角色名称;空值:不允许 NULL;删除:随角色记录一并删除。
("name", models.CharField(max_length=100, unique=True, verbose_name="角色名称")),
# 类型:TextField;用途:保存角色说明;空值:数据库不存 NULL,但允许表单空值;删除:随角色记录一并删除。
("description", models.TextField(blank=True, verbose_name="说明")),
# 类型:BooleanField;用途:标记角色是否由系统维护;空值:不允许 NULL,默认 False;删除:随角色记录一并删除。
("is_system", models.BooleanField(default=False, verbose_name="系统内置")),
# 类型:BooleanField;用途:标记角色是否启用;空值:不允许 NULL,默认 True;删除:随角色记录一并删除。
("is_active", models.BooleanField(default=True, verbose_name="启用")),
# 类型:DateTimeField;用途:自动记录角色创建时间;空值:不允许 NULL;删除:随角色记录一并删除。
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
# 类型:DateTimeField;用途:自动记录角色更新时间;空值:不允许 NULL;删除:随角色记录一并删除。
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
],
options={
# 中文单复数显示名相同;默认先按人类可读 name,再按稳定 key 排序。
"verbose_name": "角色",
"verbose_name_plural": "角色",
"ordering": ["name", "key"],
},
),
# AlterModelOptions 只更新迁移状态中的 Meta 选项;这里移除 0001 的旧自定义 permissions,不改字段。
# name 用迁移中的小写模型名定位 User;options 是替换后的完整选项快照,而不是只追加两个键。
migrations.AlterModelOptions(
name="user",
options={"verbose_name": "user", "verbose_name_plural": "users"},
),
migrations.CreateModel(
name="RoleCapability",
fields=[
# 类型:BigAutoField;用途:作为角色能力关联自增主键;空值:不允许 NULL;删除:删除关联记录时主键随记录一并删除。
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
# 类型:DateTimeField;用途:自动记录能力分配时间;空值:不允许 NULL;删除:随角色能力关联一并删除。
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="分配时间")),
# 类型:ForeignKey(User);用途:记录能力分配人;空值:允许 NULL 和表单空值;删除:删除操作人时 SET_NULL 保留关联。
(
"assigned_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assigned_role_capabilities",
to=settings.AUTH_USER_MODEL,
verbose_name="分配人",
),
),
# 类型:ForeignKey(Capability);用途:指定被关联的能力;空值:不允许 NULL;删除:删除能力时 CASCADE 删除对应关联。
(
"capability",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="role_capabilities",
to="accounts.capability",
verbose_name="能力",
),
),
# 类型:ForeignKey(Role);用途:指定被授予能力的角色;空值:不允许 NULL;删除:删除角色时 CASCADE 删除对应关联。
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="role_capabilities",
to="accounts.role",
verbose_name="角色",
),
),
],
options={
# 显示名描述一条/多条中间关系;ordering 直接按外键 ID 排序,稳定且无需读取关联表。
"verbose_name": "角色能力",
"verbose_name_plural": "角色能力",
"ordering": ["role_id", "capability_id"],
},
),
# 类型:ManyToManyField(Capability);用途:经 RoleCapability 关联角色与能力;空值:允许没有能力且中间表不存 NULL;删除:删除两端记录时清理中间关联。
# AddField 的 model_name/name 定位 Role.capabilities;field 保存完整字段定义,through 指定显式中间模型。
migrations.AddField(
model_name="role",
name="capabilities",
field=models.ManyToManyField(
blank=True,
related_name="roles",
through="accounts.RoleCapability",
to="accounts.capability",
verbose_name="能力",
),
),
migrations.CreateModel(
name="UserRole",
fields=[
# 类型:BigAutoField;用途:作为用户角色关联自增主键;空值:不允许 NULL;删除:删除关联记录时主键随记录一并删除。
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
# 类型:DateTimeField;用途:自动记录角色分配时间;空值:不允许 NULL;删除:随用户角色关联一并删除。
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="分配时间")),
# 类型:ForeignKey(User);用途:记录角色分配人;空值:允许 NULL 和表单空值;删除:删除操作人时 SET_NULL 保留关联。
(
"assigned_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assigned_user_roles",
to=settings.AUTH_USER_MODEL,
verbose_name="分配人",
),
),
# 类型:ForeignKey(Role);用途:指定用户获得的角色;空值:不允许 NULL;删除:删除角色时 CASCADE 删除对应关联。
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_roles",
to="accounts.role",
verbose_name="角色",
),
),
# 类型:ForeignKey(User);用途:指定被授予角色的用户;空值:不允许 NULL;删除:删除用户时 CASCADE 删除对应关联。
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_roles",
to=settings.AUTH_USER_MODEL,
verbose_name="用户",
),
),
],
options={
# 中间关系的中文单复数名称相同;按 user_id/role_id 排序不会触发关联对象查询。
"verbose_name": "用户角色",
"verbose_name_plural": "用户角色",
"ordering": ["user_id", "role_id"],
},
),
# 类型:ManyToManyField(Role);用途:经 UserRole 关联用户与项目角色;空值:允许没有角色且中间表不存 NULL;删除:删除两端记录时清理中间关联。
# through_fields 按“来源 User、目标 Role”明确中间模型中的两端,因为 UserRole 另有 assigned_by 也指向 User。
migrations.AddField(
model_name="user",
name="roles",
field=models.ManyToManyField(
blank=True,
related_name="users",
through="accounts.UserRole",
through_fields=("user", "role"),
to="accounts.role",
verbose_name="角色",
),
),
# AddConstraint 把组合唯一规则落实到数据库;表单检查之外,并发写入最终也不能产生重复关系。
migrations.AddConstraint(
model_name="rolecapability",
# fields 是参与组合唯一的字段元组;name 是稳定数据库约束名,便于后续迁移和排障。
constraint=models.UniqueConstraint(
fields=("role", "capability"),
name="accounts_unique_role_capability",
),
),
# 用户与角色也采用同样的数据库组合唯一约束,一个用户不能重复绑定同一角色。
migrations.AddConstraint(
model_name="userrole",
constraint=models.UniqueConstraint(
fields=("user", "role"),
name="accounts_unique_user_role",
),
),
]
6.4 完整 accounts/migrations/0003_migrate_legacy_access.py
再次强调:下列文件是升级机械。它使用历史应用注册表,目的只有“让旧版数据安全过桥”。新页面、新命令和新策略不能把它当作可复用业务层。
# accounts/migrations/0003_migrate_legacy_access.py
# migrations 提供 Migration 与 RunPython,用 Python 函数在既有表之间搬迁历史数据。
from django.db import migrations
# 这份映射是 0001 版本中真正展示给用户的旧角色名称。
# 迁移文件必须保存历史快照,不能依赖未来可能继续变化的 capabilities.py。
# 花括号创建字典;每个“旧中文组名: 新角色 key”键值对只服务于这次历史数据转换。
LEGACY_ROLE_MAP = {
"平台管理员": "platform_admin",
"用户管理员": "user_manager",
"权限管理员": "permission_manager",
"只读用户查看员": "readonly_viewer",
}
# 外层 tuple 保存固定能力快照;每个内层三元组按顺序保存 key、名称、说明,for 循环可直接解包为三个变量。
CAPABILITY_CATALOG = (
("accounts.dashboard.view", "查看管理首页", "访问用户与权限管理首页。"),
("accounts.user.view", "查看用户", "查看用户列表和用户详情。"),
("accounts.user.create", "创建用户", "创建普通用户账号。"),
("accounts.user.change", "编辑用户", "编辑用户业务资料。"),
("accounts.user.delete", "删除用户", "删除用户账号,但不能破坏最后一个激活超级用户。"),
("accounts.user.status", "管理用户状态", "启用或停用用户账号。"),
("accounts.user.assign_role", "分配用户角色", "为用户替换项目自有角色,不提供直接权限分配。"),
("accounts.department.view", "查看部门", "查看部门层级和部门用户统计。"),
("accounts.department.manage", "管理部门", "创建、编辑和删除部门。"),
("accounts.role.view", "查看角色", "查看项目自有角色及其能力。"),
("accounts.role.manage", "管理角色", "创建、编辑和删除项目自有角色及能力关联。"),
("accounts.capability.view", "查看能力目录", "只读查看项目自有能力目录。"),
)
# 每个角色四元组依次是 key、名称、说明、能力 key 元组;这是迁移时点的完整角色能力快照。
ROLE_CATALOG = (
(
"platform_admin",
"平台管理员",
"拥有本项目全部管理能力。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.user.create",
"accounts.user.change",
"accounts.user.delete",
"accounts.user.status",
"accounts.user.assign_role",
"accounts.department.view",
"accounts.department.manage",
"accounts.role.view",
"accounts.role.manage",
"accounts.capability.view",
),
),
(
"user_manager",
"用户管理员",
"负责用户资料、状态、角色分配及相关只读查询。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.user.create",
"accounts.user.change",
"accounts.user.delete",
"accounts.user.status",
"accounts.user.assign_role",
"accounts.department.view",
"accounts.role.view",
"accounts.capability.view",
),
),
(
"permission_manager",
"权限管理员",
"负责角色能力配置和用户角色分配。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.user.assign_role",
"accounts.department.view",
"accounts.role.view",
"accounts.role.manage",
"accounts.capability.view",
),
),
(
"readonly_viewer",
"只读查看员",
"只能查看管理数据和能力目录。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.department.view",
"accounts.role.view",
"accounts.capability.view",
),
),
)
def create_catalog_and_migrate_legacy_groups(apps, schema_editor):
"""创建 v2 目录并把旧成员关系迁入 UserRole。"""
# RunPython 固定传入两个参数:apps 提供该迁移时点的历史模型,schema_editor 提供当前数据库连接/模式编辑能力;本函数无需直接使用后者。
# 输入:历史模型注册器 apps 与迁移编辑器 schema_editor;输出:无返回值;调用者:Django RunPython;失败:模型查询、约束或数据库写入异常会使迁移失败并回滚。
# 数据库读取阶段:必须通过 apps 获取迁移时点的历史模型,不能直接导入当前 models.py;否则未来代码变化会让旧迁移不可重放。
Capability = apps.get_model("accounts", "Capability")
Role = apps.get_model("accounts", "Role")
RoleCapability = apps.get_model("accounts", "RoleCapability")
UserRole = apps.get_model("accounts", "UserRole")
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
# 保存阶段:逐项幂等写入能力目录,并保存编码到历史模型实例的映射。
# update_or_create() 按 key 查找:不存在就用 key+defaults 新建,存在就用 defaults 更新;返回“对象、是否新建”的二元组。
# 下划线变量表示这里有意忽略“是否新建”;字典 capability_map 让后续角色能按能力 key 找到对象而不重复查询。
capability_map = {}
for key, name, description in CAPABILITY_CATALOG:
capability, _ = Capability.objects.update_or_create(
key=key,
defaults={
"name": name,
"description": description,
"is_system": True,
"is_active": True,
},
)
capability_map[key] = capability
# 保存阶段:创建或修正角色,再补齐角色到能力的中间表关系。
# 四变量 for 直接解包角色四元组;get_or_create() 只在关系缺失时 INSERT,因此重复运行不会生成重复关联。
role_map = {}
for key, name, description, capability_keys in ROLE_CATALOG:
role, _ = Role.objects.update_or_create(
key=key,
defaults={
"name": name,
"description": description,
"is_system": True,
"is_active": True,
},
)
role_map[key] = role
for capability_key in capability_keys:
RoleCapability.objects.get_or_create(
role=role,
capability=capability_map[capability_key],
)
# 业务判断阶段:只有旧数据库里真实存在的历史成员才会过桥;全新数据库没有可迁数据。
# items() 逐项给出“旧组名、角色 key”;filter() 构造惰性 QuerySet,first() 执行查询并返回首项或 None。
for legacy_name, role_key in LEGACY_ROLE_MAP.items():
group = Group.objects.filter(name=legacy_name).first()
if group is None:
continue
role = role_map[role_key]
# group.user_set 是旧 Group.groups 多对多的反向管理器;all() 返回成员 QuerySet,for 迭代时才读取数据库。
for user in group.user_set.all():
# 直接写 user_id/role_id 可避免为已有主键再获取完整关联对象;defaults 只在新建关系时使用。
UserRole.objects.get_or_create(
user_id=user.pk,
role_id=role.pk,
defaults={"assigned_by_id": None},
)
# 数据库读取阶段:双下划线连接关联字段或表达查找:content_type__app_label 跨外键,codename__in 表示集合成员。
# filter() 本身只构造 QuerySet;delete() 才执行删除,并返回删除总数和按模型统计的二元组(这里无需接收)。
# 保存阶段:删除这些废弃授权记录,避免管理员误把它们当成当前授权来源。
obsolete_permissions = Permission.objects.filter(
content_type__app_label="accounts",
content_type__model="user",
codename__in=("set_user_status", "assign_user_roles"),
)
obsolete_permissions.delete()
# 返回阶段:函数自然结束并返回 None,RunPython 据此继续执行后续迁移。
def noop(apps, schema_editor):
"""反向迁移不删除业务数据,避免回滚时误删授权。"""
# 签名仍必须接收 RunPython 传来的 apps/schema_editor;虽然本函数不用它们,也不能删掉参数。
# 输入:历史模型注册器 apps 与迁移编辑器 schema_editor;输出:None;调用者:Django 反向 RunPython;失败:函数不访问数据库,正常情况下不抛业务异常。
# 返回阶段:明确返回 None,表示有意不执行逆向数据删除。
return None
# 继承 migrations.Migration 后,Django 才能把依赖和操作加入迁移图;未覆盖 atomic,沿用默认原子迁移行为。
class Migration(migrations.Migration):
# 必须先建好自有 RBAC 表,数据迁移才能取得这些历史模型并写入关系。
dependencies = [
("accounts", "0002_custom_rbac"),
]
# RunPython 的第一个函数用于向前迁移,第二个函数用于反向迁移;回退结构时有意保留已迁业务数据。
operations = [
migrations.RunPython(create_catalog_and_migrate_legacy_groups, noop),
]
6.5 0003 两个函数的契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
6.6 应用迁移并检查边界
执行目录:项目根目录。目的:先让 Django 做静态配置检查,再应用到 0003,并确认没有模型漂移。预期输出:check 显示没有问题;migrate 依次显示 accounts.0001、0002、0003 为 OK;showmigrations 的前三项都有 [X];makemigrations --check --dry-run 显示没有变化。常见错误:如果在声明 AUTH_USER_MODEL 前已经迁移过默认用户表,练习项目最安全的处理是删除本地 db.sqlite3 后从头迁移;不要在有真实数据的系统里这样做。
python manage.py check
python manage.py migrate
python manage.py showmigrations accounts
python manage.py makemigrations --check --dry-run
如果最后一条命令想生成新的 0004,先不要接受。常见原因是模型代码与本文迁移文本有拼写差异、Meta.ordering 不一致,或者漏写了约束。本文阶段正确结果应是 No changes detected。
7. 幂等同步能力与角色目录
7.1 bootstrap 与数据迁移分工
0003 保存发布时的历史快照,不能随着未来目录变化而修改。日常部署和本地重建则需要一个读取当前 capabilities.py 的幂等命令。命令要做到:
- 先验证目录结构、键唯一性和引用完整性;
- 在一个数据库事务中同步;
- 拒绝接管同键的非系统对象;
- 精确同步系统角色的能力集合;
- 把代码中已经删除的系统对象安全退休;
- 输出创建、更新、停用和关联变更计数;
- 绝不自动给用户分配角色。
创建完整的 accounts/management/commands/bootstrap_rbac.py:
# accounts/management/commands/bootstrap_rbac.py
# BaseCommand 让 Django 发现并运行本模块的 Command;CommandError 把可预期配置错误显示为命令行失败。
from django.core.management.base import BaseCommand, CommandError
# transaction.atomic() 把多次 ORM 写入包成一个事务,任一步异常都会回滚。
from django.db import transaction
# 目录常量是代码中的期望状态;命令据此校验并同步数据库。
from accounts.capabilities import (
CAPABILITY_CATALOG,
CAPABILITY_KEYS,
ROLE_CATALOG,
ROLE_KEYS,
)
# ORM 模型分别表示能力、角色及两类显式关联,供命令查询和写入。
from accounts.models import Capability, Role, RoleCapability, UserRole
def validate_catalog():
"""验证代码目录内部一致性,成功时返回 None。"""
# 输入:无显式参数,读取模块级能力和角色目录;输出:校验成功时自然返回 None;调用者:Command.handle();失败:目录结构或引用不合法时抛 CommandError。
# 业务判断阶段:先检查能力目录容器、必填文本、命名空间和编码唯一性。
# isinstance(value, types) 做运行时类型判断;(list, tuple) 表示两种类型任一即可,or 会在左侧为真时短路。
# not CAPABILITY_CATALOG 同时拒绝空列表/空元组;命令在任何数据库写入前先失败。
if not isinstance(CAPABILITY_CATALOG, (list, tuple)) or not CAPABILITY_CATALOG:
raise CommandError("能力目录必须是非空列表或元组。")
capability_keys = []
# enumerate(..., start=1) 同时给出从 1 开始的序号和当前元素;便于错误消息使用人类习惯的编号。
for index, item in enumerate(CAPABILITY_CATALOG, start=1):
if not isinstance(item, dict):
raise CommandError("第 %s 项能力定义必须是字典。" % index)
# item.get() 在键不存在时返回 None;for 逐个验证三个必填字段,str/strip 同时拒绝非字符串和纯空白。
for field_name in ("key", "name", "description"):
value = item.get(field_name)
if not isinstance(value, str) or not value.strip():
raise CommandError(
"第 %s 项能力缺少有效的 %s。" % (index, field_name)
)
if not item["key"].startswith("accounts."):
raise CommandError("能力编码必须使用 accounts.* 命名空间。")
capability_keys.append(item["key"])
# set 去重且不关心顺序;长度变化说明原列表有重复,集合相等则说明目录与导出的常量键完全一致。
if len(capability_keys) != len(set(capability_keys)):
raise CommandError("能力目录存在重复编码。")
if set(capability_keys) != set(CAPABILITY_KEYS):
raise CommandError("能力编码常量与能力目录不一致。")
# 业务判断阶段:再检查角色目录、角色名称唯一性以及每个角色引用的能力是否存在。
if not isinstance(ROLE_CATALOG, (list, tuple)) or not ROLE_CATALOG:
raise CommandError("角色目录必须是非空列表或元组。")
role_keys = []
role_names = []
for index, role in enumerate(ROLE_CATALOG, start=1):
if not isinstance(role, dict):
raise CommandError("第 %s 项角色定义必须是字典。" % index)
for field_name in ("key", "name", "description"):
value = role.get(field_name)
if not isinstance(value, str) or not value.strip():
raise CommandError(
"第 %s 项角色缺少有效的 %s。" % (index, field_name)
)
capabilities = role.get("capabilities")
if not isinstance(capabilities, (list, tuple)):
raise CommandError("角色 %s 的能力集合必须是列表或元组。" % role["key"])
# any() 遇到第一项 True 就短路;这里一发现非字符串或空字符串能力键便拒绝整个角色定义。
if any(not isinstance(key, str) or not key for key in capabilities):
raise CommandError("角色 %s 包含无效能力编码。" % role["key"])
if len(capabilities) != len(set(capabilities)):
raise CommandError("角色 %s 存在重复能力。" % role["key"])
# 集合差集 A - B 留下角色引用但目录未声明的键;sorted() 只为得到稳定、易读的错误顺序。
missing = sorted(set(capabilities) - set(capability_keys))
if missing:
raise CommandError(
"角色 %s 引用了不存在的能力:%s。"
% (role["key"], ", ".join(missing))
)
role_keys.append(role["key"])
role_names.append(role["name"])
if len(role_keys) != len(set(role_keys)):
raise CommandError("角色目录存在重复编码。")
if len(role_names) != len(set(role_names)):
raise CommandError("角色目录存在重复名称。")
if set(role_keys) != set(ROLE_KEYS):
raise CommandError("角色编码常量与角色目录不一致。")
# Django 会按 management/commands/<命令名>.py 发现模块,并实例化其中继承 BaseCommand 的 Command。
class Command(BaseCommand):
# help 显示在 manage.py help;requires_migrations_checks=True 会在 handle() 前警告存在未应用迁移,但不会替代真正的 migrate。
help = "幂等创建项目自有能力和四个基础角色"
requires_migrations_checks = True
def handle(self, *args, **options):
"""校验并精确同步系统目录,不创建用户或 UserRole。"""
# BaseCommand 调用 handle;*args 收集额外位置参数为 tuple,**options 收集解析后的命令选项为 dict,本命令不自定义参数。
# 输入:Django 命令位置参数 args 与选项 options;输出:无业务返回值并向 stdout 写摘要;调用者:manage.py bootstrap_rbac;失败:目录冲突抛 CommandError,数据库异常触发事务回滚。
# 业务判断阶段:写数据库前先验证代码中的目录定义,避免把无效配置同步进去。
validate_catalog()
counts = {
"capabilities_created": 0,
"capabilities_updated": 0,
"roles_created": 0,
"roles_updated": 0,
"links_created": 0,
"links_deleted": 0,
"capabilities_retired": 0,
"roles_retired": 0,
}
# 保存阶段:atomic() 返回事务上下文管理器;with 正常退出时提交,内部异常离开时回滚并继续向上抛出。
with transaction.atomic():
# 数据库读取阶段:按角色、能力、关联的固定顺序请求行锁,降低并发同步死锁概率。
# list() 强制立即执行三个惰性 QuerySet;只取 pk 减少传输,但会锁住各表在查询时已存在的全部匹配行。
# SQLite 忽略 select_for_update(),没有行锁效果,写入时仍依赖 SQLite 自身的事务/数据库写锁。
# 支持行锁的数据库也不会锁住尚不存在的 key(没有范围锁),并发新行仍必须由唯一约束作最终防线。
list(
Role.objects.select_for_update()
.order_by("pk")
.values_list("pk", flat=True)
)
list(
Capability.objects.select_for_update()
.order_by("pk")
.values_list("pk", flat=True)
)
list(
RoleCapability.objects.select_for_update()
.order_by("pk")
.values_list("pk", flat=True)
)
capability_map = {}
# 两个列表推导式从目录字典提取声明键;后面的 __in 查询会把它们作为 SQL IN 条件。
declared_capability_keys = [item["key"] for item in CAPABILITY_CATALOG]
declared_role_keys = [item["key"] for item in ROLE_CATALOG]
# 数据库读取阶段:filter() 形成惰性 QuerySet,first() 执行查询并返回首项或 None。
# 系统命令不能静默接管管理员创建的同键对象;无记录时才允许后续创建。
for item in CAPABILITY_CATALOG:
existing = Capability.objects.filter(key=item["key"]).first()
if existing is not None and not existing.is_system:
raise CommandError(
"能力编码 %s 已被非系统能力占用,拒绝接管。" % item["key"]
)
for item in ROLE_CATALOG:
existing = Role.objects.filter(key=item["key"]).first()
if existing is not None and not existing.is_system:
raise CommandError(
"角色编码 %s 已被非系统角色占用,拒绝接管。" % item["key"]
)
# 数据库读取阶段:filter(is_system=True) 只选系统记录,exclude(key__in=...) 再排除仍在目录中的键。
# stale_roles 立即转 list 是因为后面要逐个清关系和改名;stale_capabilities 可直接做批量 UPDATE。
stale_capabilities = Capability.objects.filter(is_system=True).exclude(
key__in=declared_capability_keys
)
stale_roles = list(
Role.objects.filter(is_system=True).exclude(key__in=declared_role_keys)
)
# 保存阶段:QuerySet.update() 用一条 SQL 批量写入并返回受影响行数;它不调用模型 save(),也不会自动刷新 auto_now 字段。
# 旧系统能力改为非系统且停用,保留记录供审计而不是直接删除。
counts["capabilities_retired"] = stale_capabilities.update(
is_system=False,
is_active=False,
)
for stale_role in stale_roles:
# 退休角色不能继续携带旧能力和用户成员关系。
# 只改 is_active 会留下可被误重新启用的历史授权,因此显式清空关联。
RoleCapability.objects.filter(role=stale_role).delete()
UserRole.objects.filter(role=stale_role).delete()
base_name = "[已停用] %s" % stale_role.name
# [:100] 是切片,最多保留 CharField 允许的 100 个字符;while 在候选名称仍冲突时持续加唯一后缀。
candidate_name = base_name[:100]
counter = 0
while Role.objects.filter(name=candidate_name).exclude(
pk=stale_role.pk
).exists():
counter += 1
suffix = "-%s-%s" % (stale_role.pk, counter)
candidate_name = "%s%s" % (
base_name[: 100 - len(suffix)],
suffix,
)
Role.objects.filter(pk=stale_role.pk).update(
name=candidate_name,
is_system=False,
is_active=False,
)
counts["roles_retired"] = len(stale_roles)
# 业务判断阶段:释放与系统角色固定中文名称冲突的自定义角色名称,同时保留其成员关系。
for item in ROLE_CATALOG:
conflict = Role.objects.filter(name=item["name"]).exclude(
key=item["key"]
).first()
if conflict is not None:
# 保留冲突角色及其成员,只重命名以释放系统角色的固定中文名称。
base_name = "[名称冲突] %s" % conflict.name
candidate_name = base_name[:100]
counter = 0
while Role.objects.filter(name=candidate_name).exclude(
pk=conflict.pk
).exists():
counter += 1
suffix = "-%s-%s" % (conflict.pk, counter)
candidate_name = "%s%s" % (
base_name[: 100 - len(suffix)],
suffix,
)
Role.objects.filter(pk=conflict.pk).update(name=candidate_name)
# 保存阶段:get_or_create() 用 key 查找并返回“对象、是否创建”;defaults 只用于 INSERT,不会更新已有对象。
# 逐项创建或修正系统能力,并把实例放入映射供角色关联使用。
for item in CAPABILITY_CATALOG:
capability, created = Capability.objects.get_or_create(
key=item["key"],
defaults={
"name": item["name"],
"description": item["description"],
"is_system": True,
"is_active": True,
},
)
if created:
counts["capabilities_created"] += 1
else:
changed = (
capability.name != item["name"]
or capability.description != item["description"]
or not capability.is_system
or not capability.is_active
)
Capability.objects.filter(pk=capability.pk).update(
name=item["name"],
description=item["description"],
is_system=True,
is_active=True,
)
if changed:
counts["capabilities_updated"] += 1
capability_map[item["key"]] = capability
# 保存阶段:逐项创建或修正系统角色,并把中间表精确同步为目录声明的能力集合。
for item in ROLE_CATALOG:
role, created = Role.objects.get_or_create(
key=item["key"],
defaults={
"name": item["name"],
"description": item["description"],
"is_system": True,
"is_active": True,
},
)
if created:
counts["roles_created"] += 1
else:
changed = (
role.name != item["name"]
or role.description != item["description"]
or not role.is_system
or not role.is_active
)
Role.objects.filter(pk=role.pk).update(
name=item["name"],
description=item["description"],
is_system=True,
is_active=True,
)
if changed:
counts["roles_updated"] += 1
# 集合推导式按本角色声明的 key 取得能力主键;set 自动去重并支持后面的差集运算。
expected_ids = {
capability_map[key].pk for key in item["capabilities"]
}
# values_list(..., flat=True) 只返回一列整数,不构造 RoleCapability 实例;set 固定查询结果。
existing_ids = set(
RoleCapability.objects.filter(role=role).values_list(
"capability_id",
flat=True,
)
)
# existing - expected 是多余关系;delete() 返回“删除对象总数、按模型统计”二元组,_ 表示忽略第二项。
extra_ids = existing_ids - expected_ids
if extra_ids:
deleted, _ = RoleCapability.objects.filter(
role=role,
capability_id__in=extra_ids,
).delete()
counts["links_deleted"] += deleted
# expected - existing 是缺失关系;sorted() 让插入顺序稳定,便于日志、测试和并发排查。
for capability_id in sorted(expected_ids - existing_ids):
_, created = RoleCapability.objects.get_or_create(
role=role,
capability_id=capability_id,
)
if created:
counts["links_created"] += 1
# 返回阶段:事务提交后输出计数摘要;handle() 随后自然返回 None。
self.stdout.write(
self.style.SUCCESS(
"能力:新建 %s、更新 %s、停用 %s;角色:新建 %s、更新 %s、停用 %s;角色能力:新建 %s、删除 %s。"
% (
counts["capabilities_created"],
counts["capabilities_updated"],
counts["capabilities_retired"],
counts["roles_created"],
counts["roles_updated"],
counts["roles_retired"],
counts["links_created"],
counts["links_deleted"],
)
)
)
7.2 validate_catalog 与 handle 的函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
handle() 对 Role、Capability、RoleCapability 加锁并精确同步。它会在系统角色退休时删除该角色对应的 UserRole,这是“代码目录明确删除一个系统角色”时的撤权行为;但在正常创建/更新目录时,它不会新建任何 UserRole,也不会猜测某个用户应该属于哪个角色。
7.3 执行两次验证幂等性
执行目录:项目根目录。目的:同步当前能力与四个基础角色,并确认第二次运行没有重复创建。预期输出:第一次可能显示迁移已创建的目录无需更新,也可能显示少量同步;第二次所有新建、更新、停用、删除计数都应为 0。常见错误:如果出现“已被非系统对象占用”,不要把 is_system 手工改成真来强行接管,应先调查同键对象来源。
python manage.py bootstrap_rbac
python manage.py bootstrap_rbac
此时可以检查数量。下面命令只读本地 SQLite,不创建用户关系:
python manage.py shell -c "from accounts.models import Capability, Role, UserRole; print('capabilities=%s roles=%s user_roles=%s' % (Capability.objects.count(), Role.objects.count(), UserRole.objects.count()))"
全新项目在尚未创建角色绑定时应看到 capabilities=12 roles=4 user_roles=0。这正是“bootstrap 不分配用户”的可观察证据。
8. 请求级策略:一次请求只加载一次能力快照
8.1 完整 accounts/policy.py
# accounts/policy.py
# PermissionDenied 是 Django 识别的授权异常;视图层未捕获时框架会生成 403 响应。
from django.core.exceptions import PermissionDenied
# Capability 用于读取启用能力目录;UserRole 是普通用户沿“用户角色→角色能力”查询的起点。
from .models import Capability, UserRole
# 模块级常量保存一个不太可能与其他代码冲突的私有属性名;前导下划线表示仅供本模块内部使用。
_REQUEST_SNAPSHOT_ATTRIBUTE = "_accounts_policy_snapshot"
# PolicySnapshot 是普通 Python 类,不继承 Django Model,因此实例只存在于内存,不会自动读写一张数据库表。
class PolicySnapshot:
"""一个用户在一次请求中的只读授权快照。"""
def __init__(self, user):
"""记录身份状态并立即加载当前有效能力键。"""
# __init__ 是构造实例时自动执行的初始化方法;self 指向正在创建的快照,user 是本次授权主体。
# 输入:Django 用户、AnonymousUser 或 None;输出:初始化完成的快照对象;调用者:policy_snapshot() 或直接实例化代码;失败:读取用户属性或数据库能力时异常向上抛出。
# getattr(user, name, default) 安全读取可能不存在的属性;and 从左到右短路,user 为 None 时不会继续访问。
# bool() 把结果规范为严格 True/False,避免把 Django 的惰性属性或其他真值对象直接暴露出去。
self.user = user
self.is_authenticated = bool(
user is not None and getattr(user, "is_authenticated", False)
)
self.is_active = bool(self.is_authenticated and user.is_active)
self.is_superuser = bool(self.is_active and user.is_superuser)
# 数据库读取阶段:根据前面的身份状态加载一次不可变能力集合,供整次请求复用。
self.capability_keys = self._load_capability_keys()
def _load_capability_keys(self):
"""返回不可变能力键集合;无效主体返回空集合。"""
# 输入:已完成身份状态初始化的 self;输出:frozenset 能力编码;调用者:PolicySnapshot.__init__();失败:数据库查询异常向上抛出。
# 业务判断阶段:未激活主体直接拒绝,不执行授权数据库查询。
if not self.is_active:
return frozenset()
if self.is_superuser:
# 数据库读取阶段:filter() 构造只含启用能力的惰性 QuerySet;values_list("key", flat=True) 让每行只返回 key 字符串。
# frozenset() 迭代 QuerySet,因而在这里真正执行 SQL,并把结果去重、冻结;超级用户仍读取目录用于展示。
return frozenset(
Capability.objects.filter(is_active=True).values_list(
"key",
flat=True,
)
)
# 数据库读取阶段:双下划线路径让 ORM 跨 UserRole.role → RoleCapability.capability 做 JOIN,并在各层限制启用状态。
# user_id 直接使用已知主键,避免为了比较外键再取 User;values_list 只选最终能力 key。
# distinct() 在 SQL 层去重同一能力经多个角色产生的重复行;frozenset() 执行查询并得到不可变集合。
# 本查询不读取 AbstractUser.groups 或 user_permissions,项目业务授权来源只有自有 UserRole 链。
return frozenset(
UserRole.objects.filter(
user_id=self.user.pk,
role__is_active=True,
role__role_capabilities__capability__is_active=True,
)
.values_list(
"role__role_capabilities__capability__key",
flat=True,
)
.distinct()
)
def has_capability(self, capability_key):
"""判断一个能力;激活超级用户保留内部运维旁路。"""
# 输入:能力编码 capability_key;输出:是否允许的 bool;调用者:模块级 has_capability() 及组合判断;失败:纯内存判断,正常情况下不抛业务异常。
# 业务判断阶段:先拒绝未激活主体,再允许超级用户,最后查询快照集合。
if not self.is_active:
return False
if self.is_superuser:
return True
return capability_key in self.capability_keys
def has_any_capability(self, capability_keys):
"""至少一个能力满足时返回 True。"""
# 输入:可迭代能力编码 capability_keys;输出:至少一项通过时为 True;调用者:模块级 has_any_capability();失败:不可迭代输入会抛 TypeError。
# 业务判断阶段:tuple() 固定一次性生成器,确保本次判断有稳定输入;生成器表达式逐项调用单能力判断。
# any() 遇到第一项 True 就停止并返回 True;空元组没有成功项,因此自然返回 False。
keys = tuple(capability_keys)
return any(self.has_capability(key) for key in keys)
def has_all_capabilities(self, capability_keys):
"""全部能力满足时返回 True;空集合按拒绝处理。"""
# 输入:可迭代能力编码 capability_keys;输出:全部通过且非空时为 True;调用者:模块级 has_all_capabilities();失败:不可迭代输入会抛 TypeError。
# tuple() 防止一次性迭代器被重复消费;all() 遇到第一项 False 就短路,只有每项都通过才返回 True。
# Python 的 all(空集合) 原本为 True,所以下面的显式分支把“未配置任何要求”改成安全拒绝。
keys = tuple(capability_keys)
if not keys:
# 空配置不能被数学上的 vacuous truth 意外解释为授权。
return False
return all(self.has_capability(key) for key in keys)
def policy_snapshot(subject):
"""把 request、user 或现有快照规范为 PolicySnapshot。"""
# 输入:HttpRequest、用户对象或 PolicySnapshot;输出:可复用的 PolicySnapshot;调用者:所有模块级策略函数和可直接复用快照的业务代码;失败:不支持对象或数据库查询异常向上抛出。
# isinstance() 识别调用者已经传入的快照;直接复用可避免再次查询。
if isinstance(subject, PolicySnapshot):
return subject
# hasattr(subject, "user") 以 request 的 user 属性作轻量识别;getattr(..., None) 读取尚不存在时的默认值。
# 首次调用创建快照并用 setattr 写回同一个 request;后续策略/模板调用复用它,不再查数据库。
# 缓存只覆盖一次请求:同一请求中途若修改角色,旧快照不会自动刷新;新请求会重新加载。
if hasattr(subject, "user"):
snapshot = getattr(subject, _REQUEST_SNAPSHOT_ATTRIBUTE, None)
if snapshot is None:
snapshot = PolicySnapshot(subject.user)
setattr(subject, _REQUEST_SNAPSHOT_ATTRIBUTE, snapshot)
return snapshot
# 返回阶段:非 request 主体新建独立快照,不跨请求、线程或进程共享。
return PolicySnapshot(subject)
def has_capability(subject, capability_key):
"""返回主体是否拥有一个能力。"""
# 输入:request/user/快照 subject 与单个能力编码;输出:bool;调用者:装饰器、模板标签和业务代码;失败:快照加载异常向上抛出。
# 返回阶段:统一取得快照后委托实例方法判断。
return policy_snapshot(subject).has_capability(capability_key)
def has_any_capability(subject, capability_keys):
"""返回主体是否拥有给定能力中的至少一项。"""
# 输入:request/user/快照 subject 与能力编码迭代器;输出:bool;调用者:装饰器和业务代码;失败:输入不可迭代或快照加载异常向上抛出。
# 返回阶段:统一取得快照后委托实例方法执行“任一”判断。
return policy_snapshot(subject).has_any_capability(capability_keys)
def has_all_capabilities(subject, capability_keys):
"""返回主体是否拥有给定全部能力。"""
# 输入:request/user/快照 subject 与能力编码迭代器;输出:bool;调用者:装饰器和业务代码;失败:输入不可迭代或快照加载异常向上抛出。
# 返回阶段:统一取得快照后委托实例方法执行“全部”判断。
return policy_snapshot(subject).has_all_capabilities(capability_keys)
def require_capability(subject, capability_key):
"""缺少单项能力时抛出 PermissionDenied。"""
# 输入:request/user/快照 subject 与单个能力编码;输出:授权通过时自然返回 None;调用者:视图装饰器或业务入口;失败:缺少能力时抛 PermissionDenied。
# 业务判断阶段:复用布尔策略函数,拒绝时统一生成 403 所需异常。
if not has_capability(subject, capability_key):
raise PermissionDenied("缺少能力:%s。" % capability_key)
def require_any_capability(subject, capability_keys):
"""给定集合一项都不满足时抛出 PermissionDenied。"""
# 输入:request/user/快照 subject 与能力编码迭代器;输出:至少一项通过时返回 None;调用者:视图装饰器或业务入口;失败:全部缺少时抛 PermissionDenied。
# 业务判断阶段:先固定迭代器,既供判断使用,也供拒绝消息稳定列出能力。
keys = tuple(capability_keys)
if not has_any_capability(subject, keys):
raise PermissionDenied("至少需要以下一项能力:%s。" % "、".join(keys))
def require_all_capabilities(subject, capability_keys):
"""给定集合未全部满足时抛出 PermissionDenied。"""
# 输入:request/user/快照 subject 与能力编码迭代器;输出:全部通过时返回 None;调用者:视图装饰器或业务入口;失败:任一缺少或集合为空时抛 PermissionDenied。
# 业务判断阶段:先固定迭代器,既供判断使用,也供拒绝消息稳定列出能力。
keys = tuple(capability_keys)
if not has_all_capabilities(subject, keys):
raise PermissionDenied("需要具备以下全部能力:%s。" % "、".join(keys))
8.2 策略类与函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
8.3 为什么缓存放在 request,而不是进程全局
一个模板可能判断五个菜单,如果每次判断都重新查数据库,会产生重复查询。把快照挂在当前 request 上,可以让装饰器和模板复用同一集合。请求结束后 request 被释放,下一个请求重新加载,从而看到最新角色变更。
不能用模块级字典长期缓存“用户 ID → 能力集合”:多进程之间不会同步,撤权可能延迟,不同租户或测试还可能污染彼此。本文明确只有请求级快照,没有远程缓存和跨请求状态。
8.4 启用状态是链上每一层的门
- 未认证:空能力集合;
- 用户停用:空能力集合;
- 角色停用:该角色不贡献能力;
- 能力停用:该能力不进入集合;
- 激活超级用户:作为内部运维旁路,
has_capability()对任意键返回真; - 停用超级用户:没有旁路,仍返回假。
9. 把策略接到视图和模板
9.1 完整 accounts/decorators.py
# accounts/decorators.py
# wraps 把原视图的名称、文档和其他元数据复制给包装函数,便于 Django 与调试工具识别。
from functools import wraps
# login_required 先处理匿名用户并跳转登录页,项目能力检查只需面对已登录主体。
from django.contrib.auth.decorators import login_required
# 三个 require_* 函数在授权失败时统一抛 PermissionDenied,分别表达单项、任一和全部能力。
from .policy import (
require_all_capabilities,
require_any_capability,
require_capability,
)
# 这是“装饰器工厂”:@capability_required(KEY) 会先调用外层函数捕获 KEY,返回的 decorator 再接收被装饰视图。
def capability_required(capability_key):
"""返回要求单项能力的视图装饰器。"""
# 输入:单个能力编码 capability_key;输出:可装饰视图的 decorator;调用者:视图定义处的 @capability_required;失败:这里只捕获配置,实际拒绝发生在请求阶段。
def decorator(view_func):
# decorator 与内部 wrapped 都能继续访问外层 capability_key;这种“函数记住外层局部变量”的结构叫闭包。
# 输入:原始视图函数 view_func;输出:保留元数据且带登录/能力检查的 wrapped;调用者:Python 装饰器机制;失败:无效视图会在包装或调用时抛异常。
# 多个 @ 从下往上应用:wraps 先复制元数据,login_required 再成为最外层包装,所以请求先做登录检查。
@login_required
@wraps(view_func)
def wrapped(request, *args, **kwargs):
# request 是固定首参;*args 把其余位置参数收成 tuple,**kwargs 把 URL 命名参数收成 dict,转交时再分别展开。
# 输入:HttpRequest 及原视图参数;输出:原视图响应;调用者:Django URL 分发器;失败:匿名用户跳转登录,缺少能力抛 PermissionDenied,视图异常继续上抛。
# 业务判断阶段:匿名用户由 login_required 跳转;已登录但缺能力时统一返回 403。
require_capability(request, capability_key)
# 返回阶段:授权通过后原样调用视图并返回其 HttpResponse。
return view_func(request, *args, **kwargs)
# 返回函数对象而不是在这里调用它;Django 将在真正收到请求时调用 wrapped。
return wrapped
# 外层同样返回函数对象,供 @ 语法把原视图替换为 decorator(view_func) 的结果。
return decorator
def any_capability_required(capability_keys):
"""返回要求至少一项能力的视图装饰器。"""
# 输入:能力编码迭代器 capability_keys;输出:可装饰视图的 decorator;调用者:视图定义处的 @any_capability_required;失败:不可迭代输入在定义视图时抛 TypeError。
# 业务判断阶段:立即转为元组,避免一次性迭代器在多个请求间被耗尽。
keys = tuple(capability_keys)
def decorator(view_func):
# 输入:原始视图函数 view_func;输出:带登录和“任一能力”检查的 wrapped;调用者:Python 装饰器机制;失败:无效视图会在包装或调用时抛异常。
@login_required
@wraps(view_func)
def wrapped(request, *args, **kwargs):
# 输入:HttpRequest 及原视图参数;输出:原视图响应;调用者:Django URL 分发器;失败:匿名用户跳转登录,全部能力缺失时抛 PermissionDenied。
# 业务判断阶段:确认当前请求至少拥有元组中的一个能力。
require_any_capability(request, keys)
# 返回阶段:授权通过后原样调用视图并返回其 HttpResponse。
return view_func(request, *args, **kwargs)
# 返回函数对象而不是在这里调用它;Django 将在真正收到请求时调用 wrapped。
return wrapped
# 外层同样返回函数对象,供 @ 语法把原视图替换为 decorator(view_func) 的结果。
return decorator
def all_capabilities_required(capability_keys):
"""返回要求全部能力的视图装饰器。"""
# 输入:能力编码迭代器 capability_keys;输出:可装饰视图的 decorator;调用者:视图定义处的 @all_capabilities_required;失败:不可迭代输入在定义视图时抛 TypeError。
# 业务判断阶段:立即转为元组,避免一次性迭代器在多个请求间被耗尽。
keys = tuple(capability_keys)
def decorator(view_func):
# 输入:原始视图函数 view_func;输出:带登录和“全部能力”检查的 wrapped;调用者:Python 装饰器机制;失败:无效视图会在包装或调用时抛异常。
@login_required
@wraps(view_func)
def wrapped(request, *args, **kwargs):
# 输入:HttpRequest 及原视图参数;输出:原视图响应;调用者:Django URL 分发器;失败:匿名用户跳转登录,任一能力缺失或配置为空时抛 PermissionDenied。
# 业务判断阶段:确认当前请求拥有元组中的全部能力。
require_all_capabilities(request, keys)
# 返回阶段:授权通过后原样调用视图并返回其 HttpResponse。
return view_func(request, *args, **kwargs)
# 返回函数对象而不是在这里调用它;Django 将在真正收到请求时调用 wrapped。
return wrapped
# 外层同样返回函数对象,供 @ 语法把原视图替换为 decorator(view_func) 的结果。
return decorator
9.2 三个装饰器工厂的契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
@wraps 保留原视图名称和元数据,方便调试、测试和 URL 工具识别。先应用能力包装,再由外层 login_required 处理匿名用户,保证匿名结果是 302 而不是 403。
9.3 完整 accounts/templatetags/policy_tags.py
# accounts/templatetags/policy_tags.py
# template 提供 Library 和 simple_tag,用于把普通 Python 函数注册成 Django 模板标签。
from django import template
# has_capability 复用统一策略判断,避免模板标签自行复制授权查询。
from accounts.policy import has_capability
# 实例化 Library 得到本模块的标签注册器;Django 因 accounts 在 INSTALLED_APPS 中且本文件位于 templatetags 包而发现它。
register = template.Library()
# @register.simple_tag(...) 在导入时把下方函数注册为 policy_can;takes_context=True 让 Django 自动把当前 Context 作为第一个参数。
# 模板可用 {% policy_can "key" as allowed %}:函数只返回 bool,simple_tag 自己处理 as allowed 的赋值与不直接输出。
@register.simple_tag(takes_context=True)
def policy_can(context, capability_key):
"""返回当前模板主体是否拥有指定能力。"""
# 输入:Django 自动传入的模板上下文与模板给出的能力编码;输出:bool;调用者:policy_can 标签;失败:策略查询异常向上抛出。
# 数据库读取阶段:先从上下文取 request;存在时可复用当前请求缓存的 PolicySnapshot。
request = context.get("request")
if request is not None:
# 业务判断阶段:优先传 request 复用快照;没有 request 时才回退到上下文中的 user。
return has_capability(request, capability_key)
# simple_tag 会自动处理模板中的“as variable”:把本函数返回值保存到变量而不直接输出。
return has_capability(context.get("user"), capability_key)
9.4 policy_can 的函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
模板隐藏只能改善界面,不能替代服务端授权。用户可以自己构造 URL 和 HTTP 请求;真正的端点必须使用能力装饰器,写端点还要在事务和锁之后重新检查。本文首页本身由服务端装饰器保护,管理按钮则只是第 2 篇路由尚未接入前的可见性演示。
10. 第 1 篇专用的登录、视图和路由
10.1 完整 accounts/forms.py
第 1 篇只需要本地登录表单。不要复制最终版本中外部身份绑定表单的导入。
# accounts/forms.py
# forms 提供表单字段和 Widget;它们负责接收、校验与展示输入,不会像模型字段那样创建数据库列。
from django import forms
# AuthenticationForm 已实现用户名/密码认证、停用账号拒绝和通用错误处理,本类只改界面字段配置。
from django.contrib.auth.forms import AuthenticationForm
# 继承 AuthenticationForm 可复用 clean() 中的 authenticate() 流程、confirm_login_allowed() 的激活检查和 get_user()。
# LoginView 会以当前 request 和 POST data 实例化本类;调用 is_valid() 时先逐字段清洗,再运行 AuthenticationForm.clean()。
# 认证成功的用户暂存在表单实例中,LoginView 随后通过 get_user() 取出并写入 Session;认证失败则产生表单错误,不保存明文密码。
# 即使这里重写 username 字段,父类 __init__ 仍会从当前 User.USERNAME_FIELD 补上模型 max_length 和 HTML maxlength。
class LoginForm(AuthenticationForm):
"""本地用户名/密码登录表单。"""
# 类属性声明字段:Django 的表单元类在创建 LoginForm 类时收集它们,实例化表单时再复制成实例字段。
# CharField 接收文本并默认 required=True;label 是页面标签,widget 只控制 HTML 输入框,不改变认证规则。
username = forms.CharField(
label="用户名",
widget=forms.TextInput(
# attrs 字典原样变成 HTML 属性;autofocus 建议浏览器聚焦,autocomplete 告知密码管理器这是用户名。
attrs={
"autofocus": True,
"autocomplete": "username",
}
),
)
# 密码仍用 CharField 接收原始字符串;required 默认为 True,label 只负责显示。
# strip=False 很关键:不删除用户真实密码的首尾空白;widget 指定 PasswordInput 密码控件,只遮蔽浏览器显示,不加密也不保存提交值。
password = forms.CharField(
label="密码",
strip=False,
widget=forms.PasswordInput(
# attrs 是写入 HTML 控件的属性字典;current-password 帮助浏览器选择已有密码,而不是建议生成新密码。
attrs={"autocomplete": "current-password"}
),
)
10.2 LoginForm 的输入、输出、调用者与失败
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
表单不自己查用户、不比较明文密码,也不调用 login()。这些职责由 AuthenticationForm、认证后端和 LoginView 分工完成,避免手写流程遗漏停用账号检查或 Session 轮换。
10.3 完整 accounts/views.py
# accounts/views.py
# logout 清除当前请求对应 Session 中的认证信息。
from django.contrib.auth import logout
# login_required 把匿名访问重定向到 LOGIN_URL。
from django.contrib.auth.decorators import login_required
# render 用模板生成响应;redirect 用 URL 或命名路由生成重定向响应。
from django.shortcuts import redirect, render
# require_POST 拒绝 GET 等其他方法,使退出只能由 POST 请求触发。
from django.views.decorators.http import require_POST
# DASHBOARD_VIEW 是稳定能力编码,避免在视图中重复手写授权字符串。
from .capabilities import DASHBOARD_VIEW
# capability_required 组合登录检查与项目能力检查。
from .decorators import capability_required
# @ 表示把下方函数替换为装饰器返回的包装函数;工厂先接收能力 key,再捕获 home 作为被保护视图。
@capability_required(DASHBOARD_VIEW)
def home(request):
"""渲染第一个受项目能力保护的管理首页。"""
# 输入:已通过登录和 DASHBOARD_VIEW 校验的 HttpRequest;输出:模板渲染得到的 HttpResponse;调用者:accounts 根路由;失败:未登录会跳转、缺能力返回 403、模板错误向上抛出。
# 返回阶段:render(request, template_name) 加载模板,并用 RequestContext 注入 settings 中配置的上下文处理器。
# 本页没有额外 context 字典;render 最终返回状态码默认 200 的 HttpResponse。
return render(request, "accounts/home.html")
# 多个装饰器从下往上应用:先由 require_POST 包住原视图,再由 login_required 包在最外层。
# 因此在视图装饰器链内部先检查登录;已登录请求再检查必须是 POST。CSRF 由全局 CsrfViewMiddleware 在调用视图前校验,不是 require_POST 的职责。
@login_required
@require_POST
def logout_view(request):
"""只接受带 CSRF 的 POST,结束当前本地 Session。"""
# 输入:已登录且通过 require_POST/CSRF 检查的 HttpRequest;输出:重定向到登录页的 HttpResponse;调用者:accounts:logout 路由;失败:未登录会跳转,非 POST 返回 405,CSRF 无效返回 403。
# 保存阶段:logout() 调用 Session.flush(),清除当前会话数据并轮换会话键,使旧会话标识不能继续代表该用户。
# 退出会修改认证状态,不能用可被预取或外链触发的 GET 请求。
logout(request)
# 返回阶段:redirect() 识别命名路由 accounts:login,先反向解析成 URL,再返回默认 302 重定向响应。
return redirect("accounts:login")
10.4 两个视图的函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
装饰器次序有明确语义:login_required 在外层,匿名请求先走登录跳转;已登录请求再检查 HTTP 方法。CSRF 不需要在函数上手写装饰器,因为全局 CsrfViewMiddleware 已启用,且模板表单输出 {% csrf_token %}。
10.5 完整 accounts/urls.py
# accounts/urls.py
# auth_views 提供现成的登录类视图;as 别名让后续引用更简短且不与本应用 views 混淆。
from django.contrib.auth import views as auth_views
# path 把 URL 路径、视图和可反向解析的名称组合成路由对象。
from django.urls import path
# “from . import”中的点表示当前 accounts 包;views 模块提供本应用函数视图。
from . import views
# LoginForm 自定义登录字段展示,同时复用 Django AuthenticationForm 的认证逻辑。
from .forms import LoginForm
# app_name 为所有路由加上 accounts 命名空间,模板和 redirect 可用 accounts:login 等稳定名称反向解析。
app_name = "accounts"
# Django 按列表顺序匹配路径;path(route, view, name=...) 创建 URLPattern,route 不写开头斜杠。
# name 是代码引用的路由名,不是浏览器地址;命中后分发器调用 view(request, *args, **kwargs)。
urlpatterns = [
# 根路径调用受 DASHBOARD_VIEW 装饰器保护的管理首页视图。
path("", views.home, name="home"),
# as_view() 是类视图入口:它接收配置关键字并返回一个可供 path 调用的函数;每次请求会创建新的 LoginView 实例。
# template_name 指定 GET/错误 POST 使用的模板;authentication_form 指定表单类;redirect_authenticated_user 避免已登录用户重复看登录页。
# 登录成功后的目标优先取安全的 next 参数,否则使用 LOGIN_REDIRECT_URL;失败则用绑定错误的同一表单重新渲染。
path(
"login/",
auth_views.LoginView.as_view(
template_name="accounts/login.html",
authentication_form=LoginForm,
redirect_authenticated_user=True,
),
name="login",
),
# /logout/ 由自定义视图处理,只接受 POST 并在完成后重定向到登录页。
path("logout/", views.logout_view, name="logout"),
]
10.6 LoginView 的阶段配置
| 配置 | 输入/输出/失败语义 |
|---|---|
template_name | 输入是模板路径;GET 输出本文的本地登录页。模板缺失时抛出 TemplateDoesNotExist。 |
authentication_form | 输入是表单类;POST 时实例化并验证,成功后建立 Session,失败后返回 200 与字段错误。 |
redirect_authenticated_user=True | 已登录用户访问登录页会跳到成功 URL。若该用户没有首页能力,随后首页返回 403,这不是循环,而是正确区分认证与授权。 |
| 成功 URL | 未提供安全的 next 时使用设置中的 LOGIN_REDIRECT_URL = "accounts:home"。 |
路由表只有首页、登录和退出。没有外部身份开始、回调、绑定、解绑或 Fake 授权路由,也没有任何后续管理页面的名字,因此本阶段模板不得反向解析那些名字。
10.7 完整 devopsX/urls.py
# devopsX/urls.py
# admin 暴露 Django 自带管理站点的 URL 集合 admin.site.urls。
from django.contrib import admin
# path 声明一条路由;include 把某个 URL 前缀后的匹配工作交给另一个路由模块。
from django.urls import include, path
# urlpatterns 是 Django 启动时读取的顶层路由表;请求会按从上到下的顺序尝试匹配。
# path(route, view) 的 route 不含开头斜杠;命中前缀后,Django 把剩余路径交给视图或 include 返回的 URLResolver。
urlpatterns = [
# path 的第一个参数是 URL 前缀,第二个参数把 /admin/ 交给 Django 自带后台处理。
path("admin/", admin.site.urls),
# 空前缀表示其余根路径继续交给 accounts.urls;include 让应用维护自己的子路由。
path("", include("accounts.urls")),
]
include() 的输入是应用 URL 模块,输出表现为把空前缀下的请求交给 accounts.urls。调用者是 Django URL resolver;导入错误或重复命名空间会在 check 或请求解析时失败。本文不通过管理站点执行业务授权;createsuperuser 只是创建内部运维账号,业务首页仍走项目策略。
11. 第 1 篇专用模板:没有后续 reverse()
11.1 完整 templates/base.html
{# templates/base.html #}
{# load 会加载 static 标签库,后面的 static 标签才能把静态文件相对名解析成可访问 URL。 #}
{% load static %}
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# block 声明可被子模板覆盖的区块;这里同时给未覆盖 title 的页面提供默认标题。 #}
<title>{% block title %}devopsX 用户与访问控制{% endblock %}</title>
<link rel="stylesheet" href="{% static 'accounts/style.css' %}">
</head>
<body>
<header class="site-header">
<div class="container header-row">
{# url 按路由名反向生成地址,避免把 /accounts/ 之类路径硬编码进模板。 #}
<a class="brand" href="{% url 'accounts:home' %}">devopsX</a>
{# if 是模板条件判断;只有已认证用户才显示导航和退出入口。 #}
{% if user.is_authenticated %}
<nav aria-label="主导航">
<a href="{% url 'accounts:home' %}">首页</a>
</nav>
<div class="account-actions">
<span>当前用户:{{ user }}</span>
<form method="post"
action="{% url 'accounts:logout' %}"
class="inline-form">
{# csrf_token 输出与当前 Session 绑定的防伪字段;所有会修改服务器状态的站内 POST 表单都必须带它。 #}
{% csrf_token %}
<button type="submit" class="link-button">退出</button>
</form>
</div>
{% endif %}
</div>
</header>
<main class="container">
{% if messages %}
<div class="messages" aria-live="polite">
{# for 会逐项遍历 messages;每轮把当前消息暂存到变量 message。 #}
{% for message in messages %}
{# default 过滤器在 message.tags 为空时改用 info,保证 CSS 类名始终可用。 #}
<div class="message {{ message.tags|default:'info' }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>
这个基础模板只反向解析本文已经注册的 home 与 logout。它没有“登录方式”、用户列表、角色列表、能力目录等链接。第 2、3 篇会按各自路由边界替换或扩展导航。
11.2 完整 templates/accounts/login.html
{# templates/accounts/login.html #}
{# extends 让本页继承 base.html 的整体骨架;本文件只覆盖父模板预留的 block。 #}
{% extends "base.html" %}
{% block title %}本地登录{% endblock %}
{% block content %}
<section class="panel narrow login-panel">
<h1>登录 devopsX</h1>
<p>第 1 篇只使用本地用户名和密码,不连接任何外部身份平台。</p>
{% if form.non_field_errors %}
<div class="form-errors" role="alert">
{# form.non_field_errors 渲染不属于某个单独字段的表单错误,例如用户名与密码组合校验失败。 #}
{{ form.non_field_errors }}
</div>
{% endif %}
<form method="post" action="{% url 'accounts:login' %}">
{% csrf_token %}
{% for field in form %}
<div class="field">
{{ field.label_tag }}
{{ field }}
{{ field.errors }}
</div>
{% endfor %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
<button class="button" type="submit">登录</button>
</form>
</section>
{% endblock %}
表单的调用者是浏览器。输入是用户名、密码、CSRF token 和可选 next。成功输出 302,失败输出带统一错误的 200。Django 会验证 next 是否允许跳转,不能自己直接相信任意外部 URL。本文模板没有 Fake Provider 按钮,也没有外部身份路由。
11.3 完整 templates/accounts/home.html
{# templates/accounts/home.html #}
{% extends "base.html" %}
{% load policy_tags %}
{% block title %}管理首页{% endblock %}
{% block content %}
{% policy_can "accounts.user.view" as can_view_users %}
{% policy_can "accounts.department.view" as can_view_departments %}
{% policy_can "accounts.role.view" as can_view_roles %}
{% policy_can "accounts.capability.view" as can_view_capabilities %}
{% policy_can "accounts.user.create" as can_create_users %}
{% policy_can "accounts.department.manage" as can_manage_departments %}
{% policy_can "accounts.role.manage" as can_manage_roles %}
<div class="page-header">
<div>
<h1>用户与访问控制</h1>
<p>
登录与会话由 Django 负责;业务授权只沿
User → UserRole → Role → RoleCapability → Capability 判断。
</p>
</div>
</div>
<section class="panel">
<h2>当前授权快照</h2>
<p>账号:<strong>{{ user }}</strong></p>
<p>下面只展示当前请求中允许看到的只读模块。实际列表页面在第 2 篇接入。</p>
<div class="card-grid">
{% if can_view_users %}
<article class="card" data-module="users">
<strong>用户</strong>
<span>允许查看用户资料。</span>
</article>
{% endif %}
{% if can_view_departments %}
<article class="card" data-module="departments">
<strong>部门</strong>
<span>允许查看部门层级。</span>
</article>
{% endif %}
{% if can_view_roles %}
<article class="card" data-module="roles">
<strong>角色</strong>
<span>允许查看项目角色。</span>
</article>
{% endif %}
{% if can_view_capabilities %}
<article class="card" data-module="capabilities">
<strong>能力目录</strong>
<span>允许查看稳定业务能力编码。</span>
</article>
{% endif %}
</div>
</section>
{% if can_create_users or can_manage_departments or can_manage_roles %}
<section class="panel top-space" data-section="management-actions">
<h2>管理动作预览</h2>
<p>这些禁用按钮只验证服务端可见性;第 2 篇才接入真实写路由。</p>
<div class="button-row">
{% if can_create_users %}
<button type="button" class="button" data-action="user-create" disabled>
创建用户(第 2 篇启用)
</button>
{% endif %}
{% if can_manage_departments %}
<button type="button" class="button" data-action="department-manage" disabled>
维护部门(第 2 篇启用)
</button>
{% endif %}
{% if can_manage_roles %}
<button type="button" class="button" data-action="role-manage" disabled>
配置角色(第 2 篇启用)
</button>
{% endif %}
</div>
</section>
{% endif %}
{% endblock %}
readonly_viewer 拥有四个只读模块能力和首页能力,但不拥有三个管理能力。因此它会看到只读卡片,整个 data-section="management-actions" 不会出现在响应 HTML 中。注意这只是“隐藏动作”的验收样本,不是可点击的伪功能;禁用按钮不会发请求,也没有不存在的 URL 名称。
11.4 完整 templates/403.html
{# templates/403.html #}
{% extends "base.html" %}
{% block title %}无权访问{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>403 无权访问</h1>
<p>
当前账号已经登录,但没有访问此页面所需的业务能力。
请由管理员通过项目自有 UserRole 绑定合适角色。
</p>
<p>如果刚刚完成授权,请刷新页面以发起一个新请求。</p>
</section>
{% endblock %}
403 模板故意不提供“返回首页”链接,因为当前被拒绝的页面就是首页;添加该链接只会让无权用户反复点击同一个 403。退出按钮仍来自基础模板,可以正常结束 Session。
11.5 完整 accounts/static/accounts/style.css
:root {
color-scheme: light;
--primary: #2457a6;
--primary-dark: #183f7d;
--danger: #b42318;
--border: #d8dee8;
--surface: #ffffff;
--background: #f4f6f9;
--text: #172033;
--muted: #617087;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--text);
background: var(--background);
line-height: 1.5;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.container {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
}
.site-header {
margin-bottom: 28px;
color: #ffffff;
background: #13233c;
}
.header-row {
min-height: 64px;
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.brand {
color: #ffffff;
font-size: 1.25rem;
font-weight: 750;
}
.site-header nav {
display: flex;
gap: 18px;
flex: 1;
}
.site-header nav a,
.account-actions a {
color: #e8eef8;
}
.account-actions {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.9rem;
}
.inline-form {
display: inline;
margin: 0;
}
.link-button {
border: 0;
padding: 0;
color: #e8eef8;
background: none;
font: inherit;
cursor: pointer;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
h1 {
margin: 0 0 8px;
font-size: 1.8rem;
}
p {
color: var(--muted);
}
.panel,
.card {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
box-shadow: 0 2px 10px rgba(23, 32, 51, 0.04);
}
.panel {
padding: 24px;
}
.narrow {
max-width: 620px;
margin: 40px auto;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px;
color: var(--text);
}
.card span {
color: var(--muted);
}
.button-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.button {
display: inline-block;
border: 1px solid var(--primary);
border-radius: 7px;
padding: 8px 14px;
color: #ffffff;
background: var(--primary);
font: inherit;
cursor: pointer;
}
.button:hover {
background: var(--primary-dark);
text-decoration: none;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.68;
}
input {
width: 100%;
min-height: 38px;
border: 1px solid #b9c3d1;
border-radius: 6px;
padding: 7px 10px;
background: #ffffff;
font: inherit;
}
.field {
display: grid;
gap: 5px;
margin-bottom: 14px;
}
.errorlist,
.form-errors {
color: var(--danger);
}
.top-space {
margin-top: 18px;
}
.messages {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.message {
border: 1px solid #b8d0f4;
border-radius: 7px;
padding: 11px 14px;
background: #eaf2ff;
}
code {
color: #6941c6;
}
@media (max-width: 760px) {
.header-row,
.account-actions {
align-items: flex-start;
flex-direction: column;
padding: 14px 0;
}
.site-header nav {
flex-wrap: wrap;
}
.page-header {
align-items: flex-start;
flex-direction: column;
}
}
12. 启动前做一次完整静态检查
执行目录:项目根目录。目的:验证设置、模型、URL、模板依赖和迁移状态。预期输出:系统检查无问题、没有待生成迁移、0001~0003 全部已应用。常见错误:NoReverseMatch 说明你混入了后续模板;导入外部身份模块失败说明你混入了后续 forms/views;密钥错误说明 .env 缺失或变量为空;布尔错误说明环境值不是受支持拼写。
python manage.py check
python manage.py makemigrations --check --dry-run
python manage.py showmigrations accounts
还可以直接让模板引擎加载三个页面模板,提前发现语法错误:
python manage.py shell -c "from django.template.loader import get_template; names = ('base.html', 'accounts/login.html', 'accounts/home.html', '403.html'); [get_template(name) for name in names]; print('templates ok')"
执行目录:项目根目录。目的:只加载模板,不启动服务器。预期输出:templates ok。常见错误:TemplateSyntaxError 通常是标签拼写或未加载 policy_tags;TemplateDoesNotExist 通常是目录与 TEMPLATES["DIRS"] 不一致。
13. 创建超级用户与无角色普通用户
13.1 创建内部运维超级用户
执行目录:项目根目录。目的:创建本机内部运维账号,稍后把它记录为角色分配人。预期输出:交互输入用户名、可选邮箱和密码后显示创建成功。密码输入不会回显。常见错误:密码过弱时验证器会警告或拒绝;不要为了教程在命令行参数里写明文密码。
python manage.py createsuperuser
超级用户是受控运维旁路,不等于 platform_admin 角色。前者由 is_superuser 表示,且只有激活状态才旁路;后者是项目自有角色,可以审计为 UserRole。本文验收普通用户时不会给他超级用户标记。
13.2 交互创建一个普通用户,但先不分配角色
执行目录:项目根目录。目的:通过 create_user() 安全哈希密码,创建用于 403→200 验收的普通账号。预期输出:最后打印用户名、启用状态和 roles=0。常见错误:用户名重复会触发唯一约束;密码验证规则由调用路径决定,但仍应输入本机专用强密码;不要把密码写进文章或 shell 历史。
python manage.py shell
>>> from getpass import getpass
>>> from django.contrib.auth import get_user_model
>>> User = get_user_model()
>>> password = getpass("请输入本机测试密码:")
>>> demo = User.objects.create_user(
... username="readonly-demo",
... password=password,
... display_name="只读验收用户",
... is_active=True,
... )
>>> print("username=%s active=%s roles=%s" % (demo.username, demo.is_active, demo.user_roles.count()))
username=readonly-demo active=True roles=0
>>> exit()
这里的 ... 是 Python 交互解释器自动显示的续行提示,不是省去代码。完整调用参数已经列出。create_user() 的输入是用户名、原始密码和业务字段;输出是已保存用户;调用者是这次人工初始化;失败包括唯一冲突和数据库错误。它内部调用安全密码编码流程,数据库中的 password 不等于你输入的原文。
14. 启动本地服务器
执行目录:项目根目录。目的:启动仅供本机学习的开发服务器。预期输出:终端显示 Starting development server at http://127.0.0.1:8000/,浏览器可以访问。常见错误:端口占用时可使用 python manage.py runserver 8001;若启动时报密钥或布尔配置错误,修复 .env,不要把检查删掉;开发服务器不能作为生产部署。
python manage.py runserver
保持这个终端运行。角色绑定步骤在第二个已激活同一虚拟环境的终端执行。所有页面、Session 和数据库都在本机;本文没有远程身份平台或远程数据库。
15. 浏览器验收一:匿名用户必须跳登录
- 打开浏览器开发者工具的 Network 面板,访问
http://127.0.0.1:8000/。 - 第一跳应为
302,Location 指向/login/?next=/。 - 浏览器跟随跳转后,登录页返回
200。 - 页面只显示本地用户名和密码表单,不应出现任何外部身份登录按钮。
原因链是:URL resolver 调用 home 的包装函数;外层 login_required 看到匿名 request.user;它依据 LOGIN_URL 生成带 next 的重定向。此时策略查询不会把匿名用户当作有能力的主体。
16. 浏览器验收二:登录成功,但没有角色时返回 403
- 在登录页输入
readonly-demo和刚才只保存在你本机的测试密码。 - 提交后,
LoginView应返回 302 到首页。 - 首页请求中的
request.user.is_authenticated为真,is_active为真。 - 由于
UserRole仍为 0,策略查询得到空能力集合。 require_capability(request, "accounts.dashboard.view")抛出PermissionDenied,最终响应状态应为403,页面显示“当前账号已经登录,但没有访问此页面所需的业务能力”。
这是关键验收:认证成功没有自动变成授权成功。若此时得到 200,说明视图没有能力装饰器或策略错误地读取了其他授权来源;若又跳回登录页,检查 Session cookie、中间件顺序和账号启用状态。
17. 通过 UserRole 绑定 readonly_viewer
17.1 在第二个终端执行显式角色绑定
执行目录:项目根目录;虚拟环境必须已激活。目的:把普通用户与现有 readonly_viewer 角色通过 UserRole 关联,并记录超级用户为分配人。预期输出:第一次执行显示 created=True;重复执行显示 created=False,不会产生重复行。常见错误:找不到角色说明尚未运行 bootstrap;找不到超级用户说明输入的用户名不对;唯一约束错误通常表示绕开了 get_or_create() 并发重复写入。
python manage.py shell
>>> from django.contrib.auth import get_user_model
>>> from accounts.models import Role, UserRole
>>> User = get_user_model()
>>> operator_name = input("请输入刚创建的超级用户名:").strip()
>>> operator = User.objects.get(
... username=operator_name,
... is_superuser=True,
... is_active=True,
... )
>>> demo = User.objects.get(username="readonly-demo", is_active=True)
>>> role = Role.objects.get(key="readonly_viewer", is_active=True)
>>> membership, created = UserRole.objects.get_or_create(
... user=demo,
... role=role,
... defaults={"assigned_by": operator},
... )
>>> print("membership=%s created=%s" % (membership, created))
membership=只读验收用户:只读查看员 created=True
>>> exit()
同样,这里的 ... 是交互解释器续行提示。get_or_create() 输入用户、角色和首次创建时的分配人;输出二元组 (membership, created);调用者是本地初始化操作;失败包括目标不存在、数据库不可用或约束冲突。它只写一条 UserRole,不会修改密码、Session 或角色能力集合。
17.2 为什么刷新就能生效
第一次 403 请求中的能力快照只挂在那一个 request 对象上。角色绑定后刷新浏览器会产生新请求,AuthenticationMiddleware 从 Session 恢复用户,policy_snapshot(request) 重新查询链路,于是读取到:
- 用户处于启用状态;
readonly_viewer处于启用状态;- 角色的
accounts.dashboard.view及四个只读能力处于启用状态。
不需要重启服务器,也不应该操作全局缓存。若你在同一个请求函数中先构造快照再修改授权,当前快照不会自动变化;真实写操作应在提交后重定向,让浏览器发起新请求。
18. 浏览器验收三:绑定后 200,管理动作仍隐藏
- 在仍保持
readonly-demoSession 的浏览器刷新首页。 - 状态应从 403 变为
200。 - 页面应显示用户、部门、角色、能力目录四张只读卡片。
- 页面不应显示“管理动作预览”区块。
- 在 Elements 面板或“查看页面源代码”中搜索
data-section="management-actions"、data-action="user-create"、data-action="department-manage"、data-action="role-manage",均应不存在。
这证明模板标签与视图装饰器复用了项目自有能力链。但请继续记住:隐藏 HTML 不是安全边界。第 2 篇接入真实写端点时,每一个端点都必须在服务端检查对应能力;不能因为按钮看不见就省掉后端校验。
19. 浏览器验收四:退出必须是 POST 且受 CSRF 保护
- 点击页头“退出”。它是一个包含 CSRF token 的 POST 表单,不是普通链接。
- Network 面板应看到
POST /logout/返回 302 到/login/。 - 再次访问首页,应重新得到匿名跳转。
- 直接在地址栏访问
http://127.0.0.1:8000/logout/发出 GET。若已登录,应得到 405;若已退出,外层登录检查会先跳登录。这两种结果都不会通过 GET 改变 Session。
为什么拒绝 GET 退出:浏览器预取、爬虫、聊天软件链接预览、第三方图片或恶意页面都可能触发 GET。让状态变化只能由带 CSRF token 的 POST 发起,可以阻止外站替用户静默退出。CSRF token 不是权限令牌,它只证明表单请求来自当前站点会话上下文;业务能力仍由策略层判断。
20. 用 shell 复核授权链,而不是猜测页面结果
执行目录:项目根目录。目的:只读打印普通用户角色和有效能力键。预期输出:角色列表只有 readonly_viewer,能力列表包含首页与四个只读键,不包含 create/manage 键。常见错误:结果为空时核对用户、角色和能力的 is_active;出现多余能力时检查角色能力关联是否被手工修改,并重新运行 bootstrap 精确同步系统角色。
python manage.py shell -c "from django.contrib.auth import get_user_model; from accounts.policy import PolicySnapshot; user = get_user_model().objects.get(username='readonly-demo'); snapshot = PolicySnapshot(user); print('roles=%s' % list(user.user_roles.values_list('role__key', flat=True))); print('capabilities=%s' % sorted(snapshot.capability_keys))"
预期能力集合为:
accounts.capability.view
accounts.dashboard.view
accounts.department.view
accounts.role.view
accounts.user.view
这里直接构造 PolicySnapshot(user) 是 shell 诊断入口;网页代码应优先把 request 传给策略,以获得请求级复用。
21. 常见错误与定位顺序
21.1 ImproperlyConfigured:缺少 DEVOPSX_SECRET_KEY
现象:几乎所有 manage.py 命令在加载设置时立即失败。原因:.env 不在项目根目录、变量名拼错、值为空或启动命令不在预期项目。处理:确认 BASE_DIR / ".env" 存在,重新运行本机随机密钥生成命令。不要给设置添加公开默认密钥。
21.2 环境变量必须是布尔值
现象:设置导入抛出严格布尔错误。原因:使用了 debug、enabled、True # comment 等不支持值。处理:只使用 1/true/yes/on 或 0/false/no/off,大小写可混合,首尾空格会被去除。
21.3 生产模式禁止启用本地 Fake Provider
现象:DEVOPSX_DEBUG=false 且 Fake 开关为真时拒绝启动。原因:本地学习工具不允许在生产模式误开。处理:第 1 篇始终设置 DEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER=false。不要删除保护条件,更不要把它解释为真实平台开关。
21.4 NoReverseMatch 指向 external_*、user_list 或 role_list
现象:登录页、首页或 403 页面渲染失败。原因:复制了第 2/3 篇或最终版本的 base.html、login.html、home.html,但路由仍是本文阶段。处理:恢复本文给出的六个阶段文件:forms.py、views.py、urls.py、base.html、login.html、home.html。运行 python manage.py check 与模板加载命令。
21.5 ImportError 指向 identity 或 providers
现象:URL 模块导入 views 时失败。原因:复制了最终完整视图,它在第 1 篇尚不存在的外部身份服务层上有依赖。处理:使用本文的两函数 views.py。第 4 篇会在创建模型、迁移、服务和路由之后再扩展。
21.6 登录后一直 403
按下面顺序检查,不要先把用户改成超级用户:
- 用户是否
is_active=True; - 是否真的存在该用户到
readonly_viewer的UserRole; - 角色是否启用;
accounts.dashboard.view是否启用;- 角色与首页能力之间是否存在
RoleCapability; - 是否刷新产生了新请求;
- 视图导入的能力常量是否为
DASHBOARD_VIEW。
给普通用户设置 is_superuser=True 会掩盖链路错误,不能作为修复。
21.7 登录后 302 循环
确认登录 URL 本身没有能力装饰器,LOGIN_URL 指向 accounts:login,Session 与 Authentication 中间件顺序与本文一致。停用账号通常会让表单验证失败并停留在登录页,而不是成功建立 Session。
21.8 退出 GET 返回 302 而不是 405
如果已经退出,login_required 会先把匿名请求跳到登录页,所以得到 302。要验证方法限制,请先登录,然后直接对 /logout/ 发 GET;此时内层 require_POST 返回 405。两种情况都满足“GET 不改变 Session”。
21.9 makemigrations --check 发现变化
本文采用发布历史迁移文件,而不是让最终模型一次生成一个全新 0001。逐项比较模型字段、Meta、中间表、through_fields 和约束。不要运行 makemigrations 接受一个意外 0004 来掩盖抄写错误。
21.10 bootstrap 第二次仍显示更新
通常是有人在两次命令之间修改了系统目录行,或者数据库排序/规范化导致键不一致。使用 shell 查看对应行,不要删掉幂等检查。系统角色由代码目录拥有,网页管理在第 2 篇也会禁止编辑系统角色。
22. 安全边界复盘
22.1 密码与密钥是两类不同秘密
- 用户密码通过 Django 哈希器保存,使用
create_user()或表单写入; - Django
SECRET_KEY由环境提供,用于签名,不是用户密码; - 二者都不应出现在文章、提交或命令历史中;
- 示例使用交互式
getpass(),文章没有任何真实凭据。
22.2 CSRF 与授权不能互相替代
CSRF 防止第三方站点借用用户浏览器发起状态变化;授权判断当前用户是否允许做这个动作。一个请求可以拥有有效 CSRF token 但没有业务能力,也可以拥有业务能力但缺失 CSRF token。写端点必须同时满足二者。
22.3 页面隐藏与端点拒绝不能互相替代
模板标签让无权用户不看到管理入口,是最小暴露和更好体验;装饰器与事务内重检才是安全边界。本文只有一个真实业务页面,已经由 @capability_required(DASHBOARD_VIEW) 保护。第 2 篇引入写端点时会继续执行服务端检查,而不是只复制这些模板条件。
22.4 超级用户旁路必须受 is_active 约束
策略只在 is_authenticated、is_active、is_superuser 同时成立时旁路。停用超级用户不会保留项目能力。超级用户用于内部恢复和运维,不应成为普通业务角色分配的捷径。
22.5 角色和能力的“停用”语义
停用角色会切断其全部能力;停用能力会从所有角色的有效集合中消失。这样可以在不立刻物理删除历史行的情况下撤权。bootstrap 对代码目录中彻底退休的系统角色更严格:它会清空角色能力和用户成员关系,避免未来误重新启用恢复旧授权。
22.6 本文没有真实外部身份结论
设置中出现的 Fake 开关不是平台接入。本文没有 provider registry、state、nonce、PKCE、回调、外部 subject、绑定、解绑或审计事件;因此不能声称验证了企业微信、飞书、微信开放平台或任何其他真实提供方。所有验收只覆盖本地 SQLite、本地密码、浏览器 Session 和项目自有 RBAC。
23. 命令执行清单
| 命令 | 执行目录 | 目的 | 成功标志 | 常见失败 |
|---|---|---|---|---|
python -m pip install -r requirements.txt | 项目根目录 | 安装依赖 | Django 5.2.17 可查询 | 虚拟环境未激活、网络或证书配置 |
python manage.py check | 项目根目录 | 检查设置、URL、模型 | 无系统检查问题 | 环境变量缺失、导入后续模块、路由错误 |
python manage.py migrate | 项目根目录 | 应用数据库历史 | accounts 0001~0003 为 OK | 自定义用户声明太晚、迁移文本不一致 |
python manage.py makemigrations --check --dry-run | 项目根目录 | 检查模型漂移 | No changes detected | 字段、Meta、约束抄写错误 |
python manage.py bootstrap_rbac | 项目根目录 | 精确同步目录 | 统计行;第二次全零 | 非系统同键冲突、数据库错误 |
python manage.py createsuperuser | 项目根目录 | 创建内部运维账号 | 创建成功提示 | 用户名重复、密码验证失败 |
python manage.py runserver | 项目根目录 | 启动本地开发服务 | 127.0.0.1 地址可访问 | 端口占用、设置导入失败 |
24. 第 1 篇精确验收清单
- □ Python 虚拟环境已激活,
python -m django --version输出5.2.17。 - □ 项目根目录存在本机
.env,但文章、提交和终端输出中没有泄露真实DEVOPSX_SECRET_KEY。 - □
DEVOPSX_DEBUG=true使用严格布尔拼写。 - □
DEVOPSX_ACCOUNTS_ENABLE_FAKE_PROVIDER=false,且第 1 篇没有任何外部身份导入、模型、路由、按钮或反向解析。 - □
AUTH_USER_MODEL = "accounts.User"在第一次迁移前已经配置。 - □ 当前
models.py只包含Department、User、Capability、Role、RoleCapability、UserRole。 - □ 业务授权链只有 User → UserRole → Role → RoleCapability → Capability。
- □ 所有外键的
PROTECT、CASCADE、SET_NULL行为与本文一致。 - □ 两个关联表都有数据库唯一约束。
- □
python manage.py showmigrations accounts显示 0001、0002、0003 已应用。 - □ 明确知道 0003 是旧版本升级机械,不是当前学习模型或运行时业务 API。
- □
python manage.py makemigrations --check --dry-run不要求创建新迁移。 - □
bootstrap_rbac连续执行两次,第二次所有变更计数为 0。 - □ bootstrap 后能力数为 12、角色数为 4,并且在手工绑定前
UserRole数为 0;命令没有自动分配任何用户。 - □ 普通用户通过
create_user()创建,数据库不保存明文密码。 - □ 阶段专用
forms.py只包含本地LoginForm。 - □ 阶段专用
views.py只包含受保护首页与 POST 退出,不依赖外部身份模块。 - □ 阶段专用
urls.py只有首页、本地登录和退出。 - □ 阶段专用
base.html、login.html、home.html不反向解析后续路由,因此不会产生NoReverseMatch。 - □ 匿名访问首页先得到 302 到
/login/?next=/。 - □
readonly-demo本地登录成功,未绑定角色时首页得到 403。 - □ 通过
UserRole绑定启用的readonly_viewer后,刷新新请求得到 200。 - □ 只读用户看到用户、部门、角色、能力目录四张只读卡片。
- □ 只读用户响应 HTML 中不存在三个管理动作标记。
- □ 退出按钮发送带 CSRF token 的 POST,成功后 Session 失效。
- □ 已登录时 GET
/logout/返回 405,GET 不改变认证状态。 - □ 所有验收只使用本地数据库与本地 Session,没有远程状态,也没有伪造真实 provider 验证结果。
25. 下一篇边界
到这里,第 1 篇完成的是“本地认证主体 + 项目角色能力链 + 第一个受保护请求”。我们还没有提供用户列表、用户创建、部门维护、角色编辑、角色分配表单,也没有处理写操作中的并发撤权、越权委派、最后一个激活超级用户保护等问题。
第 2 篇会在这个基础上替换或扩展 forms.py、views.py、urls.py、base.html 与 home.html,实现项目自有用户/部门/角色管理,并把每个写操作的能力检查、事务、行锁和提交前重检讲完整。第 4 篇才会继续扩展模型与登录页,引入外部身份、一次性登录事务和审计事件;届时也会新增 0004 及后续迁移。不要提前把第 4 篇文件混入当前阶段。

浙公网安备 33010602011771号