权限管理设计方案
This commit is contained in:
BIN
Pasted image 20260424165018.png
Normal file
BIN
Pasted image 20260424165018.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
BIN
Pasted image 20260424165102.png
Normal file
BIN
Pasted image 20260424165102.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
@@ -41,11 +41,11 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten
|
||||
|
||||
### 1.2 目标用户
|
||||
|
||||
| 角色 | 使用场景 | 频率 |
|
||||
|------|---------|------|
|
||||
| 角色 | 使用场景 | 频率 |
|
||||
| --------------------------- | ----------- | ------ |
|
||||
| 超级管理员(Platform Super Admin) | 全局配置、高危操作授权 | 低频(每周) |
|
||||
| 运营人员(Ops Operator) | 日常租户管理、监控巡检 | 高频(每日) |
|
||||
| 只读审计员(Read-only Auditor) | 日志查询、合规报告导出 | 中频(每周) |
|
||||
| 运维人员(Ops Operator) | 日常租户管理、监控巡检 | 高频(每日) |
|
||||
| 只读审计员(Read-only Auditor) | 日志查询、合规报告导出 | 中频(每周) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
677
Project/fonrey/TECH_STACK/权限管理系统技术方案.md
Normal file
677
Project/fonrey/TECH_STACK/权限管理系统技术方案.md
Normal file
@@ -0,0 +1,677 @@
|
||||
# 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/<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 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_
|
||||
Reference in New Issue
Block a user