BiSheng 租户用户管理模型收敛:从 UserTenant 到主部门派生的 UI/API 对齐实战(F024)
2026/9/16 1:06:32 网站建设 项目流程

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 并未同步收敛,导致同一个租户下存在两层语义

  1. 操作员困惑UserTenant里有行的用户(UI 显示"在该租户的成员")≠ 实际登录会进入该租户的用户(由主部门派生决定)。排查 P0 级同步缺陷(_apply_local_primary_department_change漏触发 sync)时,必须反复区分"UI 上看到的成员"与"实际归属"两套数据。
  2. 数据漂移TenantService.aadd_users写入的UserTenant行是"幽灵成员"——它不改变 leaf 派生结果,且 sync 链路只维护is_active=1的行,这类残留行永远无法被自然消除
  3. 合规盲区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=20200;返回主部门挂在该租户子树的 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 ATenant A 的列表含 X;Tenant B 的列表含 X(兼职不改归属,与 F012 一致)
AC-04全局超管关键字搜索keyword=aliceuser.user_nameLIKE 过滤;语义与切换前一致

3.2 操作按钮收敛

ID角色操作预期结果
AC-05全局超管打开「租户管理 → 用户管理」对话框不再显示「添加用户」picker 和「移除用户」按钮;显示提示文案"添加/移除成员请到 组织 → 部门管理",链接跳转部门页
AC-06全局超管「设为管理员 / 取消管理员」按钮保留,行为不变(写/撤 OpenFGAtenant:#admintuple,不动 UserTenant 行)
AC-07Root Tenant 用户管理对话框进入对话框不显示「设为管理员/取消管理员」(Root admin 由系统级is_admin标志管理,沿用现有isRootTenant短路)

3.3 API 端点 deprecation

ID角色操作预期结果
AC-08任意调用方POST /api/v1/tenants/{id}/usersHTTP 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-15F012 / F019 / 现有同步链路升级后跑全量 syncUserTenantSyncService.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)BA 仍以衍生表为权威源,治标不治本;C 又把两种语义暴露给操作员,违背收敛初衷;B 直接以 source-of-truth 查询,与TenantResolver.resolve_user_leaf_tenant同源
AD-02废弃端点处理A: 保留端点但写 deprecated header / B: 直接 410 Gone / C: 改为转发到部门 APIBswitch-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-04UI 提示位置A: 对话框顶部 banner / B: 表格空状态提示 / C: 隐藏直接消失A操作员从「添加用户」按钮消失到理解"为啥消失"需要引导;C 信息密度不足;B 只在空列表时可见
AD-05Service 层方法保留与否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-deptA现有前端调用点已散落,改路由会触发更多前端回归;语义内变 + 在 spec/changelog 明确说明,性价比更高

5. API 契约变更详解

5.1 端点变更一览

MethodPathF024 后行为关联 AC
GET/api/v1/tenants/{id}/users数据源变:JOINDepartment.path+UserDepartment.is_primary=1;返回 schema 不变AC-01~04, AC-10
POST/api/v1/tenants/{id}/users410 GoneAC-08
DELETE/api/v1/tenants/{id}/users/{user_id}410 GoneAC-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_RESPONSEadd_users_deprecatedremove_user_deprecated两个 handler 共用,字段与 spec 一致(error/detail/migration/deprecated_since/removed_in)。

有两个容易被忽略的工程细节值得注意:

  • 两个 410 handler 不声明任何 auth 依赖switch_tenant_deprecatedadd_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
  • 错误码复用:不新增模块错误码(模块编码沿用 192tenant),410 响应形态与switch-tenant端点完全一致(参见 user_tenant.py 的switch_tenant_deprecated)。

6. Service 层与数据源切换的源码实现

6.1 方法级变更清单

方法文件变更
TenantService.aget_tenant_userstenant_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 fromUserTenantDao.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

真实实现与伪代码高度一致,并补充了三个关键细节:

  1. 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 不中断。
  2. DISTINCT 防御:列表查询先用select(UserDepartment.user_id).join(Department)...distinct().subquery()取出去重后的 user_id 集合,再 JOINUser。注释说明这是为了防御历史数据中可能存在的多主部门异常(G1 修复已封堵写入路径,但 DISTINCT 让存量数据也能干净渲染)。
  3. UserTenant 仅作装饰UserTenantLEFT 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在用户主部门变化后据此同步UserTenantis_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.0aadd_users残留的幽灵UserTenant行(主部门不在子树内)不展示。

9.2 410 端点测试

test_tenant_membership_endpoints_deprecated.py 直接调用 handler 函数(不起完整 FastAPI 应用),参数化覆盖:

  • AC-08:POST /tenants/{id}/users对任意 tenant_id(含 9999 不存在值)恒返 410,body 含errorprimary departmentapply-editdeprecated_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. 非功能要求与兼容性

  • 性能:新查询 JOINDepartment+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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询