diff --git a/Pasted image 20260424165018.png b/Pasted image 20260424165018.png new file mode 100644 index 00000000..6e3cfe2f Binary files /dev/null and b/Pasted image 20260424165018.png differ diff --git a/Pasted image 20260424165102.png b/Pasted image 20260424165102.png new file mode 100644 index 00000000..6e3cfe2f Binary files /dev/null and b/Pasted image 20260424165102.png differ diff --git a/Project/fonrey/PRD/系统管理/系统管理模块PRD.md b/Project/fonrey/PRD/系统管理/系统管理模块PRD.md index 107f6387..7d85a7a2 100644 --- a/Project/fonrey/PRD/系统管理/系统管理模块PRD.md +++ b/Project/fonrey/PRD/系统管理/系统管理模块PRD.md @@ -41,11 +41,11 @@ Fonrey 是一套面向房产经纪公司的 B2B SaaS 平台,采用 `django-ten ### 1.2 目标用户 -| 角色 | 使用场景 | 频率 | -|------|---------|------| +| 角色 | 使用场景 | 频率 | +| --------------------------- | ----------- | ------ | | 超级管理员(Platform Super Admin) | 全局配置、高危操作授权 | 低频(每周) | -| 运营人员(Ops Operator) | 日常租户管理、监控巡检 | 高频(每日) | -| 只读审计员(Read-only Auditor) | 日志查询、合规报告导出 | 中频(每周) | +| 运维人员(Ops Operator) | 日常租户管理、监控巡检 | 高频(每日) | +| 只读审计员(Read-only Auditor) | 日志查询、合规报告导出 | 中频(每周) | --- diff --git a/Project/fonrey/TECH_STACK/权限管理系统技术方案.md b/Project/fonrey/TECH_STACK/权限管理系统技术方案.md new file mode 100644 index 00000000..52756823 --- /dev/null +++ b/Project/fonrey/TECH_STACK/权限管理系统技术方案.md @@ -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//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_ \ No newline at end of file