> **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": }`,如 `{"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}.{sub_module}.{action}[.{qualifier}],全局唯一,创建后不可修改 module VARCHAR(50) NOT NULL, -- 一级模块标识,如 property / client / org sub_module VARCHAR(50) NOT NULL DEFAULT '', -- 二级子模块标识;无子模块时为空字符串 group_name VARCHAR(100) NOT NULL, -- 权限分组显示名称(管理界面分组展示用) name VARCHAR(200) NOT NULL, -- 权限项中文名称(管理界面展示) description TEXT NOT NULL DEFAULT '', -- 权限项说明(管理界面 tooltip 文案) value_type VARCHAR(20) NOT NULL CHECK (value_type IN ('BOOLEAN','SCOPE','INTEGER')), -- 权限值类型:BOOLEAN=开关 / SCOPE=数据范围 / INTEGER=数量上限 scope_choices JSONB NOT NULL DEFAULT '[]'::jsonb, -- SCOPE 类型可选范围列表(JSON 数组);非 SCOPE 类型为空数组 integer_min INTEGER, -- INTEGER 类型最小允许值;其他类型为 NULL integer_max INTEGER, -- INTEGER 类型最大允许值;其他类型为 NULL default_value JSONB NOT NULL DEFAULT '{"v":false}'::jsonb, -- 权限默认值,格式 {"v": false/scope_str/int} max_allowed_categories VARCHAR(50)[] NOT NULL DEFAULT ARRAY[]::VARCHAR[], -- 可配置此权限的角色分类白名单;空数组=无限制 sort_order INTEGER NOT NULL DEFAULT 0, -- 同分组内排序权重(数值越小越靠前) is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用;FALSE=已下线,前端配置页隐藏 is_deprecated BOOLEAN NOT NULL DEFAULT FALSE, -- 是否已废弃;废弃后不可被新角色引用 version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本号(每次更新+1) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动) updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动) CONSTRAINT chk_code_format CHECK (code ~ '^[a-z_]+\.[a-z_]+(\.[a-z_]+){1,2}$') ); CREATE INDEX idx_permission_defs_module ON permission_defs(module, sub_module, sort_order) WHERE is_active = TRUE; CREATE INDEX idx_permission_defs_active ON permission_defs(is_active) WHERE is_active = TRUE; -- roles CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关) name VARCHAR(100) NOT NULL, -- 角色显示名称(同租户内唯一,软删除后不参与唯一性校验) category VARCHAR(30) NOT NULL CHECK (category IN ('agent','store_manager','director','operator','custom')), -- 角色分类:agent=经纪人 / store_manager=门店管理 / director=区域管理 / operator=运营职能 / custom=自定义 description TEXT NOT NULL DEFAULT '', -- 角色说明文案 template_role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 模板来源(自引用);从某内置角色克隆时记录;NULL=无模板 is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE, -- 是否系统内置角色;TRUE=不可删除、不可改名 is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用;FALSE=角色已停用,员工不可再分配此角色 created_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 创建人(关联 staff 表);系统内置角色为 NULL created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动) updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动) updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 最后修改人(关联 staff 表) deleted_at TIMESTAMPTZ -- 软删除时间戳,NULL=未删除,非NULL=已软删除 ); CREATE UNIQUE INDEX idx_roles_name_active ON roles(name) WHERE deleted_at IS NULL; CREATE INDEX idx_roles_category ON roles(category) WHERE deleted_at IS NULL; CREATE INDEX idx_roles_template ON roles(template_role_id); -- role_permissions CREATE TABLE role_permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关) role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, -- 关联角色(角色删除则权限配置同步级联清除) permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义(有角色引用时权限项不可删除) value JSONB NOT NULL, -- 权限配置值,格式 {"v": false/scope_str/int},与 permission_defs.value_type 对应 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录创建时间(系统自动) updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 记录最后更新时间(系统自动) updated_by UUID REFERENCES staff(id) ON DELETE SET NULL -- 最后修改人(关联 staff 表) ); CREATE UNIQUE INDEX idx_role_permissions_uniq ON role_permissions(role_id, permission_def_id); CREATE INDEX idx_role_permissions_role ON role_permissions(role_id); CREATE INDEX idx_role_permissions_def ON role_permissions(permission_def_id); -- staff_roles CREATE TABLE staff_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关) staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID(员工删除则角色分配同步级联删除) role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, -- 角色 ID(有员工使用的角色不可删除) is_primary BOOLEAN NOT NULL DEFAULT FALSE, -- 是否主角色;每员工同时仅可有 1 个主角色(唯一索引保障) assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 角色分配时间(系统自动) assigned_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 分配操作人(关联 staff 表);NULL=系统自动分配 valid_from DATE, -- 角色有效期开始日期;NULL=立即生效 valid_until DATE -- 角色有效期结束日期;NULL=永久有效 ); CREATE UNIQUE INDEX idx_staff_roles_uniq ON staff_roles(staff_id, role_id); CREATE UNIQUE INDEX idx_staff_roles_primary ON staff_roles(staff_id) WHERE is_primary = TRUE; CREATE INDEX idx_staff_roles_role ON staff_roles(role_id); -- staff_permission_overrides CREATE TABLE staff_permission_overrides ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关) staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID(员工删除则个人覆盖配置同步级联删除) permission_def_id UUID NOT NULL REFERENCES permission_defs(id) ON DELETE RESTRICT, -- 关联权限定义 value JSONB NOT NULL, -- 覆盖配置值,格式与 role_permissions.value 一致 override_mode VARCHAR(10) NOT NULL DEFAULT 'REPLACE' CHECK (override_mode IN ('REPLACE','RESTRICT','GRANT')), -- 覆盖模式:REPLACE=完全替换角色权限 / RESTRICT=向下收紧 / GRANT=向上提升 reason TEXT NOT NULL DEFAULT '', -- 覆盖理由(操作审计留存) modified_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 修改操作人(关联 staff 表) modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 修改时间(系统自动) ); CREATE UNIQUE INDEX idx_staff_overrides_uniq ON staff_permission_overrides(staff_id, permission_def_id); CREATE INDEX idx_staff_overrides_staff ON staff_permission_overrides(staff_id); -- staff_data_scopes CREATE TABLE staff_data_scopes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 主键(系统生成,业务无关) staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, -- 员工 ID scope_type VARCHAR(20) NOT NULL -- 数据范围类型:self=本人 / group=小组 / store=门店 / area=大区 / region=区域 / company=全公司 / custom_unit=自定义单元 CHECK (scope_type IN ('self','group','store','area','region','company','custom_unit')), org_unit_id UUID REFERENCES org_units(id) ON DELETE RESTRICT, -- 自定义组织单元;scope_type=custom_unit 时必填,其他为 NULL is_readable BOOLEAN NOT NULL DEFAULT TRUE, -- 是否有读权限 is_writable BOOLEAN NOT NULL DEFAULT FALSE, -- 是否有写权限 granted_by UUID REFERENCES staff(id) ON DELETE SET NULL, -- 授权操作人(关联 staff 表) granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 授权时间(系统自动) expires_at TIMESTAMPTZ, -- 到期时间;NULL=永久有效 reason TEXT NOT NULL DEFAULT '', -- 数据范围授权理由(操作审计留存) CONSTRAINT chk_custom_unit_has_org CHECK ( (scope_type = 'custom_unit' AND org_unit_id IS NOT NULL) OR (scope_type <> 'custom_unit') ) ); CREATE INDEX idx_data_scopes_staff ON staff_data_scopes(staff_id); CREATE INDEX idx_data_scopes_org ON staff_data_scopes(org_unit_id); CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expires_at IS NOT NULL; -- permission_change_logs (append-only, no deleted_at) CREATE TABLE permission_change_logs ( id UUID NOT NULL DEFAULT gen_random_uuid(), -- 主键(与 operated_at 组成复合主键,分区表要求) operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键(原 operated_at 前置) target_type VARCHAR(30) NOT NULL -- 操作对象类型:role / role_permission / staff_role / staff_override / staff_scope CHECK (target_type IN ('role','role_permission','staff_role','staff_override','staff_scope')), target_id UUID NOT NULL, -- 操作对象 ID staff_id UUID REFERENCES staff(id) ON DELETE SET NULL, -- 被操作员工 ID(如分配/撤销角色时的目标员工) role_id UUID REFERENCES roles(id) ON DELETE SET NULL, -- 被操作角色 ID permission_code VARCHAR(150), -- 操作涉及的权限码(冗余存储,避免关联查询) action VARCHAR(20) NOT NULL -- 操作类型:create=新建 / update=修改 / delete=删除 / assign=分配 / revoke=撤销 CHECK (action IN ('create','update','delete','assign','revoke')), old_value JSONB, -- 变更前值;create 时为 NULL new_value JSONB, -- 变更后值;delete 时为 NULL operator_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT, -- 操作人员工 ID(RESTRICT:操作记录保留,操作人不可删除) operator_ip INET, -- 操作人来源 IP user_agent TEXT, -- 操作人客户端 UA reason TEXT NOT NULL DEFAULT '', -- 操作理由(可选,审计留存) PRIMARY KEY (id, operated_at) -- 分区表主键必须包含分区键 ) PARTITION BY RANGE (operated_at); CREATE TABLE permission_change_logs_2026_04 PARTITION OF permission_change_logs FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); CREATE TABLE permission_change_logs_2026_05 PARTITION OF permission_change_logs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); CREATE TABLE permission_change_logs_default PARTITION OF permission_change_logs DEFAULT; CREATE INDEX idx_perm_log_staff ON permission_change_logs(staff_id, operated_at DESC) WHERE staff_id IS NOT NULL; CREATE INDEX idx_perm_log_role ON permission_change_logs(role_id, operated_at DESC) WHERE role_id IS NOT NULL; CREATE INDEX idx_perm_log_target ON permission_change_logs(target_type, target_id, operated_at DESC); CREATE INDEX idx_perm_log_operator ON permission_change_logs(operator_id, operated_at DESC); CREATE INDEX idx_perm_log_time ON permission_change_logs(operated_at DESC); -- 拒绝 UPDATE/DELETE 的 rule(兜底,应用层已禁止) CREATE RULE no_update_perm_log AS ON UPDATE TO permission_change_logs DO INSTEAD NOTHING; CREATE RULE no_delete_perm_log AS ON DELETE TO permission_change_logs DO INSTEAD NOTHING; ``` ### C.2 Migration 0002 — Seed PermissionDef(data migration) ```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_