用户与权限管理(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/22712552Policy 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 跳转到登录页,并携带 nextlogin_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 的权威迁移链保留发布历史:

  1. 0001_initial 建立 Department 与自定义 User。它是早期版本的历史快照。
  2. 0002_custom_rbac 增加 Capability、Role、RoleCapability、UserRole 以及两个多对多查询入口,并清理旧模型选项。
  3. 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. 浏览器验收一:匿名用户必须跳登录

  1. 打开浏览器开发者工具的 Network 面板,访问 http://127.0.0.1:8000/。
  2. 第一跳应为 302,Location 指向 /login/?next=/。
  3. 浏览器跟随跳转后,登录页返回 200。
  4. 页面只显示本地用户名和密码表单,不应出现任何外部身份登录按钮。

原因链是:URL resolver 调用 home 的包装函数;外层 login_required 看到匿名 request.user;它依据 LOGIN_URL 生成带 next 的重定向。此时策略查询不会把匿名用户当作有能力的主体。

16. 浏览器验收二:登录成功,但没有角色时返回 403

  1. 在登录页输入 readonly-demo 和刚才只保存在你本机的测试密码。
  2. 提交后,LoginView 应返回 302 到首页。
  3. 首页请求中的 request.user.is_authenticated 为真,is_active 为真。
  4. 由于 UserRole 仍为 0,策略查询得到空能力集合。
  5. 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,管理动作仍隐藏

  1. 在仍保持 readonly-demo Session 的浏览器刷新首页。
  2. 状态应从 403 变为 200。
  3. 页面应显示用户、部门、角色、能力目录四张只读卡片。
  4. 页面不应显示“管理动作预览”区块。
  5. 在 Elements 面板或“查看页面源代码”中搜索 data-section="management-actions"、data-action="user-create"、data-action="department-manage"、data-action="role-manage",均应不存在。

这证明模板标签与视图装饰器复用了项目自有能力链。但请继续记住:隐藏 HTML 不是安全边界。第 2 篇接入真实写端点时,每一个端点都必须在服务端检查对应能力;不能因为按钮看不见就省掉后端校验。

19. 浏览器验收四:退出必须是 POST 且受 CSRF 保护

  1. 点击页头“退出”。它是一个包含 CSRF token 的 POST 表单,不是普通链接。
  2. Network 面板应看到 POST /logout/ 返回 302 到 /login/。
  3. 再次访问首页,应重新得到匿名跳转。
  4. 直接在地址栏访问 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

按下面顺序检查,不要先把用户改成超级用户:

  1. 用户是否 is_active=True;
  2. 是否真的存在该用户到 readonly_viewer 的 UserRole;
  3. 角色是否启用;
  4. accounts.dashboard.view 是否启用;
  5. 角色与首页能力之间是否存在 RoleCapability;
  6. 是否刷新产生了新请求;
  7. 视图导入的能力常量是否为 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 篇文件混入当前阶段。

posted @ 2026-08-26 10:58  小家电维修  阅读(7)  评论(0)    收藏  举报