Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_PERMISSION.md
2026-04-29 15:43:49 +08:00

58 KiB
Raw Blame History

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=TRUEPermissionChecker 全部返回 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 — 权限定义表(权限目录)

归属 schematenant_apps(非 shared每个租户独立一份初始化数据。 理由:避免 django-tenants 跨 schema 外键限制;为未来"企业版自定义权限项"留空间;通过 data migration 在租户创建时批量 seed 约 300 条默认记录。

字段 类型 约束 业务说明
id UUID PK
code VARCHAR(150) UNIQUE, NOT NULL 权限编码,规则:{module}.{sub_module}.{action}[.{qualifier}],如 client.private.view.scope
module VARCHAR(50) NOT NULL 一级模块枚举(见 §4.1
sub_module VARCHAR(50) 二级模块(如 二手&租赁商圈精耕
group_name VARCHAR(100) NOT NULL 分组标题(如「私客基础权限」「联系人基础权限」)
name VARCHAR(200) NOT NULL 显示名称
description TEXT 权限作用描述PRD 5.4.3 每项均有)
value_type VARCHAR(20) NOT NULL, CHECK BOOLEAN / SCOPE / INTEGER(见 §4.2
scope_choices JSONB DEFAULT '[]' SCOPE 类型有效,可选枚举值的 code 列表,如 ["none","self","store","company"]
integer_min INTEGER INTEGER 有效,最小值
integer_max INTEGER INTEGER 有效,最大值;NULL=无上限业务语义0 通常代表"不限制"
default_value JSONB NOT NULL DEFAULT '{"v":false}' 系统最小默认值
max_allowed_categories VARCHAR(50)[] DEFAULT '{}' 允许配置此权限的角色类别列表,空数组=所有类别均可(对应 Issue #11
sort_order INTEGER NOT NULL DEFAULT 0 分组内排序
is_active BOOLEAN NOT NULL DEFAULT TRUE 下线权限项置 FALSE历史记录保留
is_deprecated BOOLEAN NOT NULL DEFAULT FALSE 废弃标记(不再推荐使用但保持兼容)
version INTEGER NOT NULL DEFAULT 1 权限项定义版本,变更时递增,用于缓存失效
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

关键索引

CREATE UNIQUE INDEX idx_permission_defs_code ON permission_defs(code);
CREATE INDEX idx_permission_defs_module ON permission_defs(module, sub_module, sort_order) WHERE is_active = TRUE;
CREATE INDEX idx_permission_defs_active ON permission_defs(is_active) WHERE is_active = TRUE;

校验规则Django model clean()

  • code 必须匹配 ^[a-z_]+\.[a-z_]+(\.[a-z_]+){1,2}$
  • value_type='SCOPE'scope_choices 非空
  • default_value 必须包含 "v" 键且值与 value_type 兼容

3.2 roles — 角色表

字段 类型 约束 业务说明
id UUID PK
name VARCHAR(100) NOT NULL 角色名称
category VARCHAR(30) NOT NULL, CHECK agent(置业顾问) / store_manager(店管) / director(总经) / operator(运营) / custom(见 §4.3
description TEXT 角色描述
template_role_id UUID FK→roles, SET NULL 权限模板来源角色PRD「引用该角色配置」列
is_system_builtin BOOLEAN NOT NULL DEFAULT FALSE 系统内置角色(如"最大权限角色"),不可删除、不可改名
is_active BOOLEAN NOT NULL DEFAULT TRUE
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 软删除

关键索引

-- 软删除友好的唯一索引:同名角色只要有一个未删除即冲突
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 层 PROTECTApplication 层给出"请先迁移 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
value JSONB NOT NULL 统一格式 {"v": <value>},如 {"v": true} / {"v": "store"} / {"v": 50}
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_by UUID FK→staff, SET NULL

关键索引

CREATE UNIQUE INDEX idx_role_permissions_uniq ON role_permissions(role_id, permission_def_id);
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id);
CREATE INDEX idx_role_permissions_def ON role_permissions(permission_def_id);

稀疏存储原则只存储非 default_value 的项。保存时若 value == permission_def.default_value,执行 DELETE 而非 UPSERT。优化存储与合并性能。


3.4 staff_roles — 员工角色关联

字段 类型 约束 业务说明
id UUID PK
staff_id UUID NOT NULL, FK→staff, CASCADE
role_id UUID NOT NULL, FK→roles, PROTECT 角色被员工引用时禁止删除
is_primary BOOLEAN NOT NULL DEFAULT FALSE 主角色标识每个员工有且仅有一个主角色Issue #5
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
assigned_by UUID FK→staff, SET NULL
valid_from DATE 生效日(预留未来"定时生效"功能)
valid_until DATE 失效日

关键索引

CREATE UNIQUE INDEX idx_staff_roles_uniq ON staff_roles(staff_id, role_id);
CREATE UNIQUE INDEX idx_staff_roles_primary ON staff_roles(staff_id) WHERE is_primary = TRUE;
CREATE INDEX idx_staff_roles_role ON staff_roles(role_id);

约束触发器:每次 UPDATE is_primary=TRUE 时,自动将该 staff 其他行 is_primary=FALSEDjango signal 实现,避免 DB 级递归触发器)


3.5 staff_permission_overrides — 个人权限覆盖

字段 类型 约束 业务说明
id UUID PK
staff_id UUID NOT NULL, FK→staff, CASCADE
permission_def_id UUID NOT NULL, FK→permission_defs, RESTRICT
value JSONB NOT NULL 个人权限值,同 {"v": ...} 格式
override_mode VARCHAR(10) NOT NULL DEFAULT 'REPLACE' REPLACE(覆盖角色合并值PRD 默认) / RESTRICT(限制上限) / GRANT(仅扩展) — Issue #6
reason TEXT 管理员备注(建议强制,为后续审计)
modified_by UUID FK→staff, SET NULL
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

关键索引

CREATE UNIQUE INDEX idx_staff_overrides_uniq ON staff_permission_overrides(staff_id, permission_def_id);
CREATE INDEX idx_staff_overrides_staff ON staff_permission_overrides(staff_id);

稀疏存储只存与角色合并值不同的项。批量设置角色PRD Story 2会清除员工所有 Override个人编辑Story 3按需 UPSERT/DELETE。


3.6 staff_data_scopes — 员工数据管理范围( 新增Issue #1

PRD 5.6「管理范围」的实体化。与权限项的 SCOPE 值互为补充:权限项决定"访问什么数据"DataScope 决定"数据的组织边界"。

字段 类型 约束 业务说明
id UUID PK
staff_id UUID NOT NULL, FK→staff, CASCADE
scope_type VARCHAR(20) NOT NULL, CHECK self / group / store / area / region / company / custom_unit
org_unit_id UUID FK→org_units, RESTRICT scope_type='custom_unit' 时必填,指向具体的组织节点;其他类型时 NULLstaff.org_unit_id 动态推导)
is_readable BOOLEAN NOT NULL DEFAULT TRUE 可读
is_writable BOOLEAN NOT NULL DEFAULT FALSE 可写(默认只读)
granted_by UUID FK→staff, SET NULL
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_at TIMESTAMPTZ 临时授权失效时间
reason TEXT 授予原因

关键索引

CREATE INDEX idx_data_scopes_staff ON staff_data_scopes(staff_id);
CREATE INDEX idx_data_scopes_org ON staff_data_scopes(org_unit_id);
CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expires_at IS NOT NULL;

业务语义

  • 员工的「管理范围」= 所有 staff_data_scopes 记录对应的 org_units.path 子树并集
  • 支持"扩充范围"PRD 5.3.3):新增一条 scope_type='custom_unit', org_unit_id=门店B 记录
  • 保底规则:员工至少有一条 scope_type='self' 的记录(入职时自动创建)

3.7 permission_change_logs — 权限变更流水( 新增Issue #4

append-only不可删除,对齐 staff_transfer_logs 范式。

字段 类型 约束 业务说明
id UUID PK
target_type VARCHAR(30) NOT NULL, CHECK role / role_permission / staff_role / staff_override / staff_scope
target_id UUID NOT NULL 被变更对象的 ID上述各表的主键
staff_id UUID FK→staff, SET NULL 被影响员工target 是 staff_role/staff_override/staff_scope 时必填,便于按员工查询)
role_id UUID FK→roles, SET NULL 被影响角色(便于按角色查询)
permission_code VARCHAR(150) 被变更的权限 code用 code 而非 FK避免 PermissionDef 删除后日志丢失)
action VARCHAR(20) NOT NULL, CHECK create / update / delete / assign / revoke
old_value JSONB 变更前快照
new_value JSONB 变更后快照
operator_id UUID NOT NULL, FK→staff, RESTRICT 操作人
operator_ip INET 操作来源 IP
user_agent TEXT 操作终端 UA
reason TEXT 操作原因(批量设置角色等场景强制填写)
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
⚠️ 无 deleted_at 审计日志不可删除

关键索引

CREATE INDEX idx_perm_log_staff ON permission_change_logs(staff_id, operated_at DESC) WHERE staff_id IS NOT NULL;
CREATE INDEX idx_perm_log_role ON permission_change_logs(role_id, operated_at DESC) WHERE role_id IS NOT NULL;
CREATE INDEX idx_perm_log_target ON permission_change_logs(target_type, target_id, operated_at DESC);
CREATE INDEX idx_perm_log_operator ON permission_change_logs(operator_id, operated_at DESC);
CREATE INDEX idx_perm_log_time ON permission_change_logs(operated_at DESC);

分区建议:数据量大(每次批量角色变更产生 N 条记录)时按 operated_at 月度 RANGE 分区。


四、枚举常量

4.1 PermissionModule — 一级模块(与 PRD 8.2 导航完全对齐)

home          = 首页
property      = 房源
new_house     = 新房
client        = 客源
transaction   = 交易
data          = 数据
marketing     = 营销
hr            = 人事OA
contract      = 合同
trinet        = 三网
system        = 系统
mobile        = 移动端
smart_store   = 智能门店
recharge      = 在线充值

4.2 ValueType — 权限值类型

存储格式 UI 控件 示例
BOOLEAN {"v": true} Toggle 今日新上房源是否显示
SCOPE {"v": "store"} 下拉选择 查看私客范围(本人/本组/本门店/本区域/全公司)
INTEGER {"v": 50} 数字输入 每日最多查看联系人数0=不限制)

4.3 RoleCategory — 角色类别

agent         = 置业顾问(一线经纪人)
store_manager = 店管(店长/区域经理)
director      = 总经(公司级管理)
operator      = 运营/行政
custom        = 自定义(不继承预设上限)

4.4 ScopeLevel — SCOPE 枚举有序值(用于多角色合并)

none    = 0   无
self    = 1   本人
group   = 2   本组
store   = 3   本门店
area    = 4   本区域
region  = 5   本大区
company = 6   全公司

五、权限解析与查询模式

5.1 员工权限解析流程resolver

输入: staff_id
Step 1: 若 staff.is_system_admin = TRUE → 返回 "全部权限=true全部 SCOPE=company"(短路)
Step 2: 加载所有 staff_roles → role_ids
Step 3: 加载所有 role_permissions (role_id IN role_ids) 按 permission_def 分组
Step 4: 合并多角色值:
   BOOLEAN → OR
   SCOPE   → MAX by ScopeLevel
   INTEGER → MAX (0 视为 +∞)
Step 5: 填入未配置权限项的 default_value
Step 6: 叠加 staff_permission_overrides按 override_mode 规则):
   REPLACE  → 直接替换合并值
   RESTRICT → min(override_value, merged_value)
   GRANT    → max(override_value, merged_value)
输出: { permission_code: {"v": value}, ... }
缓存: perm:v{CACHE_VERSION}:{schema}:{staff_id} (TTL=3600)

5.2 数据范围查询ScopeQueryBuilder

核心工具类,职责:将"SCOPE 权限值 + StaffDataScope"转为 Django ORM Q 对象。

# apps/permissions/services/scope_query.py

from django.db.models import Q

class ScopeQueryBuilder:
    """
    用法:
        qs = Property.objects.all()
        qs = ScopeQueryBuilder(staff, "property.list.view.scope", field_prefix="seller_agent")\
             .apply_to(qs)
    """
    def __init__(self, staff, permission_code: str, field_prefix: str):
        self.staff = staff
        self.perm_code = permission_code
        self.field_prefix = field_prefix  # e.g. "seller_agent" or "owner"

    def build_q(self) -> Q:
        # 1. 系统管理员短路
        if self.staff.is_system_admin:
            return Q()  # no filter

        # 2. 读取权限值
        checker = PermissionChecker(self.staff)
        scope = checker.get_scope(self.perm_code)  # e.g. "store"

        # 3. 构建 base Q基于 SCOPE 值 + staff.org_unit.path 子树)
        base_q = self._scope_to_q(scope)

        # 4. 叠加 StaffDataScope 并集
        for ds in StaffDataScope.objects.filter(staff=self.staff, is_readable=True):
            base_q |= self._datascope_to_q(ds)

        return base_q

    def _scope_to_q(self, scope: str) -> Q:
        """将 SCOPE 枚举转为 Q基于当前 staff 的 org_unit.path"""
        prefix = self.field_prefix
        if scope == "none":
            return Q(pk__in=[])  # 永远为空
        if scope == "self":
            return Q(**{f"{prefix}_id": self.staff.id})
        if scope == "group":
            return Q(**{f"{prefix}__org_unit__path__startswith": self.staff.org_unit.path})
        if scope in ("store", "area", "region"):
            # 向上定位到对应层级节点
            target_unit = self.staff.org_unit.ancestor_of_type(scope)  # "store" → Store 节点
            return Q(**{f"{prefix}__org_unit__path__startswith": target_unit.path})
        if scope == "company":
            return Q()
        return Q(pk__in=[])

    def apply_to(self, queryset):
        q = self.build_q()
        # 业务规则叠加:保护客过滤(客源场景)
        if hasattr(queryset.model, "is_protected"):
            q &= (Q(is_protected=False) | Q(owner_id=self.staff.id))
        return queryset.filter(q)

5.3 「权限与角色不一致」标记

-- 批量标记逻辑SQL 层,用于人员列表高频查询)
-- 某员工若存在任一 override 行,其 value != (其角色合并值),则标记不一致
SELECT DISTINCT o.staff_id
FROM staff_permission_overrides o
JOIN permission_defs pd ON pd.id = o.permission_def_id
WHERE NOT EXISTS (
    -- 简化示例:实际需在应用层对比合并结果
    SELECT 1 FROM role_permissions rp
    JOIN staff_roles sr ON sr.role_id = rp.role_id
    WHERE sr.staff_id = o.staff_id
      AND rp.permission_def_id = o.permission_def_id
      AND rp.value = o.value
);

实现建议:查询时应用层计算,结果缓存到 perm:inconsistent:{schema}:{staff_id} (TTL=300),变更时失效。

5.4 查询某角色的应用人数PRD 5.5.1

SELECT COUNT(DISTINCT staff_id) AS applied_count
FROM staff_roles
WHERE role_id = :role_id
  AND (valid_until IS NULL OR valid_until >= CURRENT_DATE);

5.5 按模块懒加载权限面板PRD 性能考量)

-- 一次只加载单模块的权限定义与当前角色/员工的值
SELECT pd.*, rp.value AS role_value, spo.value AS override_value
FROM permission_defs pd
LEFT JOIN role_permissions rp
       ON rp.permission_def_id = pd.id AND rp.role_id = :role_id
LEFT JOIN staff_permission_overrides spo
       ON spo.permission_def_id = pd.id AND spo.staff_id = :staff_id
WHERE pd.module = :module AND pd.is_active = TRUE
ORDER BY pd.sub_module, pd.group_name, pd.sort_order;

六、Redis 缓存策略

Cache Key TTL 失效触发
perm:v{VER}:{schema}:{staff_id} 3600s invalidate_staff_cache()Override/StaffRole 变更
perm:v{VER}:{schema}:role:{role_id}:staff_ids 3600s 角色权限变更时用 Pipeline 批量失效该 role 下所有 staff 缓存Issue #8
perm:inconsistent:{schema}:{staff_id} 300s 同上
perm:defs:{schema} 86400s PermissionDef 变更(低频)
perm:role_applied_count:{schema}:{role_id} 600s StaffRole 变更

版本号机制Issue #7CACHE_VERSION 在 Django settings 中,升级 PermissionDef 结构时 bump一键全局失效。

批量失效优化

def invalidate_role_cache(role_id: int, tenant_schema: str):
    staff_ids = StaffRole.objects.filter(role_id=role_id).values_list("staff_id", flat=True)
    pipe = _redis.pipeline()
    for sid in staff_ids:
        pipe.delete(f"perm:v{CACHE_VERSION}:{tenant_schema}:{sid}")
        pipe.delete(f"perm:inconsistent:{tenant_schema}:{sid}")
    pipe.execute()  # 单次往返

七、Django Model 要点

# apps/permissions/models.py (精简示例,完整代码见 TECH_STACK/权限管理系统技术方案.md)

from django.db import models
from django.contrib.postgres.fields import ArrayField
from apps.core.models.base import TimeStampedModel, SoftDeleteModel

class ValueType(models.TextChoices):
    BOOLEAN = "BOOLEAN", "开关型"
    SCOPE   = "SCOPE", "范围型"
    INTEGER = "INTEGER", "数值型"

class ScopeLevel(models.IntegerChoices):
    NONE    = 0, "无"
    SELF    = 1, "本人"
    GROUP   = 2, "本组"
    STORE   = 3, "本门店"
    AREA    = 4, "本区域"
    REGION  = 5, "本大区"
    COMPANY = 6, "全公司"

class PermissionDef(TimeStampedModel):
    code = models.CharField(max_length=150, unique=True)
    module = models.CharField(max_length=50)
    sub_module = models.CharField(max_length=50, blank=True)
    group_name = models.CharField(max_length=100)
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    value_type = models.CharField(max_length=20, choices=ValueType.choices)
    scope_choices = models.JSONField(default=list, blank=True)
    integer_min = models.IntegerField(null=True, blank=True)
    integer_max = models.IntegerField(null=True, blank=True)
    default_value = models.JSONField(default=dict)
    max_allowed_categories = ArrayField(models.CharField(max_length=50), default=list, blank=True)
    sort_order = models.PositiveIntegerField(default=0)
    is_active = models.BooleanField(default=True)
    is_deprecated = models.BooleanField(default=False)
    version = models.IntegerField(default=1)

    class Meta:
        db_table = "permission_defs"
        indexes = [
            models.Index(fields=["module", "sub_module", "sort_order"]),
        ]

# ... Role / RolePermission / StaffRole / StaffPermissionOverride / StaffDataScope
# ... PermissionChangeLogappend-onlyoverride save() 禁止 update/delete

八、关键业务规则与约束

8.1 批量设置角色PRD Story 2

def bulk_assign_role(staff_ids: list, role_id: UUID, operator, override_individual=False):
    """
    override_individual=True 时清除员工所有个人 OverridePRD 提示"将覆盖个人自定义权限"
    """
    with transaction.atomic():
        # 1. 清除旧主角色
        StaffRole.objects.filter(staff_id__in=staff_ids, is_primary=True).update(is_primary=False)
        # 2. 批量 UPSERT 新主角色
        for sid in staff_ids:
            sr, _ = StaffRole.objects.update_or_create(
                staff_id=sid, role_id=role_id,
                defaults={"is_primary": True, "assigned_by": operator}
            )
            PermissionChangeLog.objects.create(
                target_type="staff_role", target_id=sr.id,
                staff_id=sid, role_id=role_id, action="assign",
                operator_id=operator.id
            )
        # 3. 可选清除个人 Override
        if override_individual:
            StaffPermissionOverride.objects.filter(staff_id__in=staff_ids).delete()
        # 4. 批量失效缓存
        invalidate_staff_cache_batch(staff_ids, tenant_schema)

8.2 角色删除前置校验

def delete_role(role: Role, operator):
    applied_count = StaffRole.objects.filter(role=role).count()
    if applied_count > 0:
        raise ValidationError(f"无法删除:仍有 {applied_count} 位员工使用此角色,请先迁移")
    if role.is_system_builtin:
        raise ValidationError("系统内置角色不可删除")
    role.deleted_at = timezone.now()
    role.save()
    PermissionChangeLog.objects.create(
        target_type="role", target_id=role.id, role_id=role.id,
        action="delete", operator_id=operator.id,
        old_value={"name": role.name, "category": role.category}
    )

8.3 角色类别与权限项的可配置约束Issue #11

def get_editable_permissions_for_role(role: Role):
    """编辑角色权限时,只返回该角色类别允许配置的权限项"""
    return PermissionDef.objects.filter(
        is_active=True
    ).filter(
        # max_allowed_categories 为空 或 包含当前角色类别
        models.Q(max_allowed_categories=[]) |
        models.Q(max_allowed_categories__contains=[role.category])
    )

九、与现有 DATA_MODEL 的集成契约

接入点 契约
Staff 新增 method get_permission_checker() 返回 PermissionChecker 实例
OrgUnit 新增 method ancestor_of_type(type: str) 返回指定类型的祖先节点(供 SCOPE → store/area 定位)
Property.objects Manager 新增 visible_to(staff) 方法,内部用 ScopeQueryBuilder
Client.objects 同上,额外 AND 保护客规则
apps/core/middleware Request 级注入 request.permission_checker,供 View/template 使用

十、禁止操作

  • 严禁直接 INSERT/UPDATE permission_change_logs 跳过业务逻辑 — 所有权限变更必须通过 service 层触发日志写入
  • 严禁 DELETE permission_change_logs — 审计要求不可删除
  • 严禁绕过 ScopeQueryBuilder 在 View 直接构造 Q — 会导致越权漏洞,且无法统一审计
  • 严禁在 Application 层假设 default_value 的具体值 — 所有判断必须经 PermissionChecker
  • 严禁 StaffPermissionOverrideRolePermission 值完全相同的冗余记录 — 保存时必须先做差异对比(稀疏存储)
  • 严禁硬删除 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. HookTenant.create 后钩子自动执行 002/003/004
  6. 信号注册 — Staff 创建时自动 DataScope=selfRole 权限变更 → 缓存失效Override 保存 → 日志
  7. 权限定义版本管理 — 新增 CLIpython manage.py sync_permission_defs --fixture-version=1.2

十三、开放问题与后续迭代

问题 建议方向
权限申请工作流PRD 非目标) v2 引入 permission_requests 表 + 审批流引擎
行级权限(如指定小区的房源) v2 基于 PostgreSQL RLSRow-Level Security或 Guardian
权限变更日志看板PRD 非目标) v1.5 基于 permission_change_logs + Grafana
IP 白名单/登录时段 v2 独立的"安全策略"模块
跨租户权限(加盟商/联合门店) v3需引入联邦身份方案

本文档版本 v1.1 | 作者: Backend Architect | 更新时间 2026-04-24


附录 A — 决策确认记录v1.1 追加)

议题 决策 日期 说明
Issue #3permission_defs 归属 schema tenant_apps(每租户独立) 2026-04-24 避免 django-tenants 跨 schema FK 限制;预留"企业版自定义权限项"能力;通过 Tenant.create 后钩子 seed 初始 300 条
Issue #6staff_permission_overrides.override_mode 默认值 REPLACE 2026-04-24 契合 PRD "个人特殊授权" 语义;管理员若需限制/扩展可显式选择 RESTRICT/GRANT

附录 B — PermissionDef 种子数据样例

完整 fixture约 300 条)待 Django 项目 bootstrap 后落地至 apps/permissions/fixtures/permission_defs.json。 本附录仅给出代表性条目(覆盖 BOOLEAN/SCOPE/INTEGER 三种 value_type + 主要模块)作为结构模板与 code 命名规范参考。

[
  {
    "code": "home.today_new_property.show",
    "module": "home",
    "sub_module": "",
    "group_name": "首页基础权限",
    "name": "今日新上房源是否显示",
    "description": "控制首页小组件「今日新上」房源是否对该用户可见",
    "value_type": "BOOLEAN",
    "default_value": {"v": true},
    "max_allowed_categories": [],
    "sort_order": 10
  },
  {
    "code": "property.list.view.scope",
    "module": "property",
    "sub_module": "二手&租赁",
    "group_name": "房源列表权限",
    "name": "查看房源范围",
    "description": "控制房源列表页可见的数据范围;与保护房业务规则叠加生效",
    "value_type": "SCOPE",
    "scope_choices": ["none", "self", "group", "store", "area", "region", "company"],
    "default_value": {"v": "self"},
    "max_allowed_categories": [],
    "sort_order": 10
  },
  {
    "code": "property.list.export.scope",
    "module": "property",
    "sub_module": "二手&租赁",
    "group_name": "房源列表权限",
    "name": "导出房源范围",
    "value_type": "SCOPE",
    "scope_choices": ["none", "self", "store", "company"],
    "default_value": {"v": "none"},
    "max_allowed_categories": ["store_manager", "director"],
    "sort_order": 20
  },
  {
    "code": "property.owner_phone.view.daily_limit",
    "module": "property",
    "sub_module": "二手&租赁",
    "group_name": "房源隐私权限",
    "name": "每日查看业主电话次数上限",
    "description": "0 表示不限制;达到上限后次日自然恢复",
    "value_type": "INTEGER",
    "integer_min": 0,
    "integer_max": 999,
    "default_value": {"v": 20},
    "max_allowed_categories": [],
    "sort_order": 10
  },
  {
    "code": "client.private.view.scope",
    "module": "client",
    "sub_module": "私客",
    "group_name": "私客基础权限",
    "name": "查看私客范围",
    "description": "与保护客规则叠加:非 owner/合保人即使有 store 权限也不可见保护客",
    "value_type": "SCOPE",
    "scope_choices": ["none", "self", "group", "store", "area", "region", "company"],
    "default_value": {"v": "self"},
    "max_allowed_categories": [],
    "sort_order": 10
  },
  {
    "code": "client.private.transfer.allow",
    "module": "client",
    "sub_module": "私客",
    "group_name": "私客操作权限",
    "name": "是否允许转移私客",
    "value_type": "BOOLEAN",
    "default_value": {"v": false},
    "max_allowed_categories": ["store_manager", "director"],
    "sort_order": 20
  },
  {
    "code": "transaction.contract.approve.allow",
    "module": "transaction",
    "sub_module": "合同",
    "group_name": "合同审批权限",
    "name": "是否可审批合同",
    "value_type": "BOOLEAN",
    "default_value": {"v": false},
    "max_allowed_categories": ["store_manager", "director"],
    "sort_order": 10
  },
  {
    "code": "data.report.performance.view.scope",
    "module": "data",
    "sub_module": "业绩报表",
    "group_name": "业绩数据权限",
    "name": "业绩报表可见范围",
    "value_type": "SCOPE",
    "scope_choices": ["none", "self", "group", "store", "area", "company"],
    "default_value": {"v": "self"},
    "max_allowed_categories": [],
    "sort_order": 10
  },
  {
    "code": "hr.staff.create.allow",
    "module": "hr",
    "sub_module": "员工管理",
    "group_name": "员工操作权限",
    "name": "是否可新建员工",
    "value_type": "BOOLEAN",
    "default_value": {"v": false},
    "max_allowed_categories": ["store_manager", "director", "operator"],
    "sort_order": 10
  },
  {
    "code": "system.role.manage.allow",
    "module": "system",
    "sub_module": "权限管理",
    "group_name": "角色管理权限",
    "name": "是否可管理角色与权限",
    "description": "授予此权限者可进入角色管理页;系统管理员 is_system_admin=TRUE 自动拥有",
    "value_type": "BOOLEAN",
    "default_value": {"v": false},
    "max_allowed_categories": ["director", "operator"],
    "sort_order": 10
  }
]

Seed 覆盖清单Django 项目落地时需补齐):

模块 预计条目数 典型权限项
home ~15 小组件显示/隐藏开关
property ~60 列表/详情/导出/隐私/分享/操作 × SCOPE/BOOLEAN
new_house ~30 楼盘/房型/佣金/报备
client ~50 私客/公客/保护客 × 查看/转移/释放/导出
transaction ~40 合同/审批/佣金/收付款
data ~30 业绩/排行/漏斗/业务报表
marketing ~20 营销活动/素材/投放
hr ~25 员工/组织/考勤/调整
contract/trinet/system/mobile/smart_store/recharge ~30 模块特定
合计 ~300

附录 C — Migration DDL 草图

以下为 Django migration 的 等价 DDL,用于评审与 DBA 沟通。实际实现以 apps/permissions/migrations/000X_*.py 为准。

C.1 Migration 0001 — 核心 7 张表

-- permission_defs
CREATE TABLE permission_defs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    code VARCHAR(150) NOT NULL UNIQUE,
    module 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 PermissionDefdata migration

# 伪代码
def forwards(apps, schema_editor):
    PermissionDef = apps.get_model("permissions", "PermissionDef")
    data = json.load(open("apps/permissions/fixtures/permission_defs.json"))
    PermissionDef.objects.bulk_create([PermissionDef(**row) for row in data])

C.3 Migration 0003 — Seed 系统内置角色

def forwards(apps, schema_editor):
    Role = apps.get_model("permissions", "Role")
    RolePermission = apps.get_model("permissions", "RolePermission")
    PermissionDef = apps.get_model("permissions", "PermissionDef")

    super_role = Role.objects.create(
        name="最大权限角色",
        category="director",
        is_system_builtin=True,
        description="系统内置,拥有所有权限项的最高值"
    )
    # 将所有 SCOPE 权限设为 company所有 BOOLEAN 设为 true所有 INTEGER 设为 0(不限)
    rows = []
    for pd in PermissionDef.objects.filter(is_active=True):
        if pd.value_type == "BOOLEAN":
            v = {"v": True}
        elif pd.value_type == "SCOPE":
            v = {"v": "company"}
        else:
            v = {"v": 0}
        if v == pd.default_value:
            continue  # 稀疏存储
        rows.append(RolePermission(role=super_role, permission_def=pd, value=v))
    RolePermission.objects.bulk_create(rows)

C.4 Migration 0004 — 为现有员工创建默认 DataScope(self)

def forwards(apps, schema_editor):
    Staff = apps.get_model("org", "Staff")
    StaffDataScope = apps.get_model("permissions", "StaffDataScope")
    rows = [
        StaffDataScope(staff_id=sid, scope_type="self", is_readable=True, is_writable=True, reason="初始化")
        for sid in Staff.objects.values_list("id", flat=True)
    ]
    StaffDataScope.objects.bulk_create(rows, ignore_conflicts=True)

C.5 Tenant.create 后钩子

# apps/tenants/signals.py
from django_tenants.utils import tenant_context

@receiver(post_save, sender=Tenant)
def init_tenant_permissions(sender, instance, created, **kwargs):
    if not created:
        return
    with tenant_context(instance):
        call_command("migrate", "permissions")  # 0001
        call_command("loaddata", "permission_defs")  # 等价于 0002
        seed_builtin_roles()  # 0003 逻辑
        seed_default_data_scopes()  # 0004 逻辑

附录 D — PermissionChecker 伪代码

# apps/permissions/services/checker.py

from dataclasses import dataclass
from django.core.cache import cache
from django.conf import settings

CACHE_VERSION = getattr(settings, "PERMISSION_CACHE_VERSION", 1)

@dataclass
class ResolvedValue:
    code: str
    value_type: str          # BOOLEAN / SCOPE / INTEGER
    value: object            # bool / str / int
    source: str              # "admin_shortcircuit" / "override" / "role_merge" / "default"

class PermissionChecker:
    def __init__(self, staff):
        self.staff = staff
        self._resolved: dict[str, ResolvedValue] | None = None

    # ---------- 公共 API ----------
    def has(self, code: str) -> bool:
        r = self._get(code)
        if r.value_type == "BOOLEAN":
            return bool(r.value)
        if r.value_type == "SCOPE":
            return r.value != "none"
        if r.value_type == "INTEGER":
            return r.value != 0 and r.value is not None
        return False

    def get_scope(self, code: str) -> str:
        r = self._get(code)
        assert r.value_type == "SCOPE"
        return r.value

    def get_int(self, code: str) -> int:
        r = self._get(code)
        assert r.value_type == "INTEGER"
        return r.value

    # ---------- 解析核心 ----------
    def _get(self, code: str) -> ResolvedValue:
        if self._resolved is None:
            self._resolved = self._resolve_all()
        if code not in self._resolved:
            raise KeyError(f"Unknown permission code: {code}")
        return self._resolved[code]

    def _resolve_all(self) -> dict[str, ResolvedValue]:
        # 1) 系统管理员短路
        if self.staff.is_system_admin:
            return self._build_admin_values()

        # 2) Redis cache
        schema = connection.schema_name
        key = f"perm:v{CACHE_VERSION}:{schema}:{self.staff.id}"
        cached = cache.get(key)
        if cached:
            return cached

        # 3) 加载所有 PermissionDef
        defs = {pd.code: pd for pd in PermissionDef.objects.filter(is_active=True)}

        # 4) 加载员工所有角色权限值
        role_ids = list(StaffRole.objects.filter(staff=self.staff).values_list("role_id", flat=True))
        role_values: dict[str, list] = {}  # code -> [value, value, ...]
        qs = RolePermission.objects.filter(role_id__in=role_ids).select_related("permission_def")
        for rp in qs:
            role_values.setdefault(rp.permission_def.code, []).append(rp.value["v"])

        # 5) 合并多角色值
        resolved: dict[str, ResolvedValue] = {}
        for code, pd in defs.items():
            values = role_values.get(code, [])
            if not values:
                merged = pd.default_value["v"]
                source = "default"
            else:
                merged = self._merge(pd.value_type, values)
                source = "role_merge"
            resolved[code] = ResolvedValue(code, pd.value_type, merged, source)

        # 6) 叠加 Override
        overrides = StaffPermissionOverride.objects.filter(staff=self.staff).select_related("permission_def")
        for o in overrides:
            code = o.permission_def.code
            current = resolved[code]
            new_val = self._apply_override(current, o.value["v"], o.override_mode)
            resolved[code] = ResolvedValue(code, current.value_type, new_val, "override")

        # 7) 缓存
        cache.set(key, resolved, timeout=3600)
        return resolved

    @staticmethod
    def _merge(value_type: str, values: list):
        if value_type == "BOOLEAN":
            return any(values)
        if value_type == "SCOPE":
            return max(values, key=lambda s: SCOPE_LEVEL[s])
        if value_type == "INTEGER":
            # 0 视为 +∞
            if 0 in values:
                return 0
            return max(values)

    @staticmethod
    def _apply_override(current: ResolvedValue, ov: object, mode: str):
        if mode == "REPLACE":
            return ov
        if mode == "RESTRICT":
            if current.value_type == "BOOLEAN":
                return current.value and ov
            if current.value_type == "SCOPE":
                return min([current.value, ov], key=lambda s: SCOPE_LEVEL[s])
            if current.value_type == "INTEGER":
                # 0=+∞RESTRICT 取最小正值
                a, b = current.value, ov
                if a == 0: return b
                if b == 0: return a
                return min(a, b)
        if mode == "GRANT":
            if current.value_type == "BOOLEAN":
                return current.value or ov
            if current.value_type == "SCOPE":
                return max([current.value, ov], key=lambda s: SCOPE_LEVEL[s])
            if current.value_type == "INTEGER":
                if 0 in (current.value, ov): return 0
                return max(current.value, ov)

    def _build_admin_values(self) -> dict[str, ResolvedValue]:
        defs = PermissionDef.objects.filter(is_active=True)
        out = {}
        for pd in defs:
            if pd.value_type == "BOOLEAN":
                v = True
            elif pd.value_type == "SCOPE":
                v = "company"
            else:
                v = 0
            out[pd.code] = ResolvedValue(pd.code, pd.value_type, v, "admin_shortcircuit")
        return out

附录 E — 缓存失效服务

# apps/permissions/services/cache.py

from django.db import connection
from django.core.cache import cache
from django.conf import settings

CACHE_VERSION = getattr(settings, "PERMISSION_CACHE_VERSION", 1)

def _schema() -> str:
    return connection.schema_name

def invalidate_staff(staff_id) -> None:
    s = _schema()
    cache.delete_many([
        f"perm:v{CACHE_VERSION}:{s}:{staff_id}",
        f"perm:inconsistent:{s}:{staff_id}",
    ])

def invalidate_staff_batch(staff_ids) -> None:
    s = _schema()
    keys = []
    for sid in staff_ids:
        keys.append(f"perm:v{CACHE_VERSION}:{s}:{sid}")
        keys.append(f"perm:inconsistent:{s}:{sid}")
    cache.delete_many(keys)

def invalidate_role(role_id) -> None:
    """角色权限变更 → 该角色下所有员工缓存失效"""
    from apps.permissions.models import StaffRole
    staff_ids = list(
        StaffRole.objects.filter(role_id=role_id).values_list("staff_id", flat=True)
    )
    invalidate_staff_batch(staff_ids)
    s = _schema()
    cache.delete(f"perm:v{CACHE_VERSION}:{s}:role:{role_id}:staff_ids")
    cache.delete(f"perm:role_applied_count:{s}:{role_id}")

def invalidate_all_tenant() -> None:
    """极端情况PermissionDef 批量变更)— 走版本号 bump 更高效"""
    cache.delete(f"perm:defs:{_schema()}")
    # 不推荐遍历删除;生产上应 bump settings.PERMISSION_CACHE_VERSION

信号连接(在 apps.py ready() 中注册):

信号 触发失效
post_save / post_delete on RolePermission invalidate_role(instance.role_id)
post_save / post_delete on StaffRole invalidate_staff(instance.staff_id) + 角色应用数缓存
post_save / post_delete on StaffPermissionOverride invalidate_staff(instance.staff_id)
post_save / post_delete on StaffDataScope invalidate_staff(instance.staff_id)
post_save on PermissionDef (version bump) invalidate_all_tenant() + 建议 bump CACHE_VERSION

附录 F — Manager 扩展Property / Client

契合 §九集成契约,提供对外统一的 visible_to(staff) 入口,禁止业务代码直接写过滤 Q

# apps/property/managers.py
from django.db import models
from apps.permissions.services.scope_query import ScopeQueryBuilder

class PropertyQuerySet(models.QuerySet):
    def visible_to(self, staff):
        return ScopeQueryBuilder(
            staff=staff,
            permission_code="property.list.view.scope",
            field_prefix="seller_agent",
        ).apply_to(self)

    def writable_by(self, staff):
        # 编辑权限:基于同一 scope 字段 + property.list.edit.allow BOOLEAN
        checker = staff.get_permission_checker()
        if not checker.has("property.list.edit.allow"):
            return self.none()
        return self.visible_to(staff)

class PropertyManager(models.Manager.from_queryset(PropertyQuerySet)):
    pass


# apps/client/managers.py
class ClientQuerySet(models.QuerySet):
    def visible_to(self, staff):
        # ScopeQueryBuilder.apply_to() 内部已处理 is_protected 过滤
        return ScopeQueryBuilder(
            staff=staff,
            permission_code="client.private.view.scope",
            field_prefix="owner",
        ).apply_to(self)

使用示例

# views.py
def property_list(request):
    qs = Property.objects.visible_to(request.staff)
    # ... 继续其他业务过滤 ...

附录 G — 验收 Checklist开发启动前

  • Issue #3 决策:permission_defs 归属 tenant_apps
  • Issue #6 决策override 默认 REPLACE
  • 完整 permission_defs.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