Files
track-LICA/backend/app/api/v1/endpoints/groups.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

630 lines
23 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.

"""业务分组 API —— 全员可见,操作按角色分层
权限模型2026-09 调整:从「仅超管」放开为分层,但**操作边界必须收窄**
| 谁 | 看 | 改 |
|---------------------|------------------------|--------------------------------------|
| SUPER_ADMIN | 所有组 | 全部(建组/改组/删组/配范围/管成员) |
| 主管(SUPERVISOR) | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
| 组长(is_leader) | 自己所属的组 | **仅本组成员** |
| 普通成员 | 自己所属的组 | 无(只读) |
⚠️ **为什么「主管」不能改可见范围、不能建删组**
按数据范围规则,被分进组的 SUPERVISOR 会从「全厂」降级为只看本组。
若允许他改范围,他只要把自己那组改成「生产 + 售后」就恢复全厂视野;
若能建组,他新建一个组再把自己塞进去,同样绕过。**能给自己加组 = 提权**。
所以「管成员」与「配范围」必须分开:前者安全(组长给自己加组会被唯一约束
挡住,移出自己只是失去权限),后者是提权入口。
写操作会被 audit_middleware 自动采集 —— 分组变更是高权限动作,追责必须有据。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.lifecycle import PHASE_LABELS
from app.core.mom_database import MomSessionLocal
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,
BusinessGroupPhase,
)
from app.schemas.group import (
GroupCreate,
GroupDetailOut,
GroupMemberAdd,
GroupMemberOut,
GroupMemberUpdate,
GroupOut,
GroupUpdate,
MemberCandidate,
PhaseOption,
)
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,
"只有超级管理员可以新建、修改或删除分组,以及调整分组的可见范围",
)
# ============================================================
# 内部工具
# ============================================================
def _valid_phases() -> dict[str, str]:
"""合法的 phase 取值 → 中文标签(单一事实来源是 core/lifecycle.py"""
return dict(PHASE_LABELS)
async def _load_phases(db: AsyncSession, group_ids: list[int]) -> dict[int, list[str]]:
"""批量取这些组**自己配置**的可见范围(不含继承)"""
if not group_ids:
return {}
rows = await db.execute(
select(BusinessGroupPhase.group_id, BusinessGroupPhase.phase)
.where(BusinessGroupPhase.group_id.in_(group_ids))
)
out: dict[int, list[str]] = {}
for gid, ph in rows.all():
out.setdefault(gid, []).append(ph)
return out
async def _effective_phases(
db: AsyncSession, group: BusinessGroup, own: dict[int, list[str]],
) -> list[str]:
"""实际生效的可见范围:自己配了就用,没配则向上取父组的。
继承让「生产大组配一次 PRODUCTION下面的生产/测试小组都不用再配」成立。
"""
mine = own.get(group.id)
if mine:
return sorted(mine)
if group.parent_id:
return sorted(own.get(group.parent_id, []))
return []
async def _member_counts(db: AsyncSession) -> dict[int, int]:
rows = await db.execute(
select(BusinessGroupMember.group_id, func.count())
.group_by(BusinessGroupMember.group_id)
)
return {gid: cnt for gid, cnt in rows.all()}
def _to_out(group: BusinessGroup, phases: list[str], count: int,
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,
parent_id=group.parent_id,
parent_name=parent_name,
description=group.description,
sort_order=group.sort_order,
is_active=group.is_active,
phases=sorted(own_phases),
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,
)
async def _get_group_or_404(db: AsyncSession, group_id: int) -> BusinessGroup:
group = await db.get(BusinessGroup, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"分组 {group_id} 不存在")
return group
# ============================================================
# 元数据:可选阶段
# 放在 /{group_id} 之前注册,否则 "phases" 会被当成 group_id 解析
# ============================================================
@router.get("/phase-options", response_model=list[PhaseOption])
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()]
@router.get("/member-candidates", response_model=list[MemberCandidate])
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
注意这里**不复用 users.py 的端点函数**:那个函数与 FastAPI 的 Query 默认值
耦合,直接调用拿到的是 Query 对象而非值。所以照抄同一条 SQL 的写法,
但部门条件取自同一处 settings.ORG_DEPARTMENT口径不会漂移。
"""
db = MomSessionLocal()
try:
base_sql = """
SELECT username,
SPLIT_PART(username, '/', 1) AS full_name
FROM sys_user
WHERE department = :dept
"""
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
if keyword.strip():
sql_text = base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim"
params["kw"] = f"%{keyword.strip()}%"
else:
sql_text = base_sql + " ORDER BY username LIMIT :lim"
from sqlalchemy import text
rows = db.execute(text(sql_text), params).fetchall()
return [
MemberCandidate(
username=row.username.split("/")[-1] if "/" in row.username else row.username,
full_name=row.full_name or row.username,
)
for row in rows
]
except Exception as e:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, f"MOM 用户查询失败: {str(e)}"
)
finally:
db.close()
# ============================================================
# 组的 CRUD
# ============================================================
@router.get("", response_model=list[GroupOut])
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,
await _effective_phases(db, g, own),
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),
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(
select(BusinessGroupMember)
.where(BusinessGroupMember.group_id == group.id)
.order_by(BusinessGroupMember.is_leader.desc(), BusinessGroupMember.user_id)
)).scalars().all()
# 成员姓名走 MOM带 2h TTL 缓存,见 mom_cache
from app.services.mom_cache import get_display_names
name_map = get_display_names([m.user_id for m in members]) if members else {}
parent_name = None
if group.parent_id:
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(),
members=[
GroupMemberOut(
user_id=m.user_id,
display_name=name_map.get(m.user_id) or m.user_id,
is_leader=m.is_leader,
)
for m in members
],
)
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
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:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"非法的阶段取值: {bad}")
dup = await db.scalar(select(BusinessGroup.id).where(BusinessGroup.name == payload.name))
if dup:
raise HTTPException(status.HTTP_409_CONFLICT, f"分组名「{payload.name}」已存在")
if payload.parent_id is not None:
parent = await db.get(BusinessGroup, payload.parent_id)
if not parent:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "上级分组不存在")
if parent.parent_id is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只支持两级:不能挂在子组下")
group = BusinessGroup(
name=payload.name,
parent_id=payload.parent_id,
description=payload.description,
sort_order=payload.sort_order,
)
db.add(group)
await db.flush()
for p in payload.phases:
db.add(BusinessGroupPhase(group_id=group.id, phase=p))
await db.commit()
await db.refresh(group)
# 生效范围要算上继承 —— 否则新建子组时返回的 effective_phases 是空的,
# 与紧接着的列表查询结果对不上(前端直接拿返回值渲染会闪一下「无范围」)
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
parent_name = None
if group.parent_id:
parent = await db.get(BusinessGroup, group.parent_id)
parent_name = parent.name if parent else None
return _to_out(
group,
await _effective_phases(db, group, own),
0,
parent_name,
own.get(group.id, []),
)
@router.patch("/{group_id}", response_model=GroupOut)
async def update_group(
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:
dup = await db.scalar(
select(BusinessGroup.id).where(
BusinessGroup.name == payload.name, BusinessGroup.id != group_id,
)
)
if dup:
raise HTTPException(status.HTTP_409_CONFLICT, f"分组名「{payload.name}」已存在")
group.name = payload.name
if payload.parent_id is not None:
if payload.parent_id == group_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "不能把自己设为自己的上级")
parent = await db.get(BusinessGroup, payload.parent_id)
if not parent:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "上级分组不存在")
if parent.parent_id is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只支持两级:不能挂在子组下")
group.parent_id = payload.parent_id
if payload.description is not None:
group.description = payload.description
if payload.sort_order is not None:
group.sort_order = payload.sort_order
if payload.is_active is not None:
group.is_active = payload.is_active
# 可见范围:整组覆盖式更新
if payload.phases is not None:
valid = _valid_phases()
bad = [p for p in payload.phases if p not in valid]
if bad:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"非法的阶段取值: {bad}")
await db.execute(
delete(BusinessGroupPhase).where(BusinessGroupPhase.group_id == group_id)
)
for p in payload.phases:
db.add(BusinessGroupPhase(group_id=group_id, phase=p))
await db.commit()
await db.refresh(group)
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
counts = await _member_counts(db)
parent_name = None
if group.parent_id:
parent = await db.get(BusinessGroup, group.parent_id)
parent_name = parent.name if parent else None
return _to_out(
group,
await _effective_phases(db, group, own),
counts.get(group.id, 0),
parent_name,
own.get(group.id, []),
)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
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(
select(func.count()).select_from(BusinessGroupMember)
.where(BusinessGroupMember.group_id == group_id)
) or 0
if member_count:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"该分组下还有 {member_count} 名成员。请先移除成员,或改为「停用」而不是删除。",
)
children = await db.scalar(
select(func.count()).select_from(BusinessGroup)
.where(BusinessGroup.parent_id == group_id)
) or 0
if children:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"该分组下还有 {children} 个子组,请先处理子组。",
)
await db.delete(group)
await db.commit()
# ============================================================
# 成员管理
# ============================================================
@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),
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(
BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id == payload.user_id,
)
)
if exists:
raise HTTPException(status.HTTP_409_CONFLICT, "该成员已在此分组中")
member = BusinessGroupMember(
group_id=group_id, user_id=payload.user_id, is_leader=payload.is_leader,
)
db.add(member)
await db.commit()
from app.services.mom_cache import get_display_names
name_map = get_display_names([payload.user_id])
return GroupMemberOut(
user_id=payload.user_id,
display_name=name_map.get(payload.user_id) or payload.user_id,
is_leader=payload.is_leader,
)
@router.patch("/{group_id}/members/{user_id}", response_model=GroupMemberOut)
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,
BusinessGroupMember.user_id == user_id,
)
)).scalar_one_or_none()
if not member:
raise HTTPException(status.HTTP_404_NOT_FOUND, "该成员不在此分组中")
member.is_leader = payload.is_leader
await db.commit()
from app.services.mom_cache import get_display_names
name_map = get_display_names([user_id])
return GroupMemberOut(
user_id=user_id,
display_name=name_map.get(user_id) or user_id,
is_leader=payload.is_leader,
)
@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),
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,
BusinessGroupMember.user_id == user_id,
)
)
if result.rowcount == 0:
raise HTTPException(status.HTTP_404_NOT_FOUND, "该成员不在此分组中")
await db.commit()