1372 lines
59 KiB
Markdown
1372 lines
59 KiB
Markdown
> **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 处设计问题)
|
||
|
||
---
|
||
|
||
## 一、领域概览(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
|
||
# ... PermissionChangeLog(append-only,override 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 时清除员工所有个人 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 角色删除前置校验
|
||
|
||
```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=self;Role 权限变更 → 缓存失效;Override 保存 → 日志
|
||
7. **权限定义版本管理** — 新增 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 命名规范参考。
|
||
|
||
```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 VARCHAR(50) NOT NULL,
|
||
sub_module VARCHAR(50) NOT NULL DEFAULT '',
|
||
group_name VARCHAR(100) NOT NULL,
|
||
name VARCHAR(200) NOT NULL,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
value_type VARCHAR(20) NOT NULL CHECK (value_type IN ('BOOLEAN','SCOPE','INTEGER')),
|
||
scope_choices JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||
integer_min INTEGER,
|
||
integer_max INTEGER,
|
||
default_value JSONB NOT NULL DEFAULT '{"v":false}'::jsonb,
|
||
max_allowed_categories VARCHAR(50)[] NOT NULL DEFAULT ARRAY[]::VARCHAR[],
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
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(),
|
||
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')),
|
||
description TEXT NOT NULL DEFAULT '',
|
||
template_role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
|
||
is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
created_by UUID REFERENCES staff(id) ON DELETE SET 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,
|
||
deleted_at TIMESTAMPTZ
|
||
);
|
||
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,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_by UUID REFERENCES staff(id) ON DELETE 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);
|
||
|
||
-- 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,
|
||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
|
||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
assigned_by UUID REFERENCES staff(id) ON DELETE 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);
|
||
|
||
-- 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,
|
||
permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT,
|
||
value JSONB NOT NULL,
|
||
override_mode VARCHAR(10) NOT NULL DEFAULT 'REPLACE'
|
||
CHECK (override_mode IN ('REPLACE','RESTRICT','GRANT')),
|
||
reason TEXT NOT NULL DEFAULT '',
|
||
modified_by UUID REFERENCES staff(id) ON DELETE 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);
|
||
|
||
-- 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,
|
||
scope_type VARCHAR(20) NOT NULL
|
||
CHECK (scope_type IN ('self','group','store','area','region','company','custom_unit')),
|
||
org_unit_id UUID REFERENCES org_units(id) ON DELETE RESTRICT,
|
||
is_readable BOOLEAN NOT NULL DEFAULT TRUE,
|
||
is_writable BOOLEAN NOT NULL DEFAULT FALSE,
|
||
granted_by UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ,
|
||
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 TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键(原 operated_at 前置)
|
||
target_type VARCHAR(30) NOT NULL
|
||
CHECK (target_type IN ('role','role_permission','staff_role','staff_override','staff_scope')),
|
||
target_id UUID NOT NULL,
|
||
staff_id UUID REFERENCES staff(id) ON DELETE SET NULL,
|
||
role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
|
||
permission_code VARCHAR(150),
|
||
action VARCHAR(20) NOT NULL
|
||
CHECK (action IN ('create','update','delete','assign','revoke')),
|
||
old_value JSONB,
|
||
new_value JSONB,
|
||
operator_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,
|
||
operator_ip INET,
|
||
user_agent TEXT,
|
||
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)
|
||
|
||
```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_
|