Files

707 lines
27 KiB
Python
Raw Permalink Normal View History

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
"""业务分组 API —— 全员可见,操作按角色分层
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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
权限模型(2026-09 调整:从「仅超管」放开为分层,但**操作边界必须收窄**):
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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
| 谁 | 看 | 改 |
|---------------------|------------------------|--------------------------------------|
| SUPER_ADMIN | 所有组 | 全部(建组/改组/删组/配范围/管成员) |
| 主管(SUPERVISOR) | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
| 组长(is_leader) | 自己所属的组 | **仅本组成员** |
| 普通成员 | 自己所属的组 | 无(只读) |
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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
⚠️ **为什么「主管」不能改可见范围、不能建删组**:
按数据范围规则,被分进组的 SUPERVISOR 会从「全厂」降级为只看本组。
若允许他改范围,他只要把自己那组改成「生产 + 售后」就恢复全厂视野;
若能建组,他新建一个组再把自己塞进去,同样绕过。**能给自己加组 = 提权**。
所以「管成员」与「配范围」必须分开:前者安全(组长给自己加组会被唯一约束
挡住,移出自己只是失去权限),后者是提权入口。
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
写操作会被 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
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
from app.core.roles import ADMIN_ROLES, SUPER_ADMIN
from app.services.auth_service import get_current_user
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
from app.models.business_group import (
BusinessGroup,
BusinessGroupMember,
BusinessGroupPhase,
)
from app.schemas.group import (
GroupCreate,
GroupDetailOut,
GroupMemberOut,
GroupMembersAdd,
GroupMembersAddResult,
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
GroupMemberUpdate,
GroupOut,
GroupUpdate,
MemberCandidate,
PhaseOption,
)
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
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()
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
def _require_super(user: dict) -> None:
"""建组 / 改组 / 删组 / 配范围 —— 这些是提权入口,只给超管"""
if not _is_super(user):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"只有超级管理员可以新建、修改或删除分组,以及调整分组的可见范围",
)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
# ============================================================
# 内部工具
# ============================================================
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,
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
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 推不出来。
"""
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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,
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
can_manage_members=can_manage_members,
can_manage_group=can_manage_group,
is_my_leader=is_my_leader,
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
)
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])
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
async def list_phase_options(current_user: dict = Depends(get_current_user)):
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
"""可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值"""
return [PhaseOption(value=v, label=l) for v, l in _valid_phases().items()]
@router.get("/member-candidates", response_model=list[MemberCandidate])
async def list_member_candidates(
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
keyword: str = Query("", description="按姓名/账号模糊搜索"),
limit: int = Query(500, ge=1, le=1000),
group_id: int | None = Query(None, description="传了则把已在该组的成员排除掉"),
db: AsyncSession = Depends(get_db),
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
current_user: dict = Depends(get_current_user),
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
):
"""候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT)。
⚠️ **排除超级管理员**:超管的数据范围是硬编码的全厂(见 resolve_data_scope
规则 1),分组对他根本不生效。把超管放进分组只会造成两处困惑:
· 界面上「他在这个组里」暗示他受这个组约束,但实际不受;
· 若再给他打组长标记,会产生「组长却不受组范围限制」的矛盾状态。
所以既不显示在候选里,也不允许加入(add_member 会再挡一道)。
⚠️ 传 `group_id` 时会**排除已在该组的成员** —— 否则会出现「这个人明明已经在
成员名单里,选人时却还能再选一次」的矛盾,用户会以为系统没生效。
⚠️ MOM 与 Track 是**两个独立的库**,没法 JOIN,所以先在本库查出该组的成员
集合,再在内存里排掉。LICA 只有十几人,这个做法完全够用。
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
注意这里**不复用 users.py 的端点函数**:那个函数与 FastAPI 的 Query 默认值
耦合,直接调用拿到的是 Query 对象而非值。所以照抄同一条 SQL 的写法,
但部门条件取自同一处 settings.ORG_DEPARTMENT,口径不会漂移。
"""
# 先取本组成员(Track 库)
exclude: set[str] = set()
if group_id is not None:
rows = await db.execute(
select(BusinessGroupMember.user_id)
.where(BusinessGroupMember.group_id == group_id)
)
exclude = {r[0] for r in rows.all()}
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
"""
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit, "super_admin": SUPER_ADMIN}
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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()
out: list[MemberCandidate] = []
for row in rows:
acct = row.username.split("/")[-1] if "/" in row.username else row.username
if acct in exclude: # 已在组内 —— 不再作为候选
continue
out.append(MemberCandidate(
username=acct, full_name=row.full_name or row.username,
))
return out
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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])
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
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()
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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}
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
# 能力标记一次算好,避免在循环里反复查库
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)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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, []),
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
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,
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
)
for g in groups
]
@router.get("/{group_id}", response_model=GroupDetailOut)
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
async def get_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""分组详情 + 成员列表(非超管只能看自己所属的组)"""
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
group = await _get_group_or_404(db, group_id)
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
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, "你不在该分组中,无法查看"
)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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
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
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)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
base = _to_out(
group,
await _effective_phases(db, group, own),
len(members),
parent_name,
own.get(group.id, []),
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
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,
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
)
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)
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
async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。
⚠️ 仅超管 —— 「能建组」意味着「能新建一个组再把自己塞进去」,是提权入口。
"""
_require_super(current_user)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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(
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
group_id: int,
payload: GroupUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
):
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
"""修改分组(含可见范围)。
⚠️ **仅超管** —— 这是最关键的一条:`phases` 决定能看到哪些数据,谁能改它
谁就能给自己扩权(比如把本组改成「生产 + 售后」= 全厂)。
主管能管成员但不能碰范围,就是这个原因。
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」——
这是一次批量权限变更,前端必须二次确认后再调。
"""
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
_require_super(current_user)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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)
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
async def delete_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""删除分组(仅超管)。
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
⚠️ **仅允许删空组**。有成员时返回 409,要求先移除成员或改为停用。
级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到
数据了。强制多走一步,出错时是可见的。
"""
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
_require_super(current_user)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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,
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
status_code=status.HTTP_201_CREATED)
async def add_members(
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
group_id: int,
payload: GroupMembersAdd,
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
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
):
"""**批量**把成员加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。
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
权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住
(他本来就在组里),所以这里不构成提权。
语义:
· 已在组内的静默跳过(返回在 skipped 里),不报错 ——
多选时难免选中已在组里的人,为此整批失败体验很差。
· 只要有一个是超管就整批拒绝并指名道姓,避免"部分成功"这种难解释的状态。
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
"""
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
await _get_group_or_404(db, group_id)
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
if not await _can_manage_members(db, current_user, group_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以添加成员"
)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
# 去重 + 去空白,保持用户选择的顺序
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(
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id.in_(tuple(wanted)),
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
)
)).scalars().all())
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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,
))
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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],
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
)
@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),
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
current_user: dict = Depends(get_current_user),
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
):
"""设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。"""
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
if not await _can_manage_members(db, current_user, group_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以调整组长"
)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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)
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
async def remove_member(
group_id: int, user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
"""把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。"""
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
if not await _can_manage_members(db, current_user, group_id):
raise HTTPException(
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以移除成员"
)
feat(分组权限): 分组管理接口 + 管理页 + 端到端验收 后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
2026-09-21 17:10:33 +08:00
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()