From 15097baa2b1ed7fbe04c977acd24ffd0ca639f89 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Mon, 21 Sep 2026 17:38:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9D=83=E9=99=90):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=AF=B9=E5=85=A8=E5=91=98=E5=BC=80=E6=94=BE?= =?UTF-8?q?=EF=BC=88=E6=93=8D=E4=BD=9C=E5=88=86=E5=B1=82=EF=BC=89+=20?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=AE=A1=E8=AE=A1=E4=BB=85=E8=B6=85=E7=AE=A1?= =?UTF-8?q?=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:业务分组开放给所有人,主管可操作、其余人只读;操作审计前端不显示。 ⚠️ 这里有个必须收窄的边界:按数据范围规则,被分进组的 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 --- backend/app/api/v1/endpoints/groups.py | 225 +++++++++++++++--- backend/app/schemas/group.py | 4 + .../src/components/layout/AdminLayout.tsx | 14 +- frontend/src/pages/admin/AdminGroupsPage.tsx | 121 ++++++---- frontend/src/services/groupApi.ts | 4 + 5 files changed, 286 insertions(+), 82 deletions(-) diff --git a/backend/app/api/v1/endpoints/groups.py b/backend/app/api/v1/endpoints/groups.py index b96fd34..381b1d9 100644 --- a/backend/app/api/v1/endpoints/groups.py +++ b/backend/app/api/v1/endpoints/groups.py @@ -1,13 +1,20 @@ -"""业务分组管理 API —— **仅超级管理员**可访问 +"""业务分组 API —— 全员可见,操作按角色分层 -⚠️ 为什么只有 SUPER_ADMIN 能管分组,SUPERVISOR 不行: +权限模型(2026-09 调整:从「仅超管」放开为分层,但**操作边界必须收窄**): - 按数据范围规则,被显式分进组的 SUPERVISOR 会从「全厂」**降级**为只看本组。 - 如果允许 SUPERVISOR 管理分组,那么他被分组之后,只要把自己从组里移出去 - 就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他完全无效。 + | 谁 | 看 | 改 | + |---------------------|------------------------|--------------------------------------| + | SUPER_ADMIN | 所有组 | 全部(建组/改组/删组/配范围/管成员) | + | 主管(SUPERVISOR) | 自己所属的组 | **仅本组成员**(加人/移人/设组长) | + | 组长(is_leader) | 自己所属的组 | **仅本组成员** | + | 普通成员 | 自己所属的组 | 无(只读) | - 所以这里用 require_roles(SUPER_ADMIN),**不能**用 require_admin - (后者含 SUPERVISOR)。 + ⚠️ **为什么「主管」不能改可见范围、不能建删组**: + 按数据范围规则,被分进组的 SUPERVISOR 会从「全厂」降级为只看本组。 + 若允许他改范围,他只要把自己那组改成「生产 + 售后」就恢复全厂视野; + 若能建组,他新建一个组再把自己塞进去,同样绕过。**能给自己加组 = 提权**。 + 所以「管成员」与「配范围」必须分开:前者安全(组长给自己加组会被唯一约束 + 挡住,移出自己只是失去权限),后者是提权入口。 写操作会被 audit_middleware 自动采集 —— 分组变更是高权限动作,追责必须有据。 """ @@ -19,10 +26,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db -from app.core.deps import require_roles from app.core.lifecycle import PHASE_LABELS from app.core.mom_database import MomSessionLocal -from app.core.roles import SUPER_ADMIN +from app.core.roles import ADMIN_ROLES, SUPER_ADMIN +from app.services.auth_service import get_current_user from app.models.business_group import ( BusinessGroup, BusinessGroupMember, @@ -40,11 +47,71 @@ from app.schemas.group import ( PhaseOption, ) -router = APIRouter( - prefix="/groups", - tags=["业务分组"], - dependencies=[Depends(require_roles(SUPER_ADMIN))], -) +router = APIRouter(prefix="/groups", tags=["业务分组"]) + + +# ============================================================ +# 权限判定 +# ============================================================ + +def _is_super(user: dict) -> bool: + return (user or {}).get("role") == SUPER_ADMIN + + +async def _my_group_ids(db: AsyncSession, user: dict) -> set[int]: + """当前用户所属(且启用中)的组 id""" + username = (user or {}).get("username") or "" + if not username: + return set() + rows = await db.execute( + select(BusinessGroupMember.group_id) + .join(BusinessGroup, BusinessGroup.id == BusinessGroupMember.group_id) + .where( + BusinessGroupMember.user_id == username, + BusinessGroup.is_active.is_(True), + ) + ) + return {r[0] for r in rows.all()} + + +async def _leader_group_ids(db: AsyncSession, user: dict) -> set[int]: + """当前用户当组长的组 id""" + username = (user or {}).get("username") or "" + if not username: + return set() + rows = await db.execute( + select(BusinessGroupMember.group_id) + .join(BusinessGroup, BusinessGroup.id == BusinessGroupMember.group_id) + .where( + BusinessGroupMember.user_id == username, + BusinessGroupMember.is_leader.is_(True), + BusinessGroup.is_active.is_(True), + ) + ) + return {r[0] for r in rows.all()} + + +async def _can_manage_members(db: AsyncSession, user: dict, group_id: int) -> bool: + """能否管理**这个组**的成员。 + + 超管、部门主管(SUPERVISOR)、本组组长 —— 三者之一即可。 + ⚠️ 组长只能管自己当组长的组;主管虽然能管所有组的成员,但**改不了范围** + (范围相关端点单独限超管),所以不构成提权。 + """ + if _is_super(user): + return True + if (user or {}).get("role") in ADMIN_ROLES: + return True + return group_id in await _leader_group_ids(db, user) + + +def _require_super(user: dict) -> None: + """建组 / 改组 / 删组 / 配范围 —— 这些是提权入口,只给超管""" + if not _is_super(user): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "只有超级管理员可以新建、修改或删除分组,以及调整分组的可见范围", + ) # ============================================================ @@ -94,7 +161,14 @@ async def _member_counts(db: AsyncSession) -> dict[int, int]: def _to_out(group: BusinessGroup, phases: list[str], count: int, - parent_name: str | None, own_phases: list[str]) -> GroupOut: + parent_name: str | None, own_phases: list[str], + *, can_manage_members: bool = False, can_manage_group: bool = False, + is_my_leader: bool = False) -> GroupOut: + """把 ORM 对象转成响应。 + + 能力标记(能否管成员 / 能否改组)由服务端算好下发,前端不要自己按 role 推导 —— + 组长身份是按组算的,前端凭 role 推不出来。 + """ return GroupOut( id=group.id, name=group.name, @@ -107,6 +181,9 @@ def _to_out(group: BusinessGroup, phases: list[str], count: int, effective_phases=phases, phase_labels=[PHASE_LABELS.get(p, p) for p in phases], member_count=count, + can_manage_members=can_manage_members, + can_manage_group=can_manage_group, + is_my_leader=is_my_leader, ) @@ -123,7 +200,7 @@ async def _get_group_or_404(db: AsyncSession, group_id: int) -> BusinessGroup: # ============================================================ @router.get("/phase-options", response_model=list[PhaseOption]) -async def list_phase_options(): +async def list_phase_options(current_user: dict = Depends(get_current_user)): """可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值""" return [PhaseOption(value=v, label=l) for v, l in _valid_phases().items()] @@ -132,6 +209,7 @@ async def list_phase_options(): def list_member_candidates( keyword: str = Query("", description="按姓名/账号模糊搜索"), limit: int = Query(500, ge=1, le=1000), + current_user: dict = Depends(get_current_user), ): """候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT)。 @@ -176,16 +254,34 @@ def list_member_candidates( # ============================================================ @router.get("", response_model=list[GroupOut]) -async def list_groups(db: AsyncSession = Depends(get_db)): - """列出全部业务分组(含停用的),带成员数与生效范围""" - groups = (await db.execute( - select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id) - )).scalars().all() +async def list_groups( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """列出业务分组。 + + - 超管:全部(含停用的) + - 其他人:**只返回自己所属的组** —— 普通成员不该看到别人的组有哪些人。 + 连"这个组存在但你没份"都不暴露,避免被拿来推测组织架构。 + """ + stmt = select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id) + if not _is_super(current_user): + mine = await _my_group_ids(db, current_user) + if not mine: + return [] + stmt = stmt.where(BusinessGroup.id.in_(tuple(mine))) + + groups = (await db.execute(stmt)).scalars().all() own = await _load_phases(db, [g.id for g in groups]) counts = await _member_counts(db) names = {g.id: g.name for g in groups} + # 能力标记一次算好,避免在循环里反复查库 + is_super = _is_super(current_user) + is_admin_role = (current_user or {}).get("role") in ADMIN_ROLES + leader_ids = set() if is_super else await _leader_group_ids(db, current_user) + return [ _to_out( g, @@ -193,15 +289,28 @@ async def list_groups(db: AsyncSession = Depends(get_db)): counts.get(g.id, 0), names.get(g.parent_id) if g.parent_id else None, own.get(g.id, []), + can_manage_members=is_super or is_admin_role or g.id in leader_ids, + can_manage_group=is_super, + is_my_leader=g.id in leader_ids, ) for g in groups ] @router.get("/{group_id}", response_model=GroupDetailOut) -async def get_group(group_id: int, db: AsyncSession = Depends(get_db)): - """分组详情 + 成员列表""" +async def get_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """分组详情 + 成员列表(非超管只能看自己所属的组)""" group = await _get_group_or_404(db, group_id) + if not _is_super(current_user): + mine = await _my_group_ids(db, current_user) + if group_id not in mine: + raise HTTPException( + status.HTTP_403_FORBIDDEN, "你不在该分组中,无法查看" + ) own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else [])) members = (await db.execute( @@ -219,12 +328,19 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db)): parent = await db.get(BusinessGroup, group.parent_id) parent_name = parent.name if parent else None + is_super = _is_super(current_user) + is_admin_role = (current_user or {}).get("role") in ADMIN_ROLES + leader_ids = set() if is_super else await _leader_group_ids(db, current_user) + base = _to_out( group, await _effective_phases(db, group, own), len(members), parent_name, own.get(group.id, []), + can_manage_members=is_super or is_admin_role or group.id in leader_ids, + can_manage_group=is_super, + is_my_leader=group.id in leader_ids, ) return GroupDetailOut( **base.model_dump(), @@ -240,8 +356,16 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db)): @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED) -async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db)): - """新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。""" +async def create_group( + payload: GroupCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。 + + ⚠️ 仅超管 —— 「能建组」意味着「能新建一个组再把自己塞进去」,是提权入口。 + """ + _require_super(current_user) valid = _valid_phases() bad = [p for p in payload.phases if p not in valid] if bad: @@ -291,13 +415,21 @@ async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db)) @router.patch("/{group_id}", response_model=GroupOut) async def update_group( - group_id: int, payload: GroupUpdate, db: AsyncSession = Depends(get_db), + group_id: int, + payload: GroupUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), ): - """修改分组。 + """修改分组(含可见范围)。 + + ⚠️ **仅超管** —— 这是最关键的一条:`phases` 决定能看到哪些数据,谁能改它 + 谁就能给自己扩权(比如把本组改成「生产 + 售后」= 全厂)。 + 主管能管成员但不能碰范围,就是这个原因。 ⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」—— 这是一次批量权限变更,前端必须二次确认后再调。 """ + _require_super(current_user) group = await _get_group_or_404(db, group_id) if payload.name is not None and payload.name != group.name: @@ -358,13 +490,18 @@ async def update_group( @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)): - """删除分组。 +async def delete_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """删除分组(仅超管)。 ⚠️ **仅允许删空组**。有成员时返回 409,要求先移除成员或改为停用。 级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到 数据了。强制多走一步,出错时是可见的。 """ + _require_super(current_user) group = await _get_group_or_404(db, group_id) member_count = await db.scalar( @@ -398,10 +535,21 @@ async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)): @router.post("/{group_id}/members", response_model=GroupMemberOut, status_code=status.HTTP_201_CREATED) async def add_member( - group_id: int, payload: GroupMemberAdd, db: AsyncSession = Depends(get_db), + group_id: int, + payload: GroupMemberAdd, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), ): - """把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。""" + """把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。 + + 权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住 + (他本来就在组里),所以这里不构成提权。 + """ await _get_group_or_404(db, group_id) + if not await _can_manage_members(db, current_user, group_id): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以添加成员" + ) exists = await db.scalar( select(BusinessGroupMember.id).where( @@ -431,8 +579,13 @@ async def add_member( async def update_member( group_id: int, user_id: str, payload: GroupMemberUpdate, db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), ): """设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。""" + if not await _can_manage_members(db, current_user, group_id): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以调整组长" + ) member = (await db.execute( select(BusinessGroupMember).where( BusinessGroupMember.group_id == group_id, @@ -455,8 +608,16 @@ async def update_member( @router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_member(group_id: int, user_id: str, db: AsyncSession = Depends(get_db)): +async def remove_member( + group_id: int, user_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): """把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。""" + if not await _can_manage_members(db, current_user, group_id): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以移除成员" + ) result = await db.execute( delete(BusinessGroupMember).where( BusinessGroupMember.group_id == group_id, diff --git a/backend/app/schemas/group.py b/backend/app/schemas/group.py index d981827..3ef8c78 100644 --- a/backend/app/schemas/group.py +++ b/backend/app/schemas/group.py @@ -26,6 +26,10 @@ class GroupOut(BaseModel): 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): diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index d76ea00..dc777b4 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,6 +1,7 @@ import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom"; import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText, ShieldCheck } from "lucide-react"; import { useAuth } from "../../contexts/AuthContext"; +import { isSuperAdmin } from "../../constants/task"; const MENU = [ { @@ -50,6 +51,10 @@ const MENU = [ path: "/admin/audit", icon: ScrollText, description: "谁在何时操作了什么 · 含失败与被拒请求", + // 仅超级管理员可见 —— 审计里有全员的 IP、操作明细与失败记录, + // 对普通成员展示既无必要也多一层信息暴露面。 + // 后端 /audit/* 本来就挂了 require_admin,这里只是不给入口。 + superOnly: true, }, { title: "管理层大屏", @@ -64,6 +69,11 @@ export default function AdminLayout() { const navigate = useNavigate(); const { user, logout, isAuthenticated, loading } = useAuth(); + // 菜单按角色过滤:标了 superOnly 的(操作审计)只对超管显示。 + // 真正的拦截在后端(/audit/* 挂了 require_admin),这里只是不给入口, + // 否则普通用户点进去只会看到一堆 403。 + const visibleMenu = MENU.filter((m) => !m.superOnly || isSuperAdmin(user?.role)); + // 数据范围徽标 —— 让用户看得见「我为什么只看到这些」。 // 空范围(未分组)必须显眼:列表全空却没有任何提示,用户会以为系统坏了, // 这是最难排查的一类反馈。 @@ -115,7 +125,7 @@ export default function AdminLayout() {