diff --git a/backend/app/api/v1/endpoints/groups.py b/backend/app/api/v1/endpoints/groups.py
new file mode 100644
index 0000000..b96fd34
--- /dev/null
+++ b/backend/app/api/v1/endpoints/groups.py
@@ -0,0 +1,468 @@
+"""业务分组管理 API —— **仅超级管理员**可访问
+
+⚠️ 为什么只有 SUPER_ADMIN 能管分组,SUPERVISOR 不行:
+
+ 按数据范围规则,被显式分进组的 SUPERVISOR 会从「全厂」**降级**为只看本组。
+ 如果允许 SUPERVISOR 管理分组,那么他被分组之后,只要把自己从组里移出去
+ 就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他完全无效。
+
+ 所以这里用 require_roles(SUPER_ADMIN),**不能**用 require_admin
+ (后者含 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.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.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=["业务分组"],
+ dependencies=[Depends(require_roles(SUPER_ADMIN))],
+)
+
+
+# ============================================================
+# 内部工具
+# ============================================================
+
+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]) -> GroupOut:
+ 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,
+ )
+
+
+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():
+ """可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值"""
+ 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),
+):
+ """候选人下拉 —— 复用与 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)):
+ """列出全部业务分组(含停用的),带成员数与生效范围"""
+ groups = (await db.execute(
+ select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id)
+ )).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}
+
+ 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, []),
+ )
+ for g in groups
+ ]
+
+
+@router.get("/{group_id}", response_model=GroupDetailOut)
+async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
+ """分组详情 + 成员列表"""
+ group = await _get_group_or_404(db, group_id)
+
+ 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
+
+ base = _to_out(
+ group,
+ await _effective_phases(db, group, own),
+ len(members),
+ parent_name,
+ own.get(group.id, []),
+ )
+ 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)):
+ """新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。"""
+ 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),
+):
+ """修改分组。
+
+ ⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」——
+ 这是一次批量权限变更,前端必须二次确认后再调。
+ """
+ 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)):
+ """删除分组。
+
+ ⚠️ **仅允许删空组**。有成员时返回 409,要求先移除成员或改为停用。
+ 级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到
+ 数据了。强制多走一步,出错时是可见的。
+ """
+ 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),
+):
+ """把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。"""
+ await _get_group_or_404(db, group_id)
+
+ 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),
+):
+ """设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。"""
+ 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)):
+ """把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。"""
+ 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()
diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py
index 9134603..80b08c8 100644
--- a/backend/app/api/v1/router.py
+++ b/backend/app/api/v1/router.py
@@ -18,6 +18,7 @@ from app.api.v1.endpoints.webhooks import router as webhooks_router
from app.api.v1.endpoints.external_products import router as external_products_router
from app.api.v1.endpoints.screen import router as screen_router
from app.api.v1.endpoints.audit import router as audit_router
+from app.api.v1.endpoints.groups import router as groups_router
api_router = APIRouter()
@@ -39,3 +40,4 @@ api_router.include_router(webhooks_router)
api_router.include_router(external_products_router)
api_router.include_router(screen_router)
api_router.include_router(audit_router)
+api_router.include_router(groups_router)
diff --git a/backend/app/schemas/group.py b/backend/app/schemas/group.py
new file mode 100644
index 0000000..d981827
--- /dev/null
+++ b/backend/app/schemas/group.py
@@ -0,0 +1,65 @@
+"""业务分组 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
+
+
+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 # 真实姓名,仅供界面展示
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e774b91..bcd8b31 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -45,6 +45,7 @@ const AdminAuditLogPage = lazy(() => import("./pages/admin/AdminAuditLogPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
+const AdminGroupsPage = lazy(() => import("./pages/admin/AdminGroupsPage"));
export default function App() {
return (
@@ -80,6 +81,7 @@ export default function App() {
仅超级管理员可管理业务分组
++ 组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。 +
++ 点击一行查看详情与成员。小组未单独配置范围时,自动继承所属大组。 +
+