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

25 KiB
Raw Permalink 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 权限管理系统技术方案建议

版本: 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 原生权限只支持 Booleandjango-guardian 是行级权限模型,两者都无法满足你的「范围型枚举 + 数值型」需求,因此推荐完全自定义实现。

五个需求对应的设计决策

① 基于角色分配权限 — 标准 RBAC 三表:PermissionDef(权限目录,开发者维护,放 shared schemaRole(角色模板)→ RolePermission(角色 × 权限 → 值)

② 个人权限调整 — 增加 StaffPermissionOverride 表,稀疏覆盖设计:只存储与角色默认值不同的项,不复制全量权限。解析优先级:个人覆盖 > 角色合并 > 系统默认。

③ 多种值类型 — 统一用 JSONField 存储,格式统一为 {"v": value}PermissionDef.value_type 标记类型(BOOLEAN/SCOPE/INTEGER),前端根据类型渲染 Toggle/下拉/数字框。范围型的可选枚举(如某权限只有三档选项)存在 scope_choices JSON 数组中。

④ 多角色叠加规则 — 采用最宽松原则PRD 6.4 倾向确认):BOOLEANORSCOPE 取枚举最大值(本人 < 本组 < 本门店 < 本区域 < 全公司)、INTEGER 取最大值(0=不限制是最宽松值)。

⑤ 契合技术栈 — Redis 缓存员工权限快照(变更时主动 invalidateHTMX 按模块懒加载权限面板(避免一次渲染 300+ 条Alpine.js 管理 Toggle/下拉本地状态;与 django-tenants 的集成方式:PermissionDefshared_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 原生权限系统

三、数据模型设计(完整)

# 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")

四、权限解析引擎

# 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 层集成)

# 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 请求)

# 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\'})" />'
        )

保存个人权限覆盖

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: \'个人权限已更新\'})" />')

七、「权限与角色权限不一致」标记逻辑

# 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

权限编辑页骨架(按模块懒加载)

<!-- 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>

权限项组件(范围型下拉)

<!-- 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_appsRole/RolePermission/StaffRole/Override 放入 tenant_apps
角色被删除时员工无角色 删除前校验 StaffRole.objects.filter(role=role).exists(),阻止删除并提示迁移

十一、迁移执行顺序

  1. 创建 PermissionDef fixture所有权限定义约 300 条)并执行 loaddata
  2. 建立 RoleRolePermissionStaffRoleStaffPermissionOverride
  3. org.Staff 增加权限相关的属性方法(get_permission_checker()
  4. 部署 CACHE_TTLinvalidate_* 调用点
  5. 实现管理 UI人员列表 → 角色管理 → 个人权限编辑)

文档版本 v1.0 | 生成时间 2026-04-24