需求:业务分组开放给所有人,主管可操作、其余人只读;操作审计前端不显示。
⚠️ 这里有个必须收窄的边界:按数据范围规则,被分进组的 SUPERVISOR 会从
「全厂」降级为只看本组。若允许他改可见范围,他把自己那组改成「生产+售后」
就恢复全厂视野;若允许建组,他新建一个组再把自己塞进去,同样绕过。
**「能管成员」与「能配范围」必须分开** —— 前者安全(组长给自己加组会被
唯一约束挡住、移出自己只是失去权限),后者是提权入口。
因此分层如下:
| 谁 | 看 | 改 |
|------------------|--------------|------------------------------------|
| SUPER_ADMIN | 所有组 | 全部(建/改/删组、配范围、管成员) |
| 主管 / 本组组长 | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
| 普通成员 | 自己所属的组 | 无(只读) |
后端(groups.py):
· 去掉路由级的 require_roles(SUPER_ADMIN),改为逐端点校验
· list_groups / get_group:非超管只返回自己所属的组;看别组详情 403
(连「这个组存在但你没份」都不暴露,避免被拿来推测组织架构)
· create/update/delete:_require_super —— 这三个 + 配范围是提权入口
· add/update/remove member:_can_manage_members(超管 / 主管 / 本组组长)
· GroupOut 新增 can_manage_members / can_manage_group / is_my_leader
—— 由服务端算好下发,前端不按 role 自行推导(组长身份是按组算的)
前端:
· AdminGroupsPage 去掉整页超管门禁,改为按能力显示按钮:
无权限时「组长」列退化成纯展示而非可点按钮
没有任何组时给「你还没有被分配到任何业务分组」的引导提示
· AdminLayout 的 MENU 支持 superOnly 标记,「操作审计」只对超管显示
(后端 /audit/* 本就挂了 require_admin,这里只是不给入口,
否则普通用户点进去只会看到一堆 403)
实测(四类账号):
建组/改范围:仅超管通过,其余全 403
管成员:超管/主管/本组组长通过;非本组 403;跨组组长 403
可见性:主管未分组看到 0 个组、维修组长只看到维修大组、生产组员只看到生产小组
详情页能力标记正确下发;越权查别组详情 403
70 lines
2.3 KiB
Python
70 lines
2.3 KiB
Python
"""业务分组 Schemas"""
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class PhaseOption(BaseModel):
|
||
"""可选的生命周期阶段 —— 由服务端下发,前端不要写死这两个值"""
|
||
value: str
|
||
label: str
|
||
|
||
|
||
class GroupMemberOut(BaseModel):
|
||
user_id: str # MOM 短账号(与 Task.assignee_id 同口径)
|
||
display_name: str
|
||
is_leader: bool
|
||
|
||
|
||
class GroupOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
parent_id: int | None = None
|
||
parent_name: str | None = None
|
||
description: str | None = None
|
||
sort_order: int = 0
|
||
is_active: bool = True
|
||
phases: list[str] = [] # 该组自己的可见范围(不含继承)
|
||
effective_phases: list[str] = [] # 实际生效范围(自己没配则取父组的)
|
||
phase_labels: list[str] = [] # 生效范围的中文标签
|
||
member_count: int = 0
|
||
# ---- 当前用户对这个组能做什么(服务端算好,前端别自己推导)----
|
||
can_manage_members: bool = False # 能加/移成员、设组长
|
||
can_manage_group: bool = False # 能改组名/范围/启停/删除 —— 仅超管
|
||
is_my_leader: bool = False # 我是这个组的组长
|
||
|
||
|
||
class GroupDetailOut(GroupOut):
|
||
members: list[GroupMemberOut] = []
|
||
|
||
|
||
class GroupCreate(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=50, description="分组显示名")
|
||
parent_id: int | None = Field(None, description="上级大组ID;为空则建的是大组")
|
||
description: str | None = Field(None, max_length=200)
|
||
sort_order: int = 0
|
||
phases: list[str] = Field(default_factory=list, description="可见的生命周期阶段")
|
||
|
||
|
||
class GroupUpdate(BaseModel):
|
||
name: str | None = Field(None, min_length=1, max_length=50)
|
||
parent_id: int | None = None
|
||
description: str | None = Field(None, max_length=200)
|
||
sort_order: int | None = None
|
||
is_active: bool | None = None
|
||
phases: list[str] | None = Field(
|
||
None, description="整组覆盖式更新;传 [] 表示清空(此时继承父组)",
|
||
)
|
||
|
||
|
||
class GroupMemberAdd(BaseModel):
|
||
user_id: str = Field(..., min_length=1, max_length=64, description="MOM 账号")
|
||
is_leader: bool = False
|
||
|
||
|
||
class GroupMemberUpdate(BaseModel):
|
||
is_leader: bool
|
||
|
||
|
||
class MemberCandidate(BaseModel):
|
||
username: str # 短账号,存进组的就是它
|
||
full_name: str # 真实姓名,仅供界面展示
|