BiSheng 租户用户管理模型收敛:从 UserTenant 到主部门派生的 UI/API 对齐实战(F024)
【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng
导读
本文基于 BiSheng(开源 LLM DevOps 平台)v2.5.1 的 F024 特性《租户用户管理 UI 与 F012 派生模型对齐》,完整拆解一次典型的"模型收敛"改造:当底层多租户归属模型从"用户显式加入租户"演进为"主部门自动派生"后,如何把历史遗留的租户用户管理 UI 与 API 契约同步收敛到单一权威模型上。读完本文,你将掌握:租户成员列表数据源切换的 SQL 设计、410 Gone 端点废弃模式、Service/DAO 层的兼容性改造手法,以及一套可复用的"幽灵数据"治理与零风险回滚策略。
1. 背景:多租户模型两次演进留下的"两层语义"
BiSheng 的多租户归属模型在 v2.5.x 系列经历了两次关键演进:
- v2.5.0(F010):按"用户可属多租户、登录时选择"的模型设计租户管理 UI 与 API,通过
UserTenant表显式记录用户与租户的关系,并提供"添加用户 / 移除用户"的操作入口。 - v2.5.1(F011 + F012):模型转向Tenant 树形结构 + leaf 由主部门自动派生。用户的"真实归属租户"不再由
UserTenant行决定,而是由其主部门(primary department)挂载在哪个租户子树下决定,由TenantResolver.resolve_user_leaf_tenant统一解析;同时POST /api/v1/user/switch-tenant自 F011 起即返回 410 Gone(见 user_tenant.py 中switch_tenant_deprecated的实现)。
然而 F010 时代的"添加/移除用户"UI 与对应后端 API 并未同步收敛,导致同一个租户下存在两层语义:
- 操作员困惑:
UserTenant里有行的用户(UI 显示"在该租户的成员")≠ 实际登录会进入该租户的用户(由主部门派生决定)。排查 P0 级同步缺陷(_apply_local_primary_department_change漏触发 sync)时,必须反复区分"UI 上看到的成员"与"实际归属"两套数据。 - 数据漂移:
TenantService.aadd_users写入的UserTenant行是"幽灵成员"——它不改变 leaf 派生结果,且 sync 链路只维护is_active=1的行,这类残留行永远无法被自然消除。 - 合规盲区:
enforce_transfer_before_relocate=true配置在租户用户管理 UI 这条路径上完全失效(该路径根本不走UserTenantSyncService同步)。
F024 的使命就是把 UI / API 整体收敛到"主部门派生"这单一模型,消除两层语义。本特性定位为修复型(P1,不阻塞主线),不新增任何模型字段。
2. 目标与用户故事
F024 围绕三个核心诉求展开:
- 故事 A(操作员视角对齐):作为集团 IT 全局超管 / 子租户管理员,我希望「租户管理 → 用户管理」展示的成员列表与"用户实际归属哪个租户"一致,不再被幽灵成员误导。
- 故事 B(操作动作收敛):作为租户管理员,我希望"把人加入/移出该租户"只有一个权威入口,不再需要在租户用户管理 / 部门成员管理两个 UI 之间猜测哪个真起作用。当前两个入口语义不等价:部门入口改主部门 → 真改归属;租户入口写
UserTenant→ 不改归属,且下次 sync 还会被回填。 - 故事 C(API 契约清理):作为集成 BiSheng API 的外部系统/脚本,我希望明确判断哪些租户成员管理 API 仍可用,不再写出"加了用户但用户实际登录进不了"的脚本。
3. 验收标准全景(AC-01 ~ AC-15)
AC-ID 在特性内唯一,格式
AC-NN;测试任务通过覆盖 AC: AC-NN追溯到此表。
3.1 列表数据源切换(核心)
| ID | 角色 | 操作 | 预期结果 |
|---|---|---|---|
| AC-01 | 全局超管 | GET /api/v1/tenants/{id}/users?page=1&page_size=20 | 200;返回主部门挂在该租户子树的 User 列表(JOINDepartment+UserDepartment.is_primary=1),不再返回仅有UserTenant行但主部门不在该租户的幽灵成员 |
| AC-02 | 全局超管 | 用户 X 主部门刚从 Tenant A 调到 Tenant B(UserTenantSyncService.sync_user已完成) | Tenant A 的成员列表立刻不含 X;Tenant B 的成员列表立刻含X;不依赖 UserTenant 行的清理 |
| AC-03 | 全局超管 | 用户 X 在 Tenant B 子树有兼职部门(is_primary=0),主部门在 Tenant A | Tenant A 的列表含 X;Tenant B 的列表不含 X(兼职不改归属,与 F012 一致) |
| AC-04 | 全局超管 | 关键字搜索keyword=alice | 按user.user_nameLIKE 过滤;语义与切换前一致 |
3.2 操作按钮收敛
| ID | 角色 | 操作 | 预期结果 |
|---|---|---|---|
| AC-05 | 全局超管 | 打开「租户管理 → 用户管理」对话框 | 不再显示「添加用户」picker 和「移除用户」按钮;显示提示文案"添加/移除成员请到 组织 → 部门管理",链接跳转部门页 |
| AC-06 | 全局超管 | 「设为管理员 / 取消管理员」按钮 | 保留,行为不变(写/撤 OpenFGAtenant:#admintuple,不动 UserTenant 行) |
| AC-07 | Root Tenant 用户管理对话框 | 进入对话框 | 不显示「设为管理员/取消管理员」(Root admin 由系统级is_admin标志管理,沿用现有isRootTenant短路) |
3.3 API 端点 deprecation
| ID | 角色 | 操作 | 预期结果 |
|---|---|---|---|
| AC-08 | 任意调用方 | POST /api/v1/tenants/{id}/users | HTTP 410 Gone;body{"error": "410 Gone", "message": "管理租户成员请通过修改用户主部门完成(F012 派生模型)", "migration": "PUT /api/v1/department/{dept_id}/members/{user_id}/apply-edit"} |
| AC-09 | 任意调用方 | DELETE /api/v1/tenants/{id}/users/{user_id} | HTTP 410 Gone;body 同上 |
| AC-10 | 任意调用方 | GET /api/v1/tenants/{id}/users | 保留,行为按 AC-01~04(数据源切换) |
| AC-11 | 任意调用方 | POST /api/v1/tenants/{id}/admins/{user_id}/DELETE /api/v1/tenants/{id}/admins/{user_id} | 保留,行为不变 |
3.4 残留UserTenant行的处理
| ID | 角色 | 操作 | 预期结果 |
|---|---|---|---|
| AC-12 | 升级到含 F024 的部署 | DB 中存在 v2.5.0 时期aadd_users写入的UserTenant行(主部门不在该租户子树) | 列表查询不展示这些行;DB 不动;F012sync_user行为完全不变 |
| AC-13 | 升级回滚 | 降级到不含 F024 的版本 | 数据零变更,旧版本继续按原查询返回(含幽灵成员);回滚最干净 |
3.5 兼容与可观察
| ID | 角色 | 操作 | 预期结果 |
|---|---|---|---|
| AC-14 | 调用废弃端点的脚本 | 收到 410 后查日志 | nginx/access.log 中标记endpoint_deprecated=true;后端 logger.warning 记录caller_ip + tenant_id + user_id便于排查残留集成 |
| AC-15 | F012 / F019 / 现有同步链路 | 升级后跑全量 sync | UserTenantSyncService.sync_user行为完全不变(只读/写is_active=1行);F019 admin-scope 不依赖列表查询,不受影响 |
4. 关键架构决策(AD-01 ~ AD-06)
F024 的每项设计取舍都有明确记录,理解这些决策有助于在实际项目中复用:
| ID | 决策点 | 选项 | 结论 | 理由 |
|---|---|---|---|---|
| AD-01 | 列表数据源 | A: 继续查UserTenant但加 status 过滤 / B: 改为 JOINDepartment.path+UserDepartment.is_primary=1/ C: 同时支持两种视图(带 toggle) | B | A 仍以衍生表为权威源,治标不治本;C 又把两种语义暴露给操作员,违背收敛初衷;B 直接以 source-of-truth 查询,与TenantResolver.resolve_user_leaf_tenant同源 |
| AD-02 | 废弃端点处理 | A: 保留端点但写 deprecated header / B: 直接 410 Gone / C: 改为转发到部门 API | B | 与switch-tenant的 410 Gone 模式一致,简单直接;A 留下"看似可用"的歧义路径;C 跨域转发涉及主部门解析逻辑泄漏到不合适的层 |
| AD-03 | 残留 UserTenant 行处理 | A: 直接 hard-delete / B: 软删(status='legacy')+ 启动钩子 reconcile / C: 不动数据,新查询不再以 UserTenant 为权威源 | C | 权威源整体迁移后,幽灵行根本不会被新查询触达,无需过滤更无需迁移;F024 落地后 POST/DELETE 已 410、aadd_usersservice 仅内部脚本可达,legacy 集合是封闭历史集合,自然消亡;C 不动数据 → 回滚最干净 → 升级风险最低 |
| AD-04 | UI 提示位置 | A: 对话框顶部 banner / B: 表格空状态提示 / C: 隐藏直接消失 | A | 操作员从「添加用户」按钮消失到理解"为啥消失"需要引导;C 信息密度不足;B 只在空列表时可见 |
| AD-05 | Service 层方法保留与否 | A: 保留aadd_users/aremove_user给内部脚本用 / B: 与 API 同步删除 | A | 内部 worker / migration 工具可能用到;保留 service 方法 + deprecation warning + 标 internal-only,比删了再补回来更稳 |
| AD-06 | 新查询接口的命名 | A: 沿用GET /tenants/{id}/users(语义内变) / B: 新增GET /tenants/{id}/members-by-primary-dept | A | 现有前端调用点已散落,改路由会触发更多前端回归;语义内变 + 在 spec/changelog 明确说明,性价比更高 |
5. API 契约变更详解
5.1 端点变更一览
| Method | Path | F024 后行为 | 关联 AC |
|---|---|---|---|
| GET | /api/v1/tenants/{id}/users | 数据源变:JOINDepartment.path+UserDepartment.is_primary=1;返回 schema 不变 | AC-01~04, AC-10 |
| POST | /api/v1/tenants/{id}/users | 410 Gone | AC-08 |
| DELETE | /api/v1/tenants/{id}/users/{user_id} | 410 Gone | AC-09 |
| POST | /api/v1/tenants/{id}/admins/{user_id} | 不变 | AC-11 |
| DELETE | /api/v1/tenants/{id}/admins/{user_id} | 不变 | AC-11 |
5.2 410 响应示例
HTTP/1.1 410 Gone { "error": "410 Gone", "message": "管理租户成员请通过修改用户主部门完成(F012 派生模型)", "migration": "POST /api/v1/department/{dept_id}/members/{user_id}/apply-edit", "deprecated_since": "v2.5.1", "removed_in": "v2.6.0" }5.3 源码级实现要点
在真实仓库中,410 处理已落地为共享响应体。参见 tenant_users.py:_GONE_RESPONSE被add_users_deprecated与remove_user_deprecated两个 handler 共用,字段与 spec 一致(error/detail/migration/deprecated_since/removed_in)。
有两个容易被忽略的工程细节值得注意:
- 两个 410 handler 不声明任何 auth 依赖。
switch_tenant_deprecated与add_users_deprecated均直接返回 410,且 docstring 明确说明:不带UserPayload依赖,是为了避免 SDK 客户端重试时落入 401/403 而误以为"重新登录后还能用"。这一点在 test_tenant_membership_endpoints_deprecated.py 中有专门的测试test_deprecated_endpoints_have_no_auth_dependency,通过遍历 FastAPI 路由的dependant.dependencies断言不包含UserPayload。 - 错误码复用:不新增模块错误码(模块编码沿用 192
tenant),410 响应形态与switch-tenant端点完全一致(参见 user_tenant.py 的switch_tenant_deprecated)。
6. Service 层与数据源切换的源码实现
6.1 方法级变更清单
| 方法 | 文件 | 变更 |
|---|---|---|
TenantService.aget_tenant_users | tenant_service.py | 查询逻辑从UserTenantDao.aget_tenant_users切到新 DAO 方法UserDepartmentDao.aget_users_by_tenant_subtree(tenant_id);返回 schema 不变({data, total}) |
TenantService.aadd_users | 同上 | 加@deprecated语义(源码中通过warnings.warn+logger.warning实现)+ 保留实现给内部脚本用;公开 API 端点改 410 |
TenantService.aremove_user | 同上 | 同上 |
6.2 Service 层数据源切换
aget_tenant_users的 docstring 明确记载了这次切换的动机:
F024: data source switched from
UserTenantDao.aget_tenant_users(queries UserTenant rows) toUserDepartmentDao.aget_users_by_tenant_subtree(queries primary-dept-in-tenant-subtree). Aligns withTenantResolverso v2.5.0aadd_usersresidue rows do not surface as phantom members. Return shape unchanged.
实现上只是替换 DAO 调用,返回结构{'data': users, 'total': total}保持不变,因此 GET 端点的调用方零感知。
6.3 新 DAO:aget_users_by_tenant_subtree
这是 F024 的核心查询方法,位于 department.py。spec 中给出的伪代码如下:
# Tenant root_dept_path 解析略 SELECT u.user_id, u.user_name, u.avatar, ut.last_access_time AS join_time FROM user u JOIN user_department ud ON ud.user_id = u.user_id AND ud.is_primary = 1 JOIN department d ON d.id = ud.department_id LEFT JOIN user_tenant ut ON ut.user_id = u.user_id AND ut.tenant_id = :tenant_id AND ut.is_active = 1 WHERE d.path LIKE :root_dept_path_prefix AND (:keyword IS NULL OR u.user_name LIKE :keyword) ORDER BY ut.last_access_time DESC NULLS LAST, u.user_id LIMIT :page_size OFFSET :offset真实实现与伪代码高度一致,并补充了三个关键细节:
- root_dept 解析与兼容回退:先查
tenant.root_dept_id对应部门行的path,用Department.path.like(f"{root_path}%")匹配整个子树;若租户没有root_dept_id(v2.5.0 或早期 v2.5.1 的存量数据),回退到Department.tenant_id == tenant_id的扁平模型语义,保证迁移期 UI 不中断。 - DISTINCT 防御:列表查询先用
select(UserDepartment.user_id).join(Department)...distinct().subquery()取出去重后的 user_id 集合,再 JOINUser。注释说明这是为了防御历史数据中可能存在的多主部门异常(G1 修复已封堵写入路径,但 DISTINCT 让存量数据也能干净渲染)。 - UserTenant 仅作装饰:
UserTenant以LEFT JOIN(限定is_active = 1)方式挂载,仅用于输出join_time(即last_access_time),查询从不以 UserTenant 行作为过滤条件——这正是 spec 第 7 节强调的"本查询不需要 NOT EXISTS 过滤"的原因:权威源是主部门视图,幽灵行天然不会出现。
6.4 数量口径对齐(phase-2)
仓库中还包含 F024 的 phase-2 跟进:acount_users_by_tenant_subtree与_aresolve_subtree_root_paths让租户列表/详情的user_count 列与用户对话框的列表源完全一致(见 tenant_service.py 的注释与调用),避免出现"计数非零但详情对话框为空"的另一种幽灵展示。这也印证了 spec 中"列表权威源统一"的设计主线。
6.5 与 TenantResolver 的同源一致性
spec 反复强调"与TenantResolver.resolve_user_leaf_tenant同源",防止 UI 显示与 leaf 派生分叉。在 tenant_resolver.py 中,resolve_user_leaf_tenant负责解析用户实际归属的租户(无主部门或挂载点异常时回退 Root),而 user_tenant_sync_service.py 中的sync_user在用户主部门变化后据此同步UserTenant的is_active行。F024 让"管理列表"与"运行时解析"走同一条主部门派生路径,从根上消除两套语义。
7. 数据库与 Domain 模型:零变更策略
- 不新增表,不变更字段:
UserTenant表 schema、status取值、is_active语义全部保持不变。 - F024 仅在新 DAO 查询里调整数据源,不动 DB 一行数据。spec 明确:残留的幽灵
UserTenant行是一个封闭历史集合(POST/DELETE 已 410、aadd_usersservice 仅内部脚本可达),会随新查询自然"看不见"。 - Domain 模型 / DTO 无新增:
bisheng/tenant/domain/schemas/tenant_schema.py不动。
这一策略的最大收益体现在 AC-13:升级可随时降级,数据零变更,旧版本继续按原查询返回,回滚最干净。
8. 前端设计:Platform 租户用户管理对话框收敛
8.1 修改范围
前端修改集中在 Platform 前端的TenantPage(Client 前端不涉及):
TenantPage/ ├── components/ │ ├── TenantUserDialog.tsx ← 主修改文件 │ │ ├── 删除「添加用户」picker (DepartmentUsersSelect + Button) │ │ ├── 删除「移除用户」按钮 │ │ ├── 新增顶部 Banner:「成员归属由用户主部门决定。添加/移除请到 [组织管理], │ │ │ 本页只展示当前归属并提供管理员配置。」+ 跳转链接 │ │ └── 保留「设为管理员/取消管理员」按钮(已有逻辑不变) │ └── ...当前仓库中的 TenantUserDialog.tsx 已经体现了 AC-06 / AC-07 的要求:已移除添加/移除用户操作,保留「设为管理员 / 取消管理员」按钮,并通过isRootTenant = (tenant) => tenant.id === 1对 Root 租户短路隐藏管理员按钮(与 spec 中 AC-07 的isRootTenant短路描述一致)。
8.2 API 调用变更
tenant.ts 中的处理策略是"保留导出、标记废弃":
getTenantUsersApi调用不变(端点路径不变,返回 schema 不变)。addTenantUsersApi/removeTenantUserApi保留导出,但加了@deprecatedJSDoc 注释,明确说明端点返回 410、迁移路径为部门成员编辑 API、v2.6.0 移除,给可能的外部使用方一个过渡期。
8.3 i18n 键新增
{ "tenant.membershipBanner.title": "成员管理已迁移", "tenant.membershipBanner.body": "成员归属由用户主部门决定。添加/移除请到 [组织管理],本页只展示当前归属并提供管理员配置。", "tenant.membershipBanner.cta": "前往组织管理" }对应语言包文件位于src/frontend/platform/public/locales/{en-US,zh-Hans,ja}/bs.json,需同步新增三个 key。
9. 测试与验证
spec 规划了两个新测试文件,仓库中均已落地(位于src/backend/test/tenant/):
9.1 数据源切换测试
test_tenant_users_query_source.py 是使用aiosqlite 的真实 DB 集成测试,自包含 DDL(不依赖 conftest 的表 fixtures,规避了此前的 schema drift 问题),覆盖:
- AC-01:只有主部门在租户子树内的用户出现;
- AC-02:主部门调岗后列表随之变化(实际调岗流程由
test_apply_local_primary_dept_sync.py覆盖); - AC-03:兼职(
is_primary=0)不出现; - AC-04:keyword 过滤生效;
- AC-12:v2.5.0
aadd_users残留的幽灵UserTenant行(主部门不在子树内)不展示。
9.2 410 端点测试
test_tenant_membership_endpoints_deprecated.py 直接调用 handler 函数(不起完整 FastAPI 应用),参数化覆盖:
- AC-08:
POST /tenants/{id}/users对任意 tenant_id(含 9999 不存在值)恒返 410,body 含error、primary department、apply-edit、deprecated_since=v2.5.1; - AC-09:
DELETE /tenants/{id}/users/{user_id}同理; - 410 handler无 auth 依赖的断言(防止 SDK 重试落入 401/403)。
该测试文件还复用了 F011 的回归测试模式(test_current_tenant_api.py中对switch-tenant410 的 regression 断言),说明 410 Gone 已沉淀为团队统一遵循的端点退役模式。
10. 边界情况与不支持范围
F024 明确划定了行为边界:
- 历史多归属用户:v2.5.0 时期被
aadd_users加入多个租户的用户,升级后只在主部门所在租户的列表出现一次(其他UserTenant行打 legacy)。若需"恢复多归属"——不支持,请改用户主部门。 - Root Tenant 列表:Root 租户没有
mounted_tenant_id,列表数据源退化为"主部门 path 不在任何 Child Tenant 子树下"的 User(即真正归属 Root 的用户)。 - 跨租户兼职:用户主部门在 Tenant A、兼职部门在 Tenant B 子树时,X 只在 A 的列表,不在 B 的列表。若需"看到所有跨租户协作过的人",本特性不提供该视图,未来可加独立"协作者"页。
- API 调用方仍依赖 POST/DELETE:返 410,并在 release-notes 标注 deprecation。不提供"开关恢复旧端点"的逃生门——与
switch-tenant410 Gone 的处理保持一致。 - 明确不支持:v2.5.x 系列不回头支持"用户多租户 + 登录选择"模型(该方向已在 F011/F012 决策中废弃)。
11. 非功能要求与兼容性
- 性能:新查询 JOIN
Department+UserDepartment+User三表,依赖Department.path上的 prefix index(F011 已落地的idx_department_path索引),分页查询 P95 目标 < 200ms(以同等数据量级 v2.5.0 测试结果为对照)。 - 安全:
get_tenant_users仍走get_admin_user依赖(管理路径);新 DAO 方法默认bypass_tenant_filter()。 - 兼容性:
- GET 端点行为内变但路径/schema 不变 → 前端调用点零改动;
- POST/DELETE 端点直接 410 → 外部脚本侧需要适配(release-notes 标 BREAKING);
- DB 层无 schema 变更,回滚直接降级即可。
- 可观察:reconcile 任务的 metadata 计入 audit_log,便于复盘升级期间标了多少 legacy 行。
12. 上线节奏与 Release Notes
F024 采用一次到位策略——前后端 + API 契约变更同 PR 同版本发布,理由:
- AD-03 选 C 后无数据迁移、无启动钩子,回滚零数据风险;
- 前端 / 后端 / API 三层动作统一上线,避免出现"前端隐藏按钮但后端还能调用"的中间态;
- v2.5.1 部署面以私有化集成为主,POST/DELETE 端点的外部调用方可控。
Release Notes 必备项:
- BREAKING:
POST /api/v1/tenants/{id}/users/DELETE /api/v1/tenants/{id}/users/{user_id}改 410 Gone; - 迁移指引:用
POST /api/v1/department/{dept_id}/members/{user_id}/apply-edit改主部门; - 提前通知:发版前向已知 SDK / 集成对接方告知。
13. 相关文档索引
- 版本契约与 F010 修订记录:features/v2.5.1/release-contract.md
- 被修订的 F010 spec(租户管理 UI):features/v2.5.0/010-tenant-management-ui/spec.md
- F012 leaf 派生(TenantResolver):features/v2.5.1/012-tenant-resolver/spec.md
- F011 Tenant 树形模型:
features/v2.5.1/011-tenant-tree-model/ - 同模式参考:
switch-tenant410 Gone 实现(user_tenant.py)
从立项动机看,F024 的直接触发点是 P0 修复(_apply_local_primary_department_change)+ G1(aadd_members(is_primary=1)路由到change_primary_department)+ G2(acreate_local_member补 sync)排查时暴露的 UI/模型漂移。它站在这些"功能层修复"之上,完成的是模型一致性收敛——逻辑上独立,但动机连贯,是一个"底层模型演进后,上层 UI/API 必须同频对齐"的完整工程案例。
【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考