Files
nexus/Project/fonrey/TECH_STACK/权限管理系统技术方案.md
2026-04-25 07:44:21 +08:00

678 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
# Fonrey 权限管理系统技术方案建议
**版本**: 1.0 | **项目**: Fonrey 房产经纪管理系统 | **技术栈**: Django 4.x + HTMX + Alpine.js + PostgreSQL + Redis
---
![[IMG-20260424204148583.png]]
## 一、选型结论:为什么不用现有库
在回答五个核心需求之前,先明确**不推荐**使用哪些常见方案:
|方案|为何不适用|
|---|---|
|`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/<role_id>/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(
'<div x-data x-init="$dispatch(\'toast\', {msg: \'保存成功\', type: \'success\'})" />'
)
```
### 保存个人权限覆盖
```python
class StaffPermissionOverrideSaveView(View):
"""
保存员工个人权限覆盖
POST /permissions/staff/<staff_id>/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('<div x-data x-init="$dispatch(\'toast\', {msg: \'个人权限已更新\'})" />')
```
---
## 七、「权限与角色权限不一致」标记逻辑
```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
<!-- templates/permissions/staff_permission_edit.html -->
<div x-data="{ activeModule: 'client' }">
<!-- 左侧模块导航 -->
<nav>
{% for module in modules %}
<button
@click="activeModule = '{{ module.code }}'"
:class="activeModule === '{{ module.code }}' ? 'active' : ''"
hx-get="/permissions/staff/{{ staff.id }}/module/{{ module.code }}/"
hx-target="#perm-panel"
hx-trigger="click"
hx-swap="innerHTML">
{{ module.name }}
</button>
{% endfor %}
</nav>
<!-- 右侧权限配置面板HTMX 懒加载,避免一次性渲染数百条) -->
<div id="perm-panel"
hx-get="/permissions/staff/{{ staff.id }}/module/client/"
hx-trigger="load">
<div class="loading-spinner">加载中...</div>
</div>
</div>
```
### 权限项组件(范围型下拉)
```html
<!-- templates/permissions/partials/perm_scope_item.html -->
<div x-data="{ editing: false, value: '{{ current_value }}' }" class="perm-item">
<span class="perm-name">{{ pdef.name }}</span>
<span class="perm-desc text-muted">{{ pdef.description }}</span>
<!-- 范围型下拉 -->
<select
x-model="value"
hx-post="/permissions/staff/{{ staff.id }}/override/"
hx-vals='js:{"overrides": {"{{ pdef.code }}": {"v": $el.value}}}'
hx-trigger="change"
hx-swap="none">
{% for choice in pdef.scope_choices_display %}
<option value="{{ choice.value }}" {% if choice.value == current_value %}selected{% endif %}>
{{ choice.label }}
</option>
{% endfor %}
</select>
<!-- 编辑按钮打开 Drawer -->
<button
hx-get="/permissions/pdef/{{ pdef.id }}/drawer/?staff_id={{ staff.id }}"
hx-target="#perm-drawer"
hx-trigger="click">
编辑
</button>
</div>
```
---
## 九、目录结构建议
```
apps/permissions/
├── models.py # 所有权限相关 Model见第三节
├── admin.py # Django AdminPermissionDef 管理
├── 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_