# Fonrey 权限管理系统技术方案建议 **版本**: 1.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis --- ## 一、选型结论:为什么不用现有库 在回答五个核心需求之前,先明确**不推荐**使用哪些常见方案: |方案|为何不适用| |---|---| |`django.contrib.auth` 原生权限|权限值只有 Boolean,不支持范围型(枚举)和数值型| |`django-guardian`|面向行级(Row-level)权限,是对象权限模型,与本项目需求错位| |`django-rules`|基于函数规则,适合纯 Python 逻辑判断,无法存储枚举/数字值| |Casbin / OPA|重型策略引擎,引入额外运维复杂度,与 `django-tenants` 集成困难| **推荐方案:自定义 RBAC + 个人覆盖层(Hybrid Permission Model)** 这是唯一能同时满足五个需求的路径,实现量适中,完全在 Django ORM 生态内,与 `django-tenants` 天然兼容。 ![[Pasted image 20260424165102.png]] 以下是方案的核心要点汇总: **为什么不用现有库**:Django 原生权限只支持 Boolean,`django-guardian` 是行级权限模型,两者都无法满足你的「范围型枚举 + 数值型」需求,因此推荐完全自定义实现。 **五个需求对应的设计决策**: **① 基于角色分配权限** — 标准 RBAC 三表:`PermissionDef`(权限目录,开发者维护,放 shared schema)→ `Role`(角色模板)→ `RolePermission`(角色 × 权限 → 值) **② 个人权限调整** — 增加 `StaffPermissionOverride` 表,**稀疏覆盖设计**:只存储与角色默认值不同的项,不复制全量权限。解析优先级:个人覆盖 > 角色合并 > 系统默认。 **③ 多种值类型** — 统一用 `JSONField` 存储,格式统一为 `{"v": value}`,`PermissionDef.value_type` 标记类型(`BOOLEAN`/`SCOPE`/`INTEGER`),前端根据类型渲染 Toggle/下拉/数字框。范围型的可选枚举(如某权限只有三档选项)存在 `scope_choices` JSON 数组中。 **④ 多角色叠加规则** — 采用**最宽松原则**(PRD 6.4 倾向确认):`BOOLEAN` 取 `OR`、`SCOPE` 取枚举最大值(本人 < 本组 < 本门店 < 本区域 < 全公司)、`INTEGER` 取最大值(`0=不限制`是最宽松值)。 **⑤ 契合技术栈** — Redis 缓存员工权限快照(变更时主动 `invalidate`);HTMX 按模块懒加载权限面板(避免一次渲染 300+ 条);Alpine.js 管理 Toggle/下拉本地状态;与 `django-tenants` 的集成方式:`PermissionDef` 进 `shared_apps`,其余所有表进 `tenant_apps`。 文档中还包含完整的 Model 代码、权限解析引擎、缓存策略、HTMX 集成示例和「权限与角色不一致」标记逻辑的实现。 --- ## 二、五个需求对应设计 ### 需求 1:基于角色来创建权限并分配(RBAC 基础层) 采用标准 RBAC 三表结构:`PermissionDef`(权限定义)→ `Role`(角色)→ `RolePermission`(角色权限值)。 - `PermissionDef` 是**权限目录**,由开发者维护,存储权限的元信息(名称、所属模块、值类型、可选范围等) - `Role` 是**权限模板**,管理员在 UI 上创建,如"高级业务员"、"分行经理" - `RolePermission` 存储角色对每个权限项的**具体配置值** ### 需求 2:个人用户可在角色权限基础上再进行个性化调整(个人覆盖层) 增加 `StaffPermissionOverride` 表,只存储**与角色默认值不同的权限项**(稀疏覆盖,不复制全量)。 **解析优先级**:个人覆盖值 > 角色合并值 > 系统默认值 ### 需求 3:权限值涉及多种数据类型(多态值存储) 使用 `JSONField` 统一存储权限值,通过 `PermissionDef.value_type` 字段标记类型,前端根据类型渲染不同控件: |`value_type`|存储格式|UI 控件|示例| |---|---|---|---| |`BOOLEAN`|`{"v": true}`|Toggle|是否显示今日新上房源| |`SCOPE`|`{"v": "store"}`|下拉选择|查看私客范围(本人/本组/本门店/...)| |`INTEGER`|`{"v": 50}`|数字输入框|每日最多查看联系人数| 范围型(SCOPE)的**可选枚举值**在 `PermissionDef.scope_choices` 中定义(JSON 数组),不同权限项的可选范围不同(如某项只有「本人/本门店/全公司」三档)。 ### 需求 4:多角色权限叠加规则(并集/最宽松原则) PRD 第 6 节已明确倾向:**取权限并集(最宽松原则)**。具体合并逻辑: |值类型|合并规则|示例| |---|---|---| |`BOOLEAN`|`OR` — 任一角色开启则生效|角色A关闭、角色B开启 → 最终**开启**| |`SCOPE`|`MAX` — 取范围最大的值|角色A=本组、角色B=本门店 → 最终**本门店**| |`INTEGER`|`MAX` — 取最大数值;`0` 表示不限制,是最宽松值|角色A=20、角色B=50 → **50**;角色A=20、角色B=0 → **0(不限制)**| SCOPE 值的大小关系定义为:`无 < 本人 < 本组 < 本门店 < 本区域 < 全公司` ### 需求 5:契合当前技术栈的实现方案 - 数据层:PostgreSQL + `JSONField`,与 `django-tenants` Schema 隔离完全兼容 - 缓存层:Redis 存储员工权限快照,变更时主动失效 - 视图层:Django 视图 + HTMX 局部刷新权限编辑面板,Alpine.js 管理 Toggle/下拉状态 - 权限校验:自定义 `permission_required` 装饰器 + Mixin,替代 Django 原生权限系统 --- ## 三、数据模型设计(完整) ```python # apps/permissions/models.py from django.db import models from django.contrib.postgres.fields import ArrayField class ScopeLevel(models.IntegerChoices): """范围型权限的枚举值,数值越大权限越宽""" NONE = 0, "无" SELF = 1, "本人" GROUP = 2, "本组" STORE = 3, "本门店" REGION = 4, "本区域" COMPANY = 5, "全公司" class ValueType(models.TextChoices): BOOLEAN = "BOOLEAN", "开关型" SCOPE = "SCOPE", "范围型" INTEGER = "INTEGER", "数值型" class PermissionModule(models.TextChoices): HOME = "home", "首页" PROPERTY = "property", "房源" NEW_HOUSE = "new_house", "新房" CLIENT = "client", "客源" TRANSACTION = "transaction","交易" DATA = "data", "数据" MARKETING = "marketing", "营销" HR = "hr", "人事OA" CONTRACT = "contract", "合同" TRINET = "trinet", "三网" SYSTEM = "system", "系统" MOBILE = "mobile", "移动端" SMART_STORE = "smart_store","智能门店" RECHARGE = "recharge", "在线充值" class PermissionDef(models.Model): """ 权限定义表(开发者维护,系统内置,不随租户变化) 此表在 django-tenants shared schema 中,所有租户共用。 """ code = models.CharField(max_length=100, unique=True) # 如 "client.view_private_scope" module = models.CharField(max_length=50, choices=PermissionModule.choices) 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) # 范围型权限的可选枚举值(JSON 存储 ScopeLevel 的 value 列表) # 例:[1, 2, 3, 5] 表示只提供「本人/本组/本门店/全公司」四个选项 scope_choices = models.JSONField(default=list, blank=True) # 系统最小默认值 default_value = models.JSONField(default=dict) # {"v": false} / {"v": 0} / {"v": 0} sort_order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) class Meta: ordering = ["module", "sub_module", "sort_order"] def __str__(self): return f"[{self.module}] {self.name}" class RoleCategory(models.TextChoices): AGENT = "agent", "置业顾问" MANAGER = "manager", "店管" DIRECTOR= "director","总经" class Role(models.Model): """ 角色(租户内数据,在 tenant schema 中) """ name = models.CharField(max_length=100) category = models.CharField(max_length=50, choices=RoleCategory.choices) description = models.TextField(blank=True) # 引用来源角色(从哪个角色模板复制) template_role = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="derived_roles" ) created_by = models.ForeignKey("org.Staff", on_delete=models.SET_NULL, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ("name",) # 租户内角色名唯一 def __str__(self): return self.name class RolePermission(models.Model): """ 角色的权限配置(角色 × 权限项 → 值) 只存储非默认值的项,减少存储量。 """ role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="permissions") permission_def = models.ForeignKey(PermissionDef, on_delete=models.CASCADE) value = models.JSONField() # {"v": true} | {"v": "store"} | {"v": 50} class Meta: unique_together = ("role", "permission_def") class StaffRole(models.Model): """ 员工 ↔ 角色(多对多,支持一人多角色) """ staff = models.ForeignKey("org.Staff", on_delete=models.CASCADE, related_name="staff_roles") role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="staff_roles") assigned_at = models.DateTimeField(auto_now_add=True) assigned_by = models.ForeignKey( "org.Staff", on_delete=models.SET_NULL, null=True, related_name="role_assignments_made" ) class Meta: unique_together = ("staff", "role") class StaffPermissionOverride(models.Model): """ 员工个人权限覆盖(稀疏存储,只记录与角色不同的项) 这是「个性化调整」的核心表。 """ staff = models.ForeignKey("org.Staff", on_delete=models.CASCADE, related_name="permission_overrides") permission_def = models.ForeignKey(PermissionDef, on_delete=models.CASCADE) value = models.JSONField() # 记录是谁修改的,为后续日志奠基 modified_by = models.ForeignKey( "org.Staff", on_delete=models.SET_NULL, null=True, related_name="permission_overrides_made" ) modified_at = models.DateTimeField(auto_now=True) note = models.TextField(blank=True) # 管理员备注 class Meta: unique_together = ("staff", "permission_def") ``` --- ## 四、权限解析引擎 ```python # apps/permissions/services/resolver.py import json import redis from django.conf import settings from .models import PermissionDef, RolePermission, StaffPermissionOverride, ValueType, ScopeLevel _redis = redis.Redis.from_url(settings.REDIS_URL) CACHE_TTL = 3600 # 1小时,变更时主动失效 def _merge_values(value_type: str, values: list) -> dict: """ 多角色权限值合并:最宽松原则 values: 每个角色的原始值字典列表,如 [{"v": true}, {"v": false}] """ raw = [v["v"] for v in values if v] if not raw: return None if value_type == ValueType.BOOLEAN: return {"v": any(raw)} # OR if value_type == ValueType.SCOPE: # 将 scope 字符串转为整数比较,取最大 scope_map = {s.label: s.value for s in ScopeLevel} int_vals = [scope_map.get(r, 0) for r in raw] best = max(int_vals) # 转回字符串 label_map = {s.value: s.name.lower() for s in ScopeLevel} return {"v": label_map.get(best, "none")} if value_type == ValueType.INTEGER: # 0 = 不限制(最宽松),否则取最大值 if 0 in raw: return {"v": 0} return {"v": max(raw)} return values[0] def get_resolved_permissions(staff_id: int, tenant_schema: str) -> dict: """ 获取员工完整权限快照(含缓存) 返回格式: {"permission_code": {"v": value}, ...} """ cache_key = f"perm:{tenant_schema}:{staff_id}" cached = _redis.get(cache_key) if cached: return json.loads(cached) snapshot = _build_permission_snapshot(staff_id) _redis.setex(cache_key, CACHE_TTL, json.dumps(snapshot)) return snapshot def _build_permission_snapshot(staff_id: int) -> dict: """构建员工权限快照(不含缓存逻辑)""" from apps.permissions.models import StaffRole # Step 1: 获取员工所有角色 role_ids = list( StaffRole.objects.filter(staff_id=staff_id).values_list("role_id", flat=True) ) # Step 2: 拉取所有权限定义 all_defs = {p.code: p for p in PermissionDef.objects.filter(is_active=True)} # Step 3: 从角色权限表聚合,按 permission_def 分组 role_perms_qs = RolePermission.objects.filter(role_id__in=role_ids).select_related("permission_def") role_values_by_code: dict[str, list] = {} for rp in role_perms_qs: code = rp.permission_def.code role_values_by_code.setdefault(code, []).append(rp.value) # Step 4: 合并多角色值 merged: dict[str, dict] = {} for code, values in role_values_by_code.items(): pdef = all_defs.get(code) if pdef: merged[code] = _merge_values(pdef.value_type, values) # Step 5: 填入未配置的权限默认值 for code, pdef in all_defs.items(): if code not in merged: merged[code] = pdef.default_value # Step 6: 叠加个人覆盖(最高优先级) overrides = StaffPermissionOverride.objects.filter(staff_id=staff_id).select_related("permission_def") for override in overrides: merged[override.permission_def.code] = override.value return merged def invalidate_staff_cache(staff_id: int, tenant_schema: str): """权限变更后调用此方法清除缓存""" cache_key = f"perm:{tenant_schema}:{staff_id}" _redis.delete(cache_key) def invalidate_role_cache(role_id: int, tenant_schema: str): """角色权限变更后,清除所有使用该角色的员工缓存""" from apps.permissions.models import StaffRole staff_ids = StaffRole.objects.filter(role_id=role_id).values_list("staff_id", flat=True) keys = [f"perm:{tenant_schema}:{sid}" for sid in staff_ids] if keys: _redis.delete(*keys) ``` --- ## 五、权限检查工具(Views 层集成) ```python # apps/permissions/checks.py from functools import wraps from django.http import HttpResponseForbidden from .services.resolver import get_resolved_permissions from .models import ScopeLevel class PermissionChecker: """ 在 View 或模板中使用的权限检查器 用法: checker = PermissionChecker(request.staff, request.tenant.schema_name) if checker.can("client.view_private_scope", min_scope="store"): ... """ def __init__(self, staff, tenant_schema: str): self._staff = staff self._perms = get_resolved_permissions(staff.id, tenant_schema) def get(self, code: str): """获取权限原始值""" entry = self._perms.get(code, {}) return entry.get("v") def is_enabled(self, code: str) -> bool: """布尔型:是否开启""" return bool(self.get(code)) def has_scope(self, code: str, min_scope: str) -> bool: """ 范围型:是否达到最低所需范围 min_scope: "self" | "group" | "store" | "region" | "company" """ scope_value = self.get(code) if scope_value is None: return False scope_map = {s.name.lower(): s.value for s in ScopeLevel} actual = scope_map.get(scope_value, 0) required = scope_map.get(min_scope, 0) return actual >= required def get_limit(self, code: str) -> int: """数值型:获取上限(0=不限制)""" v = self.get(code) return v if isinstance(v, int) else 0 def is_unlimited(self, code: str) -> bool: """数值型:是否不限制""" return self.get_limit(code) == 0 def permission_required(code: str, value_check=None): """ View 装饰器:检查权限 value_check: 可选的 lambda,接收权限值,返回 bool 示例: @permission_required("client.view_private_scope", lambda v: ScopeLevel[v.upper()].value >= ScopeLevel.STORE.value) def my_view(request): ... """ def decorator(view_func): @wraps(view_func) def wrapped(request, *args, **kwargs): checker = PermissionChecker(request.staff, request.tenant.schema_name) val = checker.get(code) if value_check: ok = value_check(val) else: ok = bool(val) if not ok: return HttpResponseForbidden("权限不足") return view_func(request, *args, **kwargs) return wrapped return decorator ``` --- ## 六、Django Admin / View 层权限编辑 ### 保存角色权限(HTMX 请求) ```python # apps/permissions/views.py from django.views import View from django.http import HttpResponse from django.shortcuts import get_object_or_404 from .models import Role, RolePermission, PermissionDef from .services.resolver import invalidate_role_cache class RolePermissionSaveView(View): """ 接收角色权限编辑页的 HTMX 保存请求 POST /permissions/roles//save/ body: {"perms": {"client.view_private_scope": {"v": "store"}, ...}} """ def post(self, request, role_id): role = get_object_or_404(Role, pk=role_id) data = json.loads(request.body) perm_data = data.get("perms", {}) for code, value in perm_data.items(): pdef = PermissionDef.objects.get(code=code) RolePermission.objects.update_or_create( role=role, permission_def=pdef, defaults={"value": value} ) # 清除所有使用该角色的员工缓存 invalidate_role_cache(role.id, request.tenant.schema_name) # 返回 HTMX 片段:Toast 提示 return HttpResponse( '
' ) ``` ### 保存个人权限覆盖 ```python class StaffPermissionOverrideSaveView(View): """ 保存员工个人权限覆盖 POST /permissions/staff//override/ """ def post(self, request, staff_id): from apps.org.models import Staff from .models import StaffPermissionOverride from .services.resolver import invalidate_staff_cache staff = get_object_or_404(Staff, pk=staff_id) data = json.loads(request.body) for code, value in data.get("overrides", {}).items(): pdef = PermissionDef.objects.get(code=code) StaffPermissionOverride.objects.update_or_create( staff=staff, permission_def=pdef, defaults={"value": value, "modified_by": request.staff} ) invalidate_staff_cache(staff.id, request.tenant.schema_name) return HttpResponse('
') ``` --- ## 七、「权限与角色权限不一致」标记逻辑 ```python # apps/permissions/services/consistency.py def get_staff_inconsistent_permission_codes(staff_id: int, tenant_schema: str) -> list[str]: """ 返回该员工中与其角色默认权限不一致的 permission code 列表 用于在人员列表中标记橙色「不一致」状态 """ from apps.permissions.models import StaffPermissionOverride, StaffRole, RolePermission from .resolver import _merge_values, _build_permission_snapshot # 只看个人覆盖了哪些 overrides = dict( StaffPermissionOverride.objects.filter(staff_id=staff_id) .values_list("permission_def__code", "value") ) if not overrides: return [] # 构建纯角色合并结果(不含个人覆盖) role_ids = list(StaffRole.objects.filter(staff_id=staff_id).values_list("role_id", flat=True)) role_perms_qs = RolePermission.objects.filter(role_id__in=role_ids).select_related("permission_def") role_values_by_code: dict[str, list] = {} for rp in role_perms_qs: code = rp.permission_def.code role_values_by_code.setdefault(code, []).append(rp.value) inconsistent = [] for code, override_val in overrides.items(): pdef = PermissionDef.objects.filter(code=code).first() if not pdef: continue role_merged = _merge_values(pdef.value_type, role_values_by_code.get(code, [])) if role_merged is None: role_merged = pdef.default_value if override_val != role_merged: inconsistent.append(code) return inconsistent def staff_has_inconsistency(staff_id: int, tenant_schema: str) -> bool: """快捷方法:员工是否存在个人权限不一致""" return len(get_staff_inconsistent_permission_codes(staff_id, tenant_schema)) > 0 ``` --- ## 八、前端集成(HTMX + Alpine.js) ### 权限编辑页骨架(按模块懒加载) ```html
加载中...
``` ### 权限项组件(范围型下拉) ```html
{{ pdef.name }} {{ pdef.description }}
``` --- ## 九、目录结构建议 ``` apps/permissions/ ├── models.py # 所有权限相关 Model(见第三节) ├── admin.py # Django Admin:PermissionDef 管理 ├── views.py # Role/Staff 权限保存、模块面板加载 ├── urls.py ├── services/ │ ├── resolver.py # 权限解析引擎(含 Redis 缓存) │ ├── consistency.py # 不一致标记逻辑 │ └── merger.py # 多角色值合并函数(单独抽离方便单测) ├── templatetags/ │ └── permission_tags.py # 模板标签:{% has_perm "client.view_private" %} ├── fixtures/ │ └── permission_defs.json # 初始权限目录数据 └── migrations/ ``` --- ## 十、关键风险与缓解 |风险|应对方案| |---|---| |角色权限变更未实时生效|`invalidate_role_cache()` 在保存时同步调用,清除所有相关员工缓存| |`PermissionDef` 数据量大(300+条)前端渲染慢|左侧模块导航驱动 HTMX 懒加载,每次只渲染当前模块| |INTEGER 型 `0=不限制` 语义混淆|`is_unlimited()` 工具方法封装判断,避免散落在业务代码中| |多角色 SCOPE 合并需要枚举顺序一致|`ScopeLevel` 使用 `IntegerChoices`,排序由整数值保证,不依赖字符串比较| |`django-tenants` Schema 隔离|`PermissionDef` 放入 `shared_apps`,`Role/RolePermission/StaffRole/Override` 放入 `tenant_apps`| |角色被删除时员工无角色|删除前校验 `StaffRole.objects.filter(role=role).exists()`,阻止删除并提示迁移| --- ## 十一、迁移执行顺序 1. 创建 `PermissionDef` fixture(所有权限定义,约 300 条)并执行 `loaddata` 2. 建立 `Role`、`RolePermission`、`StaffRole`、`StaffPermissionOverride` 表 3. 为 `org.Staff` 增加权限相关的属性方法(`get_permission_checker()`) 4. 部署 `CACHE_TTL` 和 `invalidate_*` 调用点 5. 实现管理 UI(人员列表 → 角色管理 → 个人权限编辑) --- _文档版本 v1.0 | 生成时间 2026-04-24_