Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_PERMISSION.md
2026-04-30 20:33:51 +08:00

1378 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> **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() | 记录最后更新时间(系统自动) |
**关键索引**
```sql
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=已软删除 |
**关键索引**
```sql
-- 软删除友好的唯一索引:同名角色只要有一个未删除即冲突
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 | 最后修改人(权限审计用) |
**关键索引**
```sql
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 | | 失效日 |
**关键索引**
```sql
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() | 最近修改时间(系统自动) |
**关键索引**
```sql
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 | | 授予原因 |
**关键索引**
```sql
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 | — | — | 审计日志**不可删除** |
**关键索引**
```sql
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` 对象。
```python
# 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
-- 批量标记逻辑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
```sql
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 性能考量)
```sql
-- 一次只加载单模块的权限定义与当前角色/员工的值
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一键全局失效。
**批量失效优化**
```python
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 要点
```python
# 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
# ... PermissionChangeLogappend-onlyoverride save() 禁止 update/delete
```
---
## 八、关键业务规则与约束
### 8.1 批量设置角色PRD Story 2
```python
def bulk_assign_role(staff_ids: list, role_id: UUID, operator, override_individual=False):
"""
override_individual=True 时清除员工所有个人 OverridePRD 提示"将覆盖个人自定义权限"
"""
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 角色删除前置校验
```python
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
```python
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个月前数据归档冷存储 |
---
## 十二、迁移与初始化清单
1. **Migration 001** — 创建 7 张核心表 + 索引 + 触发器
2. **Data Migration 002** — 从 `fixtures/permission_defs.json` 加载 300 条 PermissionDef 初始数据
3. **Data Migration 003** — 创建系统内置角色:`最大权限角色``is_system_builtin=TRUE`, category=`director`
4. **Data Migration 004** — 为所有现有员工创建 `scope_type='self'` 的默认 DataScope
5. **Hook**`Tenant.create` 后钩子自动执行 002/003/004
6. **信号注册** — Staff 创建时自动 DataScope=selfRole 权限变更 → 缓存失效Override 保存 → 日志
7. **权限定义版本管理** — 新增 CLI`python manage.py sync_permission_defs --fixture-version=1.2`
---
## 十三、开放问题与后续迭代
| 问题 | 建议方向 |
|------|---------|
| 权限申请工作流PRD 非目标) | v2 引入 `permission_requests` 表 + 审批流引擎 |
| 行级权限(如指定小区的房源) | v2 基于 PostgreSQL RLSRow-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 命名规范参考。
```json
[
{
"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 张表
```sql
-- 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, -- 操作人员工 IDRESTRICT操作记录保留操作人不可删除
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 PermissionDefdata migration
```python
# 伪代码
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 系统内置角色
```python
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)
```python
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 后钩子
```python
# 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 伪代码
```python
# 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 — 缓存失效服务
```python
# 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**。
```python
# 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)
```
**使用示例**
```python
# views.py
def property_list(request):
qs = Property.objects.visible_to(request.staff)
# ... 继续其他业务过滤 ...
```
---
## 附录 G — 验收 Checklist开发启动前
- [x] Issue #3 决策:`permission_defs` 归属 `tenant_apps`
- [x] Issue #6 决策override 默认 `REPLACE`
- [ ] 完整 `permission_defs.json` fixture~300 条)落地
- [ ] Migration 0001-0004 实现并 dry-run 通过
- [ ] `Tenant.create` post_save signal 接入 seed 逻辑
- [ ] `PermissionChecker` / `ScopeQueryBuilder` / 缓存失效服务单测覆盖 ≥ 80%
- [ ] `Property` / `Client` Manager 接入 `visible_to`
- [ ] `staff.is_system_admin=TRUE` 短路逻辑 e2e 测试
- [ ] 保护客/保护房过滤规则 e2e 测试
- [ ] `permission_change_logs` 禁止 UPDATE/DELETE 的数据库级 RULE 验证
---
_v1.1 追加: 附录 A-G | 2026-04-24_