64 KiB
For AI assistants: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
Fonrey — 权限管理数据模型(DATA_MODEL_PERMISSION)
所属系统: Fonrey 房产经纪管理系统 版本: v1.0 日期: 2026-04-24 关联模块:
apps/permissions/PRD 来源:PRD/权限管理/权限管理模块PRD.md技术方案:TECH_STACK/权限管理系统技术方案.md(本文档为其最终固化版,修正了 12 处设计问题)
变更历史
| 日期 | 变更人 | 变更内容 |
|---|---|---|
| 2026-04-30 | Atlas | 补充“变更历史”章节(文档治理) |
一、领域概览(Domain Overview)
1.1 核心概念
| 概念 | 表 | 业务说明 |
|---|---|---|
| PermissionDef(权限定义) | permission_defs |
系统所有权限项的元数据目录(约 300+ 条),开发者维护、运营不可编辑。定义权限的 code/名称/值类型/可选范围/默认值 |
| Role(角色) | roles |
权限模板,管理员创建,如「高级业务员」「分行经理」。绑定角色类别,类别决定可配置权限项的上限 |
| RolePermission(角色权限值) | role_permissions |
某角色在某权限项上的具体配置值(稀疏存储:只存非默认值) |
| StaffRole(员工-角色关联) | staff_roles |
员工与角色的多对多关系,支持一人多角色(PRD Story 8),含主角色标识 |
| StaffPermissionOverride(个人覆盖) | staff_permission_overrides |
员工个人权限定制(稀疏存储:只存与角色合并结果不同的项) |
| StaffDataScope(员工管理范围) | staff_data_scopes |
员工的数据可见边界(PRD 5.6),独立于权限项的 SCOPE 值,支持跨层级叠加(如"本组 ∪ 门店B") |
| PermissionChangeLog(权限变更日志) | permission_change_logs |
所有角色/个人权限/范围的变更流水(append-only,不可删除,对齐 staff_transfer_logs 范式) |
1.2 权限模型(Hybrid RBAC + Override)
员工最终权限 = 多角色值合并(最宽松) + 个人 Override(覆盖/收窄/扩展)
员工最终数据范围 = staff_data_scopes 的并集(跨层级可叠加)
AND 业务级 attribute/protection 规则(如保护客过滤)
优先级:PermissionOverride > Merged(RolePermissions) > PermissionDef.default_value
SCOPE 与 DataScope 的职责分离(关键设计决策):
PermissionDef.value_type = SCOPE的权限项值(本人/本组/本门店/全公司)→ 权限项自身的范围维度,如"查看私客范围=本门店"StaffDataScope→ 员工整体的数据边界,与权限项正交组合。例:员工权限"查看私客=本门店",但 DataScope 额外授予"门店B",则员工可见"所属门店 ∪ 门店B"下的私客
1.3 与其他模型的关系
OrgUnit (组织树) ─────────────┐
│ │ path 用于 SCOPE 子树查询
│ ▼
Staff ──N:M── StaffRole ──N:1── Role ──N:M── RolePermission ──N:1── PermissionDef
│ │ category: 置业顾问/店管/总经
│ │
├──1:N── StaffPermissionOverride ──N:1── PermissionDef
│
├──1:N── StaffDataScope ──N:1── OrgUnit (范围节点)
│
└── staff.role (系统角色) ──── 与 Role 表双轨并行
(系统角色决定顶层权限上限)
PermissionChangeLog (append-only) ← 所有变更流水
1.4 与现有 DATA_MODEL 的对接
| 现有模型 | 对接方式 |
|---|---|
staff.role(系统角色枚举) |
保留,作为系统角色(决定是否能登录、是否系统管理员)。Role 表是业务角色(决定业务权限)。两者正交 |
staff.is_system_admin |
权限检查短路:is_system_admin=TRUE 时 PermissionChecker 全部返回 True,绕过所有检查 |
org_units.path |
ScopeQueryBuilder 的核心依赖,用于 "本组/本门店/本区域" 的子树查询(path LIKE '/{root}/{scope_root}/%') |
properties.attribute(公盘/私盘/特盘/封盘) |
业务属性,权限系统 不干预。权限决定"能否访问房源列表",attribute 决定"列表内按业务规则过滤" |
clients.is_protected(保护客) |
保护客在 ScopeQueryBuilder 中额外 AND 保护规则:非 owner/合保人即使有「本门店」权限也不可见 |
property_protections / 保护房 |
同上,业务状态由 ScopeQueryBuilder 额外过滤 |
二、实体关系 ER
┌──────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ permission_defs │───N:1─│ role_permissions │───N:1─│ roles │
│ (权限目录, 租户级)│ │ (角色×权限→值) │ │ (业务角色模板) │
└──────────────────┘ └──────────────────┘ └────────────────────┘
│ │
│ │ N:M via
│ │
│ ┌─────▼──────┐
│ N:1 │ staff_roles│
│ │ is_primary│
│ └─────┬──────┘
│ │ N:1
│ │
│ ┌───────────────────────────┐ ┌────────▼────────┐
└──N:1│ staff_permission_overrides│──N:1────│ staff │
│ (个人差异化权限) │ └────────┬────────┘
└───────────────────────────┘ │
│ 1:N
┌──────────────────────────────────────┤
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ staff_data_scopes │──N:1────►│ org_units (物化路径树) │
│ (管理范围,可跨层叠加)│ └──────────────────────────┘
└──────────────────────┘
┌────────────────────────────────┐
│ permission_change_logs │ (不可删除流水)
│ -> target (role/staff/scope) │
└────────────────────────────────┘
三、Schema 定义
3.1 permission_defs — 权限定义表(权限目录)
归属 schema:
tenant_apps(非 shared),每个租户独立一份初始化数据。 理由:避免django-tenants跨 schema 外键限制;为未来"企业版自定义权限项"留空间;通过 data migration 在租户创建时批量 seed 约 300 条默认记录。
| 字段 | 类型 | 约束 | 业务说明 |
|---|---|---|---|
| id | UUID | PK | 主键(系统生成,业务无关) |
| code | VARCHAR(150) | UNIQUE, NOT NULL | 权限编码,规则:{module}.{sub_module}.{action}[.{qualifier}],如 client.private.view.scope |
| module | VARCHAR(50) | NOT NULL | 一级模块枚举(见 §4.1) |
| sub_module | VARCHAR(50) | 二级模块(如 二手&租赁、商圈精耕) |
|
| group_name | VARCHAR(100) | NOT NULL | 分组标题(如「私客基础权限」「联系人基础权限」) |
| name | VARCHAR(200) | NOT NULL | 显示名称 |
| description | TEXT | 权限作用描述(PRD 5.4.3 每项均有) | |
| value_type | VARCHAR(20) | NOT NULL, CHECK | BOOLEAN / SCOPE / INTEGER(见 §4.2) |
| scope_choices | JSONB | DEFAULT '[]' |
仅 SCOPE 类型有效,可选枚举值的 code 列表,如 ["none","self","store","company"] |
| integer_min | INTEGER | 仅 INTEGER 有效,最小值 |
|
| integer_max | INTEGER | 仅 INTEGER 有效,最大值;NULL=无上限(业务语义:0 通常代表"不限制") |
|
| default_value | JSONB | NOT NULL DEFAULT '{"v":false}' |
系统最小默认值 |
| max_allowed_categories | VARCHAR(50)[] | DEFAULT '{}' |
允许配置此权限的角色类别列表,空数组=所有类别均可(对应 Issue #11) |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | 分组内排序 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | 下线权限项置 FALSE,历史记录保留 |
| is_deprecated | BOOLEAN | NOT NULL DEFAULT FALSE | 废弃标记(不再推荐使用但保持兼容) |
| version | INTEGER | NOT NULL DEFAULT 1 | 权限项定义版本,变更时递增,用于缓存失效 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录创建时间(系统自动) |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录最后更新时间(系统自动) |
关键索引:
CREATE UNIQUE INDEX idx_permission_defs_code ON permission_defs(code);
CREATE INDEX idx_permission_defs_module ON permission_defs(module, sub_module, sort_order) WHERE is_active = TRUE;
CREATE INDEX idx_permission_defs_active ON permission_defs(is_active) WHERE is_active = TRUE;
校验规则(Django model clean()):
code必须匹配^[a-z_]+\.[a-z_]+(\.[a-z_]+){1,2}$value_type='SCOPE'时scope_choices非空default_value必须包含"v"键且值与value_type兼容
3.2 roles — 角色表
| 字段 | 类型 | 约束 | 业务说明 |
|---|---|---|---|
| id | UUID | PK | 主键(系统生成,业务无关) |
| name | VARCHAR(100) | NOT NULL | 角色名称 |
| category | VARCHAR(30) | NOT NULL, CHECK | agent(置业顾问) / store_manager(店管) / director(总经) / operator(运营) / custom(见 §4.3) |
| description | TEXT | 角色描述 | |
| template_role_id | UUID | FK→roles, SET NULL | 权限模板来源角色(PRD「引用该角色配置」列) |
| is_system_builtin | BOOLEAN | NOT NULL DEFAULT FALSE | 系统内置角色(如"最大权限角色"),不可删除、不可改名 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | 角色是否启用;FALSE=禁用(员工无法继承该角色权限) |
| created_by | UUID | FK→staff, SET NULL | 创建人(PRD: 角色类别只能由创建者修改) |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录创建时间(系统自动) |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录最后更新时间(系统自动) |
| updated_by | UUID | FK→staff, SET NULL | 最后修改人(权限管理审计用) |
| deleted_at | TIMESTAMPTZ | 软删除时间戳;NULL=未删除,非NULL=已软删除 |
关键索引:
-- 软删除友好的唯一索引:同名角色只要有一个未删除即冲突
CREATE UNIQUE INDEX idx_roles_name_active ON roles(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_roles_category ON roles(category) WHERE deleted_at IS NULL;
CREATE INDEX idx_roles_template ON roles(template_role_id);
禁止操作:
- ❌ 角色被
staff_roles引用时禁止删除(DB 层PROTECT,Application 层给出"请先迁移 N 位员工"提示) - ❌
is_system_builtin = TRUE的角色禁止删除/改名 - ❌
category在创建后仅created_by本人可修改(Application 层校验)
3.3 role_permissions — 角色权限值表
| 字段 | 类型 | 约束 | 业务说明 |
|---|---|---|---|
| id | UUID | PK | 主键(系统生成,业务无关) |
| role_id | UUID | NOT NULL, FK→roles, CASCADE | 所属角色(稀疏存储:角色删除时级联清理权限值) |
| permission_def_id | UUID | NOT NULL, FK→permission_defs, RESTRICT | 权限定义(RESTRICT防止删除仍被引用的权限项) |
| value | JSONB | NOT NULL | 统一格式 {"v": <value>},如 {"v": true} / {"v": "store"} / {"v": 50} |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录创建时间(系统自动) |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录最后更新时间(系统自动) |
| updated_by | UUID | FK→staff, SET NULL | 最后修改人(权限审计用) |
关键索引:
CREATE UNIQUE INDEX idx_role_permissions_uniq ON role_permissions(role_id, permission_def_id);
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id);
CREATE INDEX idx_role_permissions_def ON role_permissions(permission_def_id);
稀疏存储原则:只存储非 default_value 的项。保存时若 value == permission_def.default_value,执行 DELETE 而非 UPSERT。优化存储与合并性能。
3.4 staff_roles — 员工角色关联
| 字段 | 类型 | 约束 | 业务说明 |
|---|---|---|---|
| id | UUID | PK | 主键(系统生成,业务无关) |
| staff_id | UUID | NOT NULL, FK→staff, CASCADE | 所属员工(员工删除时级联删除角色关联) |
| role_id | UUID | NOT NULL, FK→roles, PROTECT | 角色被员工引用时禁止删除 |
| is_primary | BOOLEAN | NOT NULL DEFAULT FALSE | 主角色标识,每个员工有且仅有一个主角色(Issue #5) |
| assigned_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 角色分配时间(系统自动) |
| assigned_by | UUID | FK→staff, SET NULL | 分配操作人(管理员) |
| valid_from | DATE | 生效日(预留未来"定时生效"功能) | |
| valid_until | DATE | 失效日 |
关键索引:
CREATE UNIQUE INDEX idx_staff_roles_uniq ON staff_roles(staff_id, role_id);
CREATE UNIQUE INDEX idx_staff_roles_primary ON staff_roles(staff_id) WHERE is_primary = TRUE;
CREATE INDEX idx_staff_roles_role ON staff_roles(role_id);
约束触发器:每次 UPDATE is_primary=TRUE 时,自动将该 staff 其他行 is_primary=FALSE(Django signal 实现,避免 DB 级递归触发器)
3.5 staff_permission_overrides — 个人权限覆盖
| 字段 | 类型 | 约束 | 业务说明 |
|---|---|---|---|
| id | UUID | PK | 主键(系统生成,业务无关) |
| staff_id | UUID | NOT NULL, FK→staff, CASCADE | 所属员工(员工删除时级联删除覆盖记录) |
| permission_def_id | UUID | NOT NULL, FK→permission_defs, RESTRICT | 被覆盖的权限项 |
| value | JSONB | NOT NULL | 个人权限值,同 {"v": ...} 格式 |
| override_mode | VARCHAR(10) | NOT NULL DEFAULT 'REPLACE' | REPLACE(覆盖角色合并值,PRD 默认) / RESTRICT(限制上限) / GRANT(仅扩展) — Issue #6 |
| reason | TEXT | 管理员备注(建议强制,为后续审计) | |
| modified_by | UUID | FK→staff, SET NULL | 执行个人权限修改的管理员 |
| modified_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 最近修改时间(系统自动) |
关键索引:
CREATE UNIQUE INDEX idx_staff_overrides_uniq ON staff_permission_overrides(staff_id, permission_def_id);
CREATE INDEX idx_staff_overrides_staff ON staff_permission_overrides(staff_id);
稀疏存储:只存与角色合并值不同的项。批量设置角色(PRD Story 2)会清除员工所有 Override;个人编辑(Story 3)按需 UPSERT/DELETE。
3.6 staff_data_scopes — 员工数据管理范围(⭐ 新增,Issue #1)
PRD 5.6「管理范围」的实体化。与权限项的
SCOPE值互为补充:权限项决定"访问什么数据",DataScope 决定"数据的组织边界"。
| 字段 | 类型 | 约束 | 业务说明 |
|---|---|---|---|
| id | UUID | PK | 主键(系统生成,业务无关) |
| staff_id | UUID | NOT NULL, FK→staff, CASCADE | 所属员工(员工删除时级联删除范围记录) |
| scope_type | VARCHAR(20) | NOT NULL, CHECK | self / group / store / area / region / company / custom_unit |
| org_unit_id | UUID | FK→org_units, RESTRICT | scope_type='custom_unit' 时必填,指向具体的组织节点;其他类型时 NULL(由 staff.org_unit_id 动态推导) |
| is_readable | BOOLEAN | NOT NULL DEFAULT TRUE | 可读 |
| is_writable | BOOLEAN | NOT NULL DEFAULT FALSE | 可写(默认只读) |
| granted_by | UUID | FK→staff, SET NULL | 授权操作人(管理员) |
| granted_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 授权时间(系统自动) |
| expires_at | TIMESTAMPTZ | 临时授权失效时间 | |
| reason | TEXT | 授予原因 |
关键索引:
CREATE INDEX idx_data_scopes_staff ON staff_data_scopes(staff_id);
CREATE INDEX idx_data_scopes_org ON staff_data_scopes(org_unit_id);
CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expires_at IS NOT NULL;
业务语义:
- 员工的「管理范围」= 所有
staff_data_scopes记录对应的org_units.path子树并集 - 支持"扩充范围"(PRD 5.3.3):新增一条
scope_type='custom_unit', org_unit_id=门店B记录 - 保底规则:员工至少有一条
scope_type='self'的记录(入职时自动创建)
3.7 permission_change_logs — 权限变更流水(⭐ 新增,Issue #4)
append-only,不可删除,对齐
staff_transfer_logs范式。
| 字段 | 类型 | 约束 | 业务说明 |
|---|---|---|---|
| id | UUID | PK | 主键(系统生成,业务无关) |
| target_type | VARCHAR(30) | NOT NULL, CHECK | role / role_permission / staff_role / staff_override / staff_scope |
| target_id | UUID | NOT NULL | 被变更对象的 ID(上述各表的主键) |
| staff_id | UUID | FK→staff, SET NULL | 被影响员工(target 是 staff_role/staff_override/staff_scope 时必填,便于按员工查询) |
| role_id | UUID | FK→roles, SET NULL | 被影响角色(便于按角色查询) |
| permission_code | VARCHAR(150) | 被变更的权限 code(用 code 而非 FK,避免 PermissionDef 删除后日志丢失) | |
| action | VARCHAR(20) | NOT NULL, CHECK | create / update / delete / assign / revoke |
| old_value | JSONB | 变更前快照 | |
| new_value | JSONB | 变更后快照 | |
| operator_id | UUID | NOT NULL, FK→staff, RESTRICT | 操作人 |
| operator_ip | INET | 操作来源 IP | |
| user_agent | TEXT | 操作终端 UA | |
| reason | TEXT | 操作原因(批量设置角色等场景强制填写) | |
| operated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 操作时间(append-only流水,系统自动) |
| ⚠️ 无 deleted_at | — | — | 审计日志不可删除 |
关键索引:
CREATE INDEX idx_perm_log_staff ON permission_change_logs(staff_id, operated_at DESC) WHERE staff_id IS NOT NULL;
CREATE INDEX idx_perm_log_role ON permission_change_logs(role_id, operated_at DESC) WHERE role_id IS NOT NULL;
CREATE INDEX idx_perm_log_target ON permission_change_logs(target_type, target_id, operated_at DESC);
CREATE INDEX idx_perm_log_operator ON permission_change_logs(operator_id, operated_at DESC);
CREATE INDEX idx_perm_log_time ON permission_change_logs(operated_at DESC);
分区建议:数据量大(每次批量角色变更产生 N 条记录)时按 operated_at 月度 RANGE 分区。
四、枚举常量
4.1 PermissionModule — 一级模块(与 PRD 8.2 导航完全对齐)
home = 首页
property = 房源
new_house = 新房
client = 客源
transaction = 交易
data = 数据
marketing = 营销
hr = 人事OA
contract = 合同
trinet = 三网
system = 系统
mobile = 移动端
smart_store = 智能门店
recharge = 在线充值
4.2 ValueType — 权限值类型
| 值 | 存储格式 | UI 控件 | 示例 |
|---|---|---|---|
BOOLEAN |
{"v": true} |
Toggle | 今日新上房源是否显示 |
SCOPE |
{"v": "store"} |
下拉选择 | 查看私客范围(本人/本组/本门店/本区域/全公司) |
INTEGER |
{"v": 50} |
数字输入 | 每日最多查看联系人数(0=不限制) |
4.3 RoleCategory — 角色类别
agent = 置业顾问(一线经纪人)
store_manager = 店管(店长/区域经理)
director = 总经(公司级管理)
operator = 运营/行政
custom = 自定义(不继承预设上限)
4.4 ScopeLevel — SCOPE 枚举有序值(用于多角色合并)
none = 0 无
self = 1 本人
group = 2 本组
store = 3 本门店
area = 4 本区域
region = 5 本大区
company = 6 全公司
五、权限解析与查询模式
5.1 员工权限解析流程(resolver)
输入: staff_id
Step 1: 若 staff.is_system_admin = TRUE → 返回 "全部权限=true,全部 SCOPE=company"(短路)
Step 2: 加载所有 staff_roles → role_ids
Step 3: 加载所有 role_permissions (role_id IN role_ids) 按 permission_def 分组
Step 4: 合并多角色值:
BOOLEAN → OR
SCOPE → MAX by ScopeLevel
INTEGER → MAX (0 视为 +∞)
Step 5: 填入未配置权限项的 default_value
Step 6: 叠加 staff_permission_overrides(按 override_mode 规则):
REPLACE → 直接替换合并值
RESTRICT → min(override_value, merged_value)
GRANT → max(override_value, merged_value)
输出: { permission_code: {"v": value}, ... }
缓存: perm:v{CACHE_VERSION}:{schema}:{staff_id} (TTL=3600)
5.2 数据范围查询(ScopeQueryBuilder)
核心工具类,职责:将"SCOPE 权限值 + StaffDataScope"转为 Django ORM Q 对象。
# apps/permissions/services/scope_query.py
from django.db.models import Q
class ScopeQueryBuilder:
"""
用法:
qs = Property.objects.all()
qs = ScopeQueryBuilder(staff, "property.list.view.scope", field_prefix="seller_agent")\
.apply_to(qs)
"""
def __init__(self, staff, permission_code: str, field_prefix: str):
self.staff = staff
self.perm_code = permission_code
self.field_prefix = field_prefix # e.g. "seller_agent" or "owner"
def build_q(self) -> Q:
# 1. 系统管理员短路
if self.staff.is_system_admin:
return Q() # no filter
# 2. 读取权限值
checker = PermissionChecker(self.staff)
scope = checker.get_scope(self.perm_code) # e.g. "store"
# 3. 构建 base Q(基于 SCOPE 值 + staff.org_unit.path 子树)
base_q = self._scope_to_q(scope)
# 4. 叠加 StaffDataScope 并集
for ds in StaffDataScope.objects.filter(staff=self.staff, is_readable=True):
base_q |= self._datascope_to_q(ds)
return base_q
def _scope_to_q(self, scope: str) -> Q:
"""将 SCOPE 枚举转为 Q,基于当前 staff 的 org_unit.path"""
prefix = self.field_prefix
if scope == "none":
return Q(pk__in=[]) # 永远为空
if scope == "self":
return Q(**{f"{prefix}_id": self.staff.id})
if scope == "group":
return Q(**{f"{prefix}__org_unit__path__startswith": self.staff.org_unit.path})
if scope in ("store", "area", "region"):
# 向上定位到对应层级节点
target_unit = self.staff.org_unit.ancestor_of_type(scope) # "store" → Store 节点
return Q(**{f"{prefix}__org_unit__path__startswith": target_unit.path})
if scope == "company":
return Q()
return Q(pk__in=[])
def apply_to(self, queryset):
q = self.build_q()
# 业务规则叠加:保护客过滤(客源场景)
if hasattr(queryset.model, "is_protected"):
q &= (Q(is_protected=False) | Q(owner_id=self.staff.id))
return queryset.filter(q)
5.3 「权限与角色不一致」标记
-- 批量标记逻辑(SQL 层,用于人员列表高频查询)
-- 某员工若存在任一 override 行,其 value != (其角色合并值),则标记不一致
SELECT DISTINCT o.staff_id
FROM staff_permission_overrides o
JOIN permission_defs pd ON pd.id = o.permission_def_id
WHERE NOT EXISTS (
-- 简化示例:实际需在应用层对比合并结果
SELECT 1 FROM role_permissions rp
JOIN staff_roles sr ON sr.role_id = rp.role_id
WHERE sr.staff_id = o.staff_id
AND rp.permission_def_id = o.permission_def_id
AND rp.value = o.value
);
实现建议:查询时应用层计算,结果缓存到 perm:inconsistent:{schema}:{staff_id} (TTL=300),变更时失效。
5.4 查询某角色的应用人数(PRD 5.5.1)
SELECT COUNT(DISTINCT staff_id) AS applied_count
FROM staff_roles
WHERE role_id = :role_id
AND (valid_until IS NULL OR valid_until >= CURRENT_DATE);
5.5 按模块懒加载权限面板(PRD 性能考量)
-- 一次只加载单模块的权限定义与当前角色/员工的值
SELECT pd.*, rp.value AS role_value, spo.value AS override_value
FROM permission_defs pd
LEFT JOIN role_permissions rp
ON rp.permission_def_id = pd.id AND rp.role_id = :role_id
LEFT JOIN staff_permission_overrides spo
ON spo.permission_def_id = pd.id AND spo.staff_id = :staff_id
WHERE pd.module = :module AND pd.is_active = TRUE
ORDER BY pd.sub_module, pd.group_name, pd.sort_order;
六、Redis 缓存策略
| Cache Key | TTL | 失效触发 |
|---|---|---|
perm:v{VER}:{schema}:{staff_id} |
3600s | invalidate_staff_cache():Override/StaffRole 变更 |
perm:v{VER}:{schema}:role:{role_id}:staff_ids |
3600s | 角色权限变更时用 Pipeline 批量失效该 role 下所有 staff 缓存(Issue #8) |
perm:inconsistent:{schema}:{staff_id} |
300s | 同上 |
perm:defs:{schema} |
86400s | PermissionDef 变更(低频) |
perm:role_applied_count:{schema}:{role_id} |
600s | StaffRole 变更 |
版本号机制(Issue #7):CACHE_VERSION 在 Django settings 中,升级 PermissionDef 结构时 bump,一键全局失效。
批量失效优化:
def invalidate_role_cache(role_id: int, tenant_schema: str):
staff_ids = StaffRole.objects.filter(role_id=role_id).values_list("staff_id", flat=True)
pipe = _redis.pipeline()
for sid in staff_ids:
pipe.delete(f"perm:v{CACHE_VERSION}:{tenant_schema}:{sid}")
pipe.delete(f"perm:inconsistent:{tenant_schema}:{sid}")
pipe.execute() # 单次往返
七、Django Model 要点
# apps/permissions/models.py (精简示例,完整代码见 TECH_STACK/权限管理系统技术方案.md)
from django.db import models
from django.contrib.postgres.fields import ArrayField
from apps.core.models.base import TimeStampedModel, SoftDeleteModel
class ValueType(models.TextChoices):
BOOLEAN = "BOOLEAN", "开关型"
SCOPE = "SCOPE", "范围型"
INTEGER = "INTEGER", "数值型"
class ScopeLevel(models.IntegerChoices):
NONE = 0, "无"
SELF = 1, "本人"
GROUP = 2, "本组"
STORE = 3, "本门店"
AREA = 4, "本区域"
REGION = 5, "本大区"
COMPANY = 6, "全公司"
class PermissionDef(TimeStampedModel):
code = models.CharField(max_length=150, unique=True)
module = models.CharField(max_length=50)
sub_module = models.CharField(max_length=50, blank=True)
group_name = models.CharField(max_length=100)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
value_type = models.CharField(max_length=20, choices=ValueType.choices)
scope_choices = models.JSONField(default=list, blank=True)
integer_min = models.IntegerField(null=True, blank=True)
integer_max = models.IntegerField(null=True, blank=True)
default_value = models.JSONField(default=dict)
max_allowed_categories = ArrayField(models.CharField(max_length=50), default=list, blank=True)
sort_order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
is_deprecated = models.BooleanField(default=False)
version = models.IntegerField(default=1)
class Meta:
db_table = "permission_defs"
indexes = [
models.Index(fields=["module", "sub_module", "sort_order"]),
]
# ... Role / RolePermission / StaffRole / StaffPermissionOverride / StaffDataScope
# ... PermissionChangeLog(append-only,override save() 禁止 update/delete)
八、关键业务规则与约束
8.1 批量设置角色(PRD Story 2)
def bulk_assign_role(staff_ids: list, role_id: UUID, operator, override_individual=False):
"""
override_individual=True 时清除员工所有个人 Override(PRD 提示"将覆盖个人自定义权限")
"""
with transaction.atomic():
# 1. 清除旧主角色
StaffRole.objects.filter(staff_id__in=staff_ids, is_primary=True).update(is_primary=False)
# 2. 批量 UPSERT 新主角色
for sid in staff_ids:
sr, _ = StaffRole.objects.update_or_create(
staff_id=sid, role_id=role_id,
defaults={"is_primary": True, "assigned_by": operator}
)
PermissionChangeLog.objects.create(
target_type="staff_role", target_id=sr.id,
staff_id=sid, role_id=role_id, action="assign",
operator_id=operator.id
)
# 3. 可选清除个人 Override
if override_individual:
StaffPermissionOverride.objects.filter(staff_id__in=staff_ids).delete()
# 4. 批量失效缓存
invalidate_staff_cache_batch(staff_ids, tenant_schema)
8.2 角色删除前置校验
def delete_role(role: Role, operator):
applied_count = StaffRole.objects.filter(role=role).count()
if applied_count > 0:
raise ValidationError(f"无法删除:仍有 {applied_count} 位员工使用此角色,请先迁移")
if role.is_system_builtin:
raise ValidationError("系统内置角色不可删除")
role.deleted_at = timezone.now()
role.save()
PermissionChangeLog.objects.create(
target_type="role", target_id=role.id, role_id=role.id,
action="delete", operator_id=operator.id,
old_value={"name": role.name, "category": role.category}
)
8.3 角色类别与权限项的可配置约束(Issue #11)
def get_editable_permissions_for_role(role: Role):
"""编辑角色权限时,只返回该角色类别允许配置的权限项"""
return PermissionDef.objects.filter(
is_active=True
).filter(
# max_allowed_categories 为空 或 包含当前角色类别
models.Q(max_allowed_categories=[]) |
models.Q(max_allowed_categories__contains=[role.category])
)
九、与现有 DATA_MODEL 的集成契约
| 接入点 | 契约 |
|---|---|
Staff |
新增 method get_permission_checker() 返回 PermissionChecker 实例 |
OrgUnit |
新增 method ancestor_of_type(type: str) 返回指定类型的祖先节点(供 SCOPE → store/area 定位) |
Property.objects |
Manager 新增 visible_to(staff) 方法,内部用 ScopeQueryBuilder |
Client.objects |
同上,额外 AND 保护客规则 |
apps/core/middleware |
Request 级注入 request.permission_checker,供 View/template 使用 |
十、禁止操作
- ❌ 严禁直接 INSERT/UPDATE
permission_change_logs跳过业务逻辑 — 所有权限变更必须通过 service 层触发日志写入 - ❌ 严禁 DELETE
permission_change_logs— 审计要求不可删除 - ❌ 严禁绕过
ScopeQueryBuilder在 View 直接构造 Q — 会导致越权漏洞,且无法统一审计 - ❌ 严禁在 Application 层假设
default_value的具体值 — 所有判断必须经PermissionChecker - ❌ 严禁
StaffPermissionOverride与RolePermission值完全相同的冗余记录 — 保存时必须先做差异对比(稀疏存储) - ❌ 严禁硬删除
PermissionDef— 设置is_active=FALSE,历史数据依赖code保留 - ❌ 严禁修改
PermissionDef.code— code 是业务代码的硬依赖,变更需走新建 code + 下线旧 code 流程 - ❌ 严禁修改
StaffRole.is_primary绕过唯一约束(必须通过 signal 维护) - ❌ 严禁跨租户查询权限数据 — 所有查询必须通过
django-tenants的 connection schema 路由
十一、数据量与性能预测
| 表 | 预估行数 | 增长 | 优化策略 |
|---|---|---|---|
permission_defs |
300-500 | 低 | 全表缓存 |
roles |
10-50 per tenant | 低 | 无需优化 |
role_permissions |
50-150 per role | 低 | 按 role_id 聚合缓存 |
staff_roles |
1-3 per staff | 低 | 主键即可 |
staff_permission_overrides |
5-50 per staff(稀疏) | 中 | 按 staff_id 缓存 |
staff_data_scopes |
1-5 per staff | 低 | 结合 org_units.path 查询 |
permission_change_logs |
10万-100万+ | 高 | 月度 RANGE 分区,6个月前数据归档冷存储 |
十二、迁移与初始化清单
- Migration 001 — 创建 7 张核心表 + 索引 + 触发器
- Data Migration 002 — 从
fixtures/permission_defs.json加载 300 条 PermissionDef 初始数据 - Data Migration 003 — 创建系统内置角色:
最大权限角色(is_system_builtin=TRUE, category=director) - Data Migration 004 — 为所有现有员工创建
scope_type='self'的默认 DataScope - Hook —
Tenant.create后钩子自动执行 002/003/004 - 信号注册 — Staff 创建时自动 DataScope=self;Role 权限变更 → 缓存失效;Override 保存 → 日志
- 权限定义版本管理 — 新增 CLI:
python manage.py sync_permission_defs --fixture-version=1.2
十三、开放问题与后续迭代
| 问题 | 建议方向 |
|---|---|
| 权限申请工作流(PRD 非目标) | v2 引入 permission_requests 表 + 审批流引擎 |
| 行级权限(如指定小区的房源) | v2 基于 PostgreSQL RLS(Row-Level Security)或 Guardian |
| 权限变更日志看板(PRD 非目标) | v1.5 基于 permission_change_logs + Grafana |
| IP 白名单/登录时段 | v2 独立的"安全策略"模块 |
| 跨租户权限(加盟商/联合门店) | v3,需引入联邦身份方案 |
本文档版本 v1.1 | 作者: Backend Architect | 更新时间 2026-04-24
附录 A — 决策确认记录(v1.1 追加)
| 议题 | 决策 | 日期 | 说明 |
|---|---|---|---|
Issue #3:permission_defs 归属 schema |
✅ tenant_apps(每租户独立) |
2026-04-24 | 避免 django-tenants 跨 schema FK 限制;预留"企业版自定义权限项"能力;通过 Tenant.create 后钩子 seed 初始 300 条 |
Issue #6:staff_permission_overrides.override_mode 默认值 |
✅ REPLACE |
2026-04-24 | 契合 PRD "个人特殊授权" 语义;管理员若需限制/扩展可显式选择 RESTRICT/GRANT |
附录 B — PermissionDef 种子数据样例
完整 fixture(约 300 条)待 Django 项目 bootstrap 后落地至
apps/permissions/fixtures/permission_defs.json。 本附录仅给出代表性条目(覆盖 BOOLEAN/SCOPE/INTEGER 三种 value_type + 主要模块)作为结构模板与 code 命名规范参考。
[
{
"code": "home.today_new_property.show",
"module": "home",
"sub_module": "",
"group_name": "首页基础权限",
"name": "今日新上房源是否显示",
"description": "控制首页小组件「今日新上」房源是否对该用户可见",
"value_type": "BOOLEAN",
"default_value": {"v": true},
"max_allowed_categories": [],
"sort_order": 10
},
{
"code": "property.list.view.scope",
"module": "property",
"sub_module": "二手&租赁",
"group_name": "房源列表权限",
"name": "查看房源范围",
"description": "控制房源列表页可见的数据范围;与保护房业务规则叠加生效",
"value_type": "SCOPE",
"scope_choices": ["none", "self", "group", "store", "area", "region", "company"],
"default_value": {"v": "self"},
"max_allowed_categories": [],
"sort_order": 10
},
{
"code": "property.list.export.scope",
"module": "property",
"sub_module": "二手&租赁",
"group_name": "房源列表权限",
"name": "导出房源范围",
"value_type": "SCOPE",
"scope_choices": ["none", "self", "store", "company"],
"default_value": {"v": "none"},
"max_allowed_categories": ["store_manager", "director"],
"sort_order": 20
},
{
"code": "property.owner_phone.view.daily_limit",
"module": "property",
"sub_module": "二手&租赁",
"group_name": "房源隐私权限",
"name": "每日查看业主电话次数上限",
"description": "0 表示不限制;达到上限后次日自然恢复",
"value_type": "INTEGER",
"integer_min": 0,
"integer_max": 999,
"default_value": {"v": 20},
"max_allowed_categories": [],
"sort_order": 10
},
{
"code": "client.private.view.scope",
"module": "client",
"sub_module": "私客",
"group_name": "私客基础权限",
"name": "查看私客范围",
"description": "与保护客规则叠加:非 owner/合保人即使有 store 权限也不可见保护客",
"value_type": "SCOPE",
"scope_choices": ["none", "self", "group", "store", "area", "region", "company"],
"default_value": {"v": "self"},
"max_allowed_categories": [],
"sort_order": 10
},
{
"code": "client.private.transfer.allow",
"module": "client",
"sub_module": "私客",
"group_name": "私客操作权限",
"name": "是否允许转移私客",
"value_type": "BOOLEAN",
"default_value": {"v": false},
"max_allowed_categories": ["store_manager", "director"],
"sort_order": 20
},
{
"code": "transaction.contract.approve.allow",
"module": "transaction",
"sub_module": "合同",
"group_name": "合同审批权限",
"name": "是否可审批合同",
"value_type": "BOOLEAN",
"default_value": {"v": false},
"max_allowed_categories": ["store_manager", "director"],
"sort_order": 10
},
{
"code": "data.report.performance.view.scope",
"module": "data",
"sub_module": "业绩报表",
"group_name": "业绩数据权限",
"name": "业绩报表可见范围",
"value_type": "SCOPE",
"scope_choices": ["none", "self", "group", "store", "area", "company"],
"default_value": {"v": "self"},
"max_allowed_categories": [],
"sort_order": 10
},
{
"code": "hr.staff.create.allow",
"module": "hr",
"sub_module": "员工管理",
"group_name": "员工操作权限",
"name": "是否可新建员工",
"value_type": "BOOLEAN",
"default_value": {"v": false},
"max_allowed_categories": ["store_manager", "director", "operator"],
"sort_order": 10
},
{
"code": "system.role.manage.allow",
"module": "system",
"sub_module": "权限管理",
"group_name": "角色管理权限",
"name": "是否可管理角色与权限",
"description": "授予此权限者可进入角色管理页;系统管理员 is_system_admin=TRUE 自动拥有",
"value_type": "BOOLEAN",
"default_value": {"v": false},
"max_allowed_categories": ["director", "operator"],
"sort_order": 10
}
]
Seed 覆盖清单(Django 项目落地时需补齐):
| 模块 | 预计条目数 | 典型权限项 |
|---|---|---|
| home | ~15 | 小组件显示/隐藏开关 |
| property | ~60 | 列表/详情/导出/隐私/分享/操作 × SCOPE/BOOLEAN |
| new_house | ~30 | 楼盘/房型/佣金/报备 |
| client | ~50 | 私客/公客/保护客 × 查看/转移/释放/导出 |
| transaction | ~40 | 合同/审批/佣金/收付款 |
| data | ~30 | 业绩/排行/漏斗/业务报表 |
| marketing | ~20 | 营销活动/素材/投放 |
| hr | ~25 | 员工/组织/考勤/调整 |
| contract/trinet/system/mobile/smart_store/recharge | ~30 | 模块特定 |
| 合计 | ~300 |
附录 C — Migration DDL 草图
以下为 Django migration 的 等价 DDL,用于评审与 DBA 沟通。实际实现以
apps/permissions/migrations/000X_*.py为准。
C.1 Migration 0001 — 核心 7 张表
-- permission_defs
CREATE TABLE permission_defs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
code VARCHAR(150) NOT NULL UNIQUE, -- 权限码,格式 {module}.{sub_module}.{action}[.{qualifier}],全局唯一,创建后不可修改
module VARCHAR(50) NOT NULL, -- 一级模块标识,如 property / client / org
sub_module VARCHAR(50) NOT NULL DEFAULT '', -- 二级子模块标识;无子模块时为空字符串
group_name VARCHAR(100) NOT NULL, -- 权限分组显示名称(管理界面分组展示用)
name VARCHAR(200) NOT NULL, -- 权限项中文名称(管理界面展示)
description TEXT NOT NULL DEFAULT '', -- 权限项说明(管理界面 tooltip 文案)
value_type VARCHAR(20) NOT NULL CHECK (value_type IN ('BOOLEAN','SCOPE','INTEGER')), -- 权限值类型:BOOLEAN=开关 / SCOPE=数据范围 / INTEGER=数量上限
scope_choices JSONB NOT NULL DEFAULT '[]'::jsonb, -- SCOPE 类型可选范围列表(JSON 数组);非 SCOPE 类型为空数组
integer_min INTEGER, -- INTEGER 类型最小允许值;其他类型为 NULL
integer_max INTEGER, -- INTEGER 类型最大允许值;其他类型为 NULL
default_value JSONB NOT NULL DEFAULT '{"v":false}'::jsonb, -- 权限默认值,格式 {"v": false/scope_str/int}
max_allowed_categories VARCHAR(50)[] NOT NULL DEFAULT ARRAY[]::VARCHAR[], -- 可配置此权限的角色分类白名单;空数组=无限制
sort_order INTEGER NOT NULL DEFAULT 0, -- 同分组内排序权重(数值越小越靠前)
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用;FALSE=已下线,前端配置页隐藏
is_deprecated BOOLEAN NOT NULL DEFAULT FALSE, -- 是否已废弃;废弃后不可被新角色引用
version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本号(每次更新+1)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
CONSTRAINT chk_code_format CHECK (code ~ '^[a-z_]+\.[a-z_]+(\.[a-z_]+){1,2}$')
);
CREATE INDEX idx_permission_defs_module ON permission_defs(module, sub_module, sort_order) WHERE is_active = TRUE;
CREATE INDEX idx_permission_defs_active ON permission_defs(is_active) WHERE is_active = TRUE;
-- roles
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
name VARCHAR(100) NOT NULL, -- 角色显示名称(同租户内唯一,软删除后不参与唯一性校验)
category VARCHAR(30) NOT NULL CHECK (category IN ('agent','store_manager','director','operator','custom')), -- 角色分类:agent=经纪人 / store_manager=门店管理 / director=区域管理 / operator=运营职能 / custom=自定义
description TEXT NOT NULL DEFAULT '', -- 角色说明文案
template_role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 模板来源(自引用);从某内置角色克隆时记录;NULL=无模板
is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE, -- 是否系统内置角色;TRUE=不可删除、不可改名
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用;FALSE=角色已停用,员工不可再分配此角色
created_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 创建人(关联 staff 表);系统内置角色为 NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表)
deleted_at TIMESTAMPTZ -- 软删除时间戳,NULL=未删除,非NULL=已软删除
);
CREATE UNIQUE INDEX idx_roles_name_active ON roles(name) WHERE deleted_at IS NULL;
CREATE INDEX idx_roles_category ON roles(category) WHERE deleted_at IS NULL;
CREATE INDEX idx_roles_template ON roles(template_role_id);
-- role_permissions
CREATE TABLE role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, -- 关联角色(角色删除则权限配置同步级联清除)
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义(有角色引用时权限项不可删除)
value JSONB NOT NULL, -- 权限配置值,格式 {"v": false/scope_str/int},与 permission_defs.value_type 对应
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动)
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL -- 最后修改人(关联 staff 表)
);
CREATE UNIQUE INDEX idx_role_permissions_uniq ON role_permissions(role_id, permission_def_id);
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id);
CREATE INDEX idx_role_permissions_def ON role_permissions(permission_def_id);
-- staff_roles
CREATE TABLE staff_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID(员工删除则角色分配同步级联删除)
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, -- 角色 ID(有员工使用的角色不可删除)
is_primary BOOLEAN NOT NULL DEFAULT FALSE, -- 是否主角色;每员工同时仅可有 1 个主角色(唯一索引保障)
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 角色分配时间(系统自动)
assigned_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 分配操作人(关联 staff 表);NULL=系统自动分配
valid_from DATE, -- 角色有效期开始日期;NULL=立即生效
valid_until DATE -- 角色有效期结束日期;NULL=永久有效
);
CREATE UNIQUE INDEX idx_staff_roles_uniq ON staff_roles(staff_id, role_id);
CREATE UNIQUE INDEX idx_staff_roles_primary ON staff_roles(staff_id) WHERE is_primary = TRUE;
CREATE INDEX idx_staff_roles_role ON staff_roles(role_id);
-- staff_permission_overrides
CREATE TABLE staff_permission_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID(员工删除则个人覆盖配置同步级联删除)
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义
value JSONB NOT NULL, -- 覆盖配置值,格式与 role_permissions.value 一致
override_mode VARCHAR(10) NOT NULL DEFAULT 'REPLACE'
CHECK (override_mode IN ('REPLACE','RESTRICT','GRANT')), -- 覆盖模式:REPLACE=完全替换角色权限 / RESTRICT=向下收紧 / GRANT=向上提升
reason TEXT NOT NULL DEFAULT '', -- 覆盖理由(操作审计留存)
modified_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 修改操作人(关联 staff 表)
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 修改时间(系统自动)
);
CREATE UNIQUE INDEX idx_staff_overrides_uniq ON staff_permission_overrides(staff_id, permission_def_id);
CREATE INDEX idx_staff_overrides_staff ON staff_permission_overrides(staff_id);
-- staff_data_scopes
CREATE TABLE staff_data_scopes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关)
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID
scope_type VARCHAR(20) NOT NULL -- 数据范围类型:self=本人 / group=小组 / store=门店 / area=大区 / region=区域 / company=全公司 / custom_unit=自定义单元
CHECK (scope_type IN ('self','group','store','area','region','company','custom_unit')),
org_unit_id UUID REFERENCES org_units(id) ON DELETE RESTRICT, -- 自定义组织单元;scope_type=custom_unit 时必填,其他为 NULL
is_readable BOOLEAN NOT NULL DEFAULT TRUE, -- 是否有读权限
is_writable BOOLEAN NOT NULL DEFAULT FALSE, -- 是否有写权限
granted_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 授权操作人(关联 staff 表)
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 授权时间(系统自动)
expires_at TIMESTAMPTZ, -- 到期时间;NULL=永久有效
reason TEXT NOT NULL DEFAULT '', -- 数据范围授权理由(操作审计留存)
CONSTRAINT chk_custom_unit_has_org CHECK (
(scope_type = 'custom_unit' AND org_unit_id IS NOT NULL) OR
(scope_type <> 'custom_unit')
)
);
CREATE INDEX idx_data_scopes_staff ON staff_data_scopes(staff_id);
CREATE INDEX idx_data_scopes_org ON staff_data_scopes(org_unit_id);
CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expires_at IS NOT NULL;
-- permission_change_logs (append-only, no deleted_at)
CREATE TABLE permission_change_logs (
id UUID NOT NULL DEFAULT gen_random_uuid(), -- 主键(与 operated_at 组成复合主键,分区表要求)
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键(原 operated_at 前置)
target_type VARCHAR(30) NOT NULL -- 操作对象类型:role / role_permission / staff_role / staff_override / staff_scope
CHECK (target_type IN ('role','role_permission','staff_role','staff_override','staff_scope')),
target_id UUID NOT NULL, -- 操作对象 ID
staff_id UUID REFERENCES staff(id) ON DELETE SET NULL, -- 被操作员工 ID(如分配/撤销角色时的目标员工)
role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 被操作角色 ID
permission_code VARCHAR(150), -- 操作涉及的权限码(冗余存储,避免关联查询)
action VARCHAR(20) NOT NULL -- 操作类型:create=新建 / update=修改 / delete=删除 / assign=分配 / revoke=撤销
CHECK (action IN ('create','update','delete','assign','revoke')),
old_value JSONB, -- 变更前值;create 时为 NULL
new_value JSONB, -- 变更后值;delete 时为 NULL
operator_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT, -- 操作人员工 ID(RESTRICT:操作记录保留,操作人不可删除)
operator_ip INET, -- 操作人来源 IP
user_agent TEXT, -- 操作人客户端 UA
reason TEXT NOT NULL DEFAULT '', -- 操作理由(可选,审计留存)
PRIMARY KEY (id, operated_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (operated_at);
CREATE TABLE permission_change_logs_2026_04 PARTITION OF permission_change_logs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE permission_change_logs_2026_05 PARTITION OF permission_change_logs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE permission_change_logs_default PARTITION OF permission_change_logs DEFAULT;
CREATE INDEX idx_perm_log_staff ON permission_change_logs(staff_id, operated_at DESC) WHERE staff_id IS NOT NULL;
CREATE INDEX idx_perm_log_role ON permission_change_logs(role_id, operated_at DESC) WHERE role_id IS NOT NULL;
CREATE INDEX idx_perm_log_target ON permission_change_logs(target_type, target_id, operated_at DESC);
CREATE INDEX idx_perm_log_operator ON permission_change_logs(operator_id, operated_at DESC);
CREATE INDEX idx_perm_log_time ON permission_change_logs(operated_at DESC);
-- 拒绝 UPDATE/DELETE 的 rule(兜底,应用层已禁止)
CREATE RULE no_update_perm_log AS ON UPDATE TO permission_change_logs DO INSTEAD NOTHING;
CREATE RULE no_delete_perm_log AS ON DELETE TO permission_change_logs DO INSTEAD NOTHING;
C.2 Migration 0002 — Seed PermissionDef(data migration)
# 伪代码
def forwards(apps, schema_editor):
PermissionDef = apps.get_model("permissions", "PermissionDef")
data = json.load(open("apps/permissions/fixtures/permission_defs.json"))
PermissionDef.objects.bulk_create([PermissionDef(**row) for row in data])
C.3 Migration 0003 — Seed 系统内置角色
def forwards(apps, schema_editor):
Role = apps.get_model("permissions", "Role")
RolePermission = apps.get_model("permissions", "RolePermission")
PermissionDef = apps.get_model("permissions", "PermissionDef")
super_role = Role.objects.create(
name="最大权限角色",
category="director",
is_system_builtin=True,
description="系统内置,拥有所有权限项的最高值"
)
# 将所有 SCOPE 权限设为 company,所有 BOOLEAN 设为 true,所有 INTEGER 设为 0(不限)
rows = []
for pd in PermissionDef.objects.filter(is_active=True):
if pd.value_type == "BOOLEAN":
v = {"v": True}
elif pd.value_type == "SCOPE":
v = {"v": "company"}
else:
v = {"v": 0}
if v == pd.default_value:
continue # 稀疏存储
rows.append(RolePermission(role=super_role, permission_def=pd, value=v))
RolePermission.objects.bulk_create(rows)
C.4 Migration 0004 — 为现有员工创建默认 DataScope(self)
def forwards(apps, schema_editor):
Staff = apps.get_model("org", "Staff")
StaffDataScope = apps.get_model("permissions", "StaffDataScope")
rows = [
StaffDataScope(staff_id=sid, scope_type="self", is_readable=True, is_writable=True, reason="初始化")
for sid in Staff.objects.values_list("id", flat=True)
]
StaffDataScope.objects.bulk_create(rows, ignore_conflicts=True)
C.5 Tenant.create 后钩子
# apps/tenants/signals.py
from django_tenants.utils import tenant_context
@receiver(post_save, sender=Tenant)
def init_tenant_permissions(sender, instance, created, **kwargs):
if not created:
return
with tenant_context(instance):
call_command("migrate", "permissions") # 0001
call_command("loaddata", "permission_defs") # 等价于 0002
seed_builtin_roles() # 0003 逻辑
seed_default_data_scopes() # 0004 逻辑
附录 D — PermissionChecker 伪代码
# apps/permissions/services/checker.py
from dataclasses import dataclass
from django.core.cache import cache
from django.conf import settings
CACHE_VERSION = getattr(settings, "PERMISSION_CACHE_VERSION", 1)
@dataclass
class ResolvedValue:
code: str
value_type: str # BOOLEAN / SCOPE / INTEGER
value: object # bool / str / int
source: str # "admin_shortcircuit" / "override" / "role_merge" / "default"
class PermissionChecker:
def __init__(self, staff):
self.staff = staff
self._resolved: dict[str, ResolvedValue] | None = None
# ---------- 公共 API ----------
def has(self, code: str) -> bool:
r = self._get(code)
if r.value_type == "BOOLEAN":
return bool(r.value)
if r.value_type == "SCOPE":
return r.value != "none"
if r.value_type == "INTEGER":
return r.value != 0 and r.value is not None
return False
def get_scope(self, code: str) -> str:
r = self._get(code)
assert r.value_type == "SCOPE"
return r.value
def get_int(self, code: str) -> int:
r = self._get(code)
assert r.value_type == "INTEGER"
return r.value
# ---------- 解析核心 ----------
def _get(self, code: str) -> ResolvedValue:
if self._resolved is None:
self._resolved = self._resolve_all()
if code not in self._resolved:
raise KeyError(f"Unknown permission code: {code}")
return self._resolved[code]
def _resolve_all(self) -> dict[str, ResolvedValue]:
# 1) 系统管理员短路
if self.staff.is_system_admin:
return self._build_admin_values()
# 2) Redis cache
schema = connection.schema_name
key = f"perm:v{CACHE_VERSION}:{schema}:{self.staff.id}"
cached = cache.get(key)
if cached:
return cached
# 3) 加载所有 PermissionDef
defs = {pd.code: pd for pd in PermissionDef.objects.filter(is_active=True)}
# 4) 加载员工所有角色权限值
role_ids = list(StaffRole.objects.filter(staff=self.staff).values_list("role_id", flat=True))
role_values: dict[str, list] = {} # code -> [value, value, ...]
qs = RolePermission.objects.filter(role_id__in=role_ids).select_related("permission_def")
for rp in qs:
role_values.setdefault(rp.permission_def.code, []).append(rp.value["v"])
# 5) 合并多角色值
resolved: dict[str, ResolvedValue] = {}
for code, pd in defs.items():
values = role_values.get(code, [])
if not values:
merged = pd.default_value["v"]
source = "default"
else:
merged = self._merge(pd.value_type, values)
source = "role_merge"
resolved[code] = ResolvedValue(code, pd.value_type, merged, source)
# 6) 叠加 Override
overrides = StaffPermissionOverride.objects.filter(staff=self.staff).select_related("permission_def")
for o in overrides:
code = o.permission_def.code
current = resolved[code]
new_val = self._apply_override(current, o.value["v"], o.override_mode)
resolved[code] = ResolvedValue(code, current.value_type, new_val, "override")
# 7) 缓存
cache.set(key, resolved, timeout=3600)
return resolved
@staticmethod
def _merge(value_type: str, values: list):
if value_type == "BOOLEAN":
return any(values)
if value_type == "SCOPE":
return max(values, key=lambda s: SCOPE_LEVEL[s])
if value_type == "INTEGER":
# 0 视为 +∞
if 0 in values:
return 0
return max(values)
@staticmethod
def _apply_override(current: ResolvedValue, ov: object, mode: str):
if mode == "REPLACE":
return ov
if mode == "RESTRICT":
if current.value_type == "BOOLEAN":
return current.value and ov
if current.value_type == "SCOPE":
return min([current.value, ov], key=lambda s: SCOPE_LEVEL[s])
if current.value_type == "INTEGER":
# 0=+∞,RESTRICT 取最小正值
a, b = current.value, ov
if a == 0: return b
if b == 0: return a
return min(a, b)
if mode == "GRANT":
if current.value_type == "BOOLEAN":
return current.value or ov
if current.value_type == "SCOPE":
return max([current.value, ov], key=lambda s: SCOPE_LEVEL[s])
if current.value_type == "INTEGER":
if 0 in (current.value, ov): return 0
return max(current.value, ov)
def _build_admin_values(self) -> dict[str, ResolvedValue]:
defs = PermissionDef.objects.filter(is_active=True)
out = {}
for pd in defs:
if pd.value_type == "BOOLEAN":
v = True
elif pd.value_type == "SCOPE":
v = "company"
else:
v = 0
out[pd.code] = ResolvedValue(pd.code, pd.value_type, v, "admin_shortcircuit")
return out
附录 E — 缓存失效服务
# apps/permissions/services/cache.py
from django.db import connection
from django.core.cache import cache
from django.conf import settings
CACHE_VERSION = getattr(settings, "PERMISSION_CACHE_VERSION", 1)
def _schema() -> str:
return connection.schema_name
def invalidate_staff(staff_id) -> None:
s = _schema()
cache.delete_many([
f"perm:v{CACHE_VERSION}:{s}:{staff_id}",
f"perm:inconsistent:{s}:{staff_id}",
])
def invalidate_staff_batch(staff_ids) -> None:
s = _schema()
keys = []
for sid in staff_ids:
keys.append(f"perm:v{CACHE_VERSION}:{s}:{sid}")
keys.append(f"perm:inconsistent:{s}:{sid}")
cache.delete_many(keys)
def invalidate_role(role_id) -> None:
"""角色权限变更 → 该角色下所有员工缓存失效"""
from apps.permissions.models import StaffRole
staff_ids = list(
StaffRole.objects.filter(role_id=role_id).values_list("staff_id", flat=True)
)
invalidate_staff_batch(staff_ids)
s = _schema()
cache.delete(f"perm:v{CACHE_VERSION}:{s}:role:{role_id}:staff_ids")
cache.delete(f"perm:role_applied_count:{s}:{role_id}")
def invalidate_all_tenant() -> None:
"""极端情况(PermissionDef 批量变更)— 走版本号 bump 更高效"""
cache.delete(f"perm:defs:{_schema()}")
# 不推荐遍历删除;生产上应 bump settings.PERMISSION_CACHE_VERSION
信号连接(在 apps.py ready() 中注册):
| 信号 | 触发失效 |
|---|---|
post_save / post_delete on RolePermission |
invalidate_role(instance.role_id) |
post_save / post_delete on StaffRole |
invalidate_staff(instance.staff_id) + 角色应用数缓存 |
post_save / post_delete on StaffPermissionOverride |
invalidate_staff(instance.staff_id) |
post_save / post_delete on StaffDataScope |
invalidate_staff(instance.staff_id) |
post_save on PermissionDef (version bump) |
invalidate_all_tenant() + 建议 bump CACHE_VERSION |
附录 F — Manager 扩展(Property / Client)
契合 §九集成契约,提供对外统一的
visible_to(staff)入口,禁止业务代码直接写过滤 Q。
# apps/property/managers.py
from django.db import models
from apps.permissions.services.scope_query import ScopeQueryBuilder
class PropertyQuerySet(models.QuerySet):
def visible_to(self, staff):
return ScopeQueryBuilder(
staff=staff,
permission_code="property.list.view.scope",
field_prefix="seller_agent",
).apply_to(self)
def writable_by(self, staff):
# 编辑权限:基于同一 scope 字段 + property.list.edit.allow BOOLEAN
checker = staff.get_permission_checker()
if not checker.has("property.list.edit.allow"):
return self.none()
return self.visible_to(staff)
class PropertyManager(models.Manager.from_queryset(PropertyQuerySet)):
pass
# apps/client/managers.py
class ClientQuerySet(models.QuerySet):
def visible_to(self, staff):
# ScopeQueryBuilder.apply_to() 内部已处理 is_protected 过滤
return ScopeQueryBuilder(
staff=staff,
permission_code="client.private.view.scope",
field_prefix="owner",
).apply_to(self)
使用示例:
# views.py
def property_list(request):
qs = Property.objects.visible_to(request.staff)
# ... 继续其他业务过滤 ...
附录 G — 验收 Checklist(开发启动前)
- Issue #3 决策:
permission_defs归属tenant_apps - Issue #6 决策:override 默认
REPLACE - 完整
permission_defs.jsonfixture(~300 条)落地 - Migration 0001-0004 实现并 dry-run 通过
Tenant.createpost_save signal 接入 seed 逻辑PermissionChecker/ScopeQueryBuilder/ 缓存失效服务单测覆盖 ≥ 80%Property/ClientManager 接入visible_tostaff.is_system_admin=TRUE短路逻辑 e2e 测试- 保护客/保护房过滤规则 e2e 测试
permission_change_logs禁止 UPDATE/DELETE 的数据库级 RULE 验证
v1.1 追加: 附录 A-G | 2026-04-24