Files
track-LICA/backend/app/schemas/group.py
duxingchen 15097baa2b feat(权限): 业务分组对全员开放(操作分层)+ 操作审计仅超管可见
需求:业务分组开放给所有人,主管可操作、其余人只读;操作审计前端不显示。

⚠️ 这里有个必须收窄的边界:按数据范围规则,被分进组的 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
2026-09-21 17:38:07 +08:00

70 lines
2.3 KiB
Python
Raw 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.

"""业务分组 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 # 真实姓名,仅供界面展示