Files
track-LICA/backend/app/api/v1/endpoints/groups.py
duxingchen 9598c3fb9f feat(分组): 成员改为标签云展示 + 添加成员支持多选批量
1) 成员不再一人一行
   原先用 antd Table:成员一多就要滚很久,右侧还大片留白。
   改为标签云(flex-wrap),一行能放好几个。
   交互一并简化:
     · 点标签正文 → 切换组长(悬停有提示)
     · 点 ×     → 移出分组
     · 无权限时两者都不出现,退化成纯展示
   组长用金色标签 + 皇冠图标,一眼可辨。

2) 添加成员支持多选
   Select 加 mode="multiple" + maxTagCount="responsive",可搜可多选、批量提交。
   后端 POST /groups/{id}/members 由单个 user_id 改为 user_ids 数组,
   返回 {added, skipped, skipped_names}:
     · 已在组内的**静默跳过**而非整批 409 —— 多选时难免选中已在组里的人,
       为此让整批失败体验很差;前端会把跳过的名单说清楚,
       否则用户会疑惑「明明选了 5 个,怎么只多了 3 个」
     · 批量里混入超管则**整批拒绝并指名道姓**,不制造「部分成功」这种
       难以解释的中间状态

实测(后端):
  批量加 3 人           → added 3
  重复提交(2 旧 1 新) → added 1 / skipped 2,整批仍成功
  混入超管              → 400「以下账号是超级管理员,业务分组对其不生效:sunxia」
  空列表                → 422
  测试成员已清理
2026-09-21 17:44:06 +08:00

688 lines
26 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,
GroupMemberOut,
GroupMembersAdd,
GroupMembersAddResult,
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)
async def _is_super_admin_account(username: str) -> bool:
"""这个 MOM 账号是不是超管。
要在 MOM 侧查Track 不存用户表)。查不到(账号不存在)时返回 False ——
交给后续流程处理,不在这里假装是超管。
"""
if not username:
return False
db = MomSessionLocal()
try:
from sqlalchemy import text
row = db.execute(
text("SELECT role FROM sys_user WHERE username LIKE :p LIMIT 1"),
{"p": f"%/{username}"},
).fetchone()
return bool(row) and (row[0] or "") == SUPER_ADMIN
except Exception:
# MOM 不可用时**不放行**为「不是超管」的反面 —— 这里失败意味着查不到身份,
# 保守做法是当作超管拦下(误拦只是加不进去,误放会留下脏数据)
return True
finally:
db.close()
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
⚠️ **排除超级管理员**:超管的数据范围是硬编码的全厂(见 resolve_data_scope
规则 1分组对他根本不生效。把超管放进分组只会造成两处困惑
· 界面上「他在这个组里」暗示他受这个组约束,但实际不受;
· 若再给他打组长标记,会产生「组长却不受组范围限制」的矛盾状态。
所以既不显示在候选里也不允许加入add_member 会再挡一道)。
注意这里**不复用 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
AND COALESCE(role, '') <> :super_admin
"""
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit, "super_admin": SUPER_ADMIN}
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=GroupMembersAddResult,
status_code=status.HTTP_201_CREATED)
async def add_members(
group_id: int,
payload: GroupMembersAdd,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""**批量**把成员加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。
权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住
(他本来就在组里),所以这里不构成提权。
语义:
· 已在组内的静默跳过(返回在 skipped 里),不报错 ——
多选时难免选中已在组里的人,为此整批失败体验很差。
· 只要有一个是超管就整批拒绝并指名道姓,避免"部分成功"这种难解释的状态。
"""
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, "只有超级管理员、主管或本组组长可以添加成员"
)
# 去重 + 去空白,保持用户选择的顺序
wanted = list(dict.fromkeys(u.strip() for u in payload.user_ids if u and u.strip()))
if not wanted:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "请至少选择一名成员")
# 超管不进分组:他的范围是硬编码全厂,分组对他不生效。
# 放进来的话界面会暗示「他受这个组约束」,与事实不符。
supers = [u for u in wanted if await _is_super_admin_account(u)]
if supers:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"以下账号是超级管理员,业务分组对其不生效:{''.join(supers)}",
)
already = set((await db.execute(
select(BusinessGroupMember.user_id).where(
BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id.in_(tuple(wanted)),
)
)).scalars().all())
to_add = [u for u in wanted if u not in already]
for u in to_add:
db.add(BusinessGroupMember(
group_id=group_id, user_id=u, is_leader=payload.is_leader,
))
await db.commit()
from app.services.mom_cache import get_display_names
all_names = get_display_names(wanted)
skipped = [u for u in wanted if u in already]
return GroupMembersAddResult(
added=[
GroupMemberOut(
user_id=u,
display_name=all_names.get(u) or u,
is_leader=payload.is_leader,
)
for u in to_add
],
skipped=skipped,
skipped_names=[all_names.get(u) or u for u in skipped],
)
@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()