SpringBoot3+Vue3 用户多组织:默认任职、关联组织和顶栏切公司怎么配

这两天一直在研究这个话题,踩了几个坑,把遇到的东西整理成文,供有需要的朋友参考。

SpringBoot3+Vue3 用户多组织:默认任职、关联组织和顶栏切公司怎么配

🌐 文档地址:https://ruoyioffice.com 📦 源码1·GitHub:https://github.com/yuqing2026/ruoyi-office 📦 源码2·GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office 📦 源码3·Gitee:https://gitee.com/yqzy1688/ruoyi-office 💬
集团里一个人同时在深圳研发和长沙分公司任职,最常用的错法是再开一个账号、或者让他退出当前租户再登录。RuoYi Office 把这件事做成同一账号、多条任职、会话里只生效当前这一条。部署开关和租户开关要同时打开;默认组织务必有角色;顶栏切换会关页签、重拉菜单。关掉多组织时任职记录不删,运行时回到用户主档。

▲ 左是顶栏当前公司/部门,中是 Redis 会话里的 deptId,右是 PRIMARY / CONCURRENT 任职表;切的是任职,不是租户


引言:一人多公司难在「切的是什么」

把部门下拉框做成多选,看起来像「多组织」,上线后会碰到这些坑:

痛点常用做法后果两个公司两套账号让员工记两套密码待办拆开,消息对不齐切公司等于换租户退出再登录另一个租户集团共享主数据被切成两套库兼职没有独立角色全局角色带到所有部门长沙只能看报销,却看见了深圳合同菜单关掉开关就删任职DROP 或清空任职表下次再开,全部要重配只改前端显示Token 里 deptId 仍是主档新开的用车申请单还挂在默认部门

一句话:多组织是「同一租户内、同一账号、多条任职,会话只认当前 deptId」;多租户是「另一套客户、另一套库」。 两件事不要配成同一个按钮。

下面按能点的页面讲:用户列表的「关联组织」、用户档案上的默认部门/角色、租户表单的「用户多组织」、开发台顶栏的公司切换器。


一、先给出可直接抽取的定义

1.1 什么是用户多组织

用户多组织,是一个登录账号可以关联多个部门(通常分属不同公司),每条任职独立存角色和岗位;当前会话只激活其中一条,菜单、数据范围、新开单据都只认这一条。

它不是:

  • 再注册一个用户
  • 切换到另一个租户
  • 把用户档案上的部门改成多选(多选无法表达「当前正在哪家办公」)
RuoYi Office 的任职表是 system_user_org,角色在 system_user_org_role,岗位在 system_user_org_post。用户档案 system_users.dept_id 只同步默认任职那一条。

1.2 什么是默认任职

默认任职,是每用户有且仅有一条 isDefault = true 的记录,关系类型写成 PRIMARY,角色不能为空。 它同时干三件事:

1. 回写用户档案的部门、默认角色、默认岗位
2. 关掉多组织开关后的兜底身份
3. 登录时若会话还没切过,当前组织就是它

前端弹窗里点「默认」单选,保存时把该行标成 PRIMARY,其余行标成 CONCURRENT。不要让客户在表单上同时维护「主职/兼职」和「是否默认」两套口径——对外只露出「默认」这一个词。

1.3 什么是会话当前组织

会话当前组织,是这次登录(跟 refresh token 走)正在使用的 deptId,存在 Redis,并回写 Access Token 的 userInfo。 顶栏显示成「公司名/部门名」,切换成功后关闭全部页签,整页跳回首页,重新拉权限。

新开的审批单、用车单会带当前公司,而不是永远带用户主档部门。这才是客户能感知到的「切公司」。


二、两道开关务必同时开

运行时是否走任职表,不是看某一个布尔值,而是两道门:

开关配在哪关了会怎样部署级 multi-org.enable应用配置,默认 false整站不读不写任职表,行为与升级前一致租户级 multiOrgEnabled租户管理 → 编辑租户该租户用户列表不出现「关联组织」,顶栏不出现切换器

后端入口是 MultiOrgSupport.isEnabled():配置未开直接 false;当前线程没有 tenantId(定时任务、MQ)也视为关闭,避免 Job 误读任职表把数据范围切乱。

只开部署、不开租户:集团里只有部分客户要用一人多公司,其它租户保持「一用户一部」。只开租户、不开部署:表单上能勾,运行时仍当没开——这是最容易让实施以为「坏了」的组合,先对这两项。

无 tenantId 的后台线程一律当关闭,这是有意的:考勤汇总、合同到期提醒不应该因为某次顶栏切换,把统计口径切到兼职部门。

菜单怎么算:多组织开启后,鉴权、菜单、数据范围都只认当前任职roleIds。人在深圳研发、角色带合同菜单;切到长沙且长沙任职只配了 OA 角色,合同模块会从侧栏消失。这不是 bug,是任职隔离生效。若客户期望「菜单永远全集、只过滤数据」,不要开多组织,改用数据权限的「全部 / 自定义部门」。

数据权限口径跟会话 deptId:角色配「本部门」时,过滤条件用的是当前部门,不是用户档案部门。验收用例写成三步更清楚——切到 A 部门能看见 A 的用车单;切到 B 看不见 A 的「本部门」数据;默认组织仍能在关联组织里改回去。


三、用户列表:关联组织才是多条任职的入口

路径:/system/user。租户已开多组织时,行内操作除「编辑 / 删除」外多一列「关联组织」。超级管理员行会锁,避免演示账号被改乱。

▲ 左树是组织,右表每行有编辑、关联组织、删除;列表只配这一张,后面看弹窗和顶栏

点开后是独立弹窗,不是把用户编辑表单拉长。弹窗标题是「关联组织 · {账号}」,举个例子演示账号 demomulti。每一条任职一张卡片:

字段必填说明部门是树选择,同一用户不能重复选同一部门角色默认行必填该任职独立授权;切换后菜单只认这一组岗位否同样按任职隔离默认全局唯一同步至用户档案;去掉当前默认时自动把第一条顶上去

底部有「+ 新增任职」。保存前前端会拦:至少一条、部门不能空、默认务必恰好一条、默认必须至少一个角色。后端再拦一遍部门重复和默认角色,避免绕过弹窗的脚本把用户存成「关开关后没菜单」。

▲ 任职 1 勾了默认,部门是研发部门,角色按业务线多选;说明文字写清「切换组织只激活该任职的权限」

弹窗底部那句不要删:客户经常问「编辑用户里的角色和这里的角色会不会打架」。答案是——开启多组织后,档案上的角色/岗位只代表默认组织;兼职的授权只在这张弹窗里配。

接口:

  • GET /system/user-org/list?userId= 管理端查某人
  • GET /system/user-org/my-list 当前登录人自己的任职(给顶栏)
  • PUT /system/user-org/save 整表覆盖保存
  • POST /system/user-org/switch 只切会话,不改任职表
权限码:system:user-org:query / system:user-org:update。查询、更新走「任一任职拥有即可」的鉴权,避免人在兼职组织时改不了自己的关联。

四、用户档案:这里只配默认组织

「编辑」打开的仍是经典用户表单:用户名、昵称、部门、角色、岗位。开启多组织后,部门/角色/岗位的帮助文案会改成:

开启多组织时,此处为「默认组织」的岗位 / 角色;其它任职请到「关联组织」逐条配置。

不要在这张表上做部门多选。默认部门被改时,后端应同步 PRIMARY 那一行,而不是再插一条 CONCURRENT。批量「分配角色」也只追加到默认组织,文案会写明:其它任职请到关联组织配置。

这是有意的产品切割:档案管人,任职管「人在哪家公司以什么身份干活」。 混在一个表单里,实施会把兼职角色覆盖掉默认角色。

演示账号 demomulti(昵称「业务骨干」)的编辑弹窗如下。部门仍是「研发部门」,角色是一组业务线标签。请把它搞懂成默认组织的身份,不要在这张弹窗里找「第二条公司」。

▲ 标题是「修改用户」;兼职公司、兼职角色不在这里,而在行内「关联组织」

关联组织弹窗底部还有一句实施常忽略的话:关闭多组织,以及薪酬、考勤、员工档案,都认默认组织。 兼职能切菜单、能开单据,不等于工资和考勤日历跟着兼职部门走。算薪、排班、人事主档继续锚定 PRIMARY,避免一个人在长沙兼一周就把深圳的假期额度切走。

前端保存时也会把默认行标成 PRIMARY、其余标成 CONCURRENT,避免两套口径:

if (rows.value.length === 0) {
  message.warning('至少保留一条任职');
  return;
}
if (rows.value.some((item) => !item.deptId)) {
  message.warning('请选择部门');
  return;
}
const defaultRows = rows.value.filter((item) => item.isDefault);
if (defaultRows.length !== 1) {
  message.warning('必须且只能指定一个默认组织');
  return;
}
if ((defaultRows[0]!.roleIds ?? []).length === 0) {
  message.warning('默认组织必须至少配置一个角色');
  return;
}
await saveUserOrgList({
  userId: userId.value,
  orgs: rows.value.map((item, index) => ({
    ...item,
    sort: index,
    relationType: item.isDefault ? 'PRIMARY' : 'CONCURRENT',
  })),
});

改的是自己的任职时立刻重拉权限,否则顶栏 orgList 还是旧的。兼职行未配角色会显示橙色提示:「未配角色时,切换到此组织将看不到业务菜单」。


五、租户表单:用户多组织是租户级能力

路径:/system/tenant,编辑某个租户,表单靠下有「用户多组织」单选。帮助文案的要点:

  • 开启后,用户可关联多个部门,顶栏和移动端可切换(会刷新菜单与权限)
  • 部署侧还要把 multi-org.enable 打开
  • 关闭后任职记录保留,运行时回到默认组织

▲ 开关与租户状态、演示保护排在一起;每个租户独立,图中该演示租户为关闭。当前登录租户若已开启,顶栏仍可切组织

从开到关,前端会先打预览接口,弹确认框,内容大致包括:

  • 已配置任职用户多少人、其中多少人有多条任职
  • 其它任职上单独配的角色条数将不再生效
  • 默认组织角色与用户档案不一致的人数(关闭后菜单会变)
  • 尚未配角色的任职条数
  • 「本部门」数据权限按默认部门过滤,其它任职部门下的单据可能看不见
  • 相关账号请重新登录
确认后才提交。不要静默关掉——兼职角色会立刻失效,客服电话会响。

六、顶栏:切公司会关页签

开发台右上角,头像左侧,格式是 公司名/部门名,举个例子「深圳总公司/研发部门」。只有 multiOrgEnabledorgList 非空才渲染。任职只有一条时下拉禁用,避免点开一个不能点的列表。

▲ 切换器跟搜索、通知、头像在同一条顶栏;切完会回到首页并重拉待办口径

点开后标题是「切换组织」,可搜公司或部门,当前项打勾。确定前有确认框:

  • 目标任职一个角色都没有:警告「切换后将看不到任何业务菜单,只能再切回其它组织」
  • 否则:说明角色与岗位按任职独立生效,会关闭全部页面并重新生成菜单
确认后前端顺序是:

1. POST /system/user-org/switch,body 只有 { deptId }
2. 本地 userInfo 立刻改 companyId / deptId / 名称(避免闪一下旧部门)
3. closeAllTabs()
4. location.href 跳到该用户的首页路径

全屏遮罩文案是「正在切换组织,请稍候…」。不要只改 Pinia 不刷新路由——动态菜单、数据权限指令都是登录后算出来的,半刷新会出现「菜单还在、接口 403」。


七、后端:会话跟 refresh token,不跟短 Access Token

切换接口从请求头取出当前 Access Token,查到对应的 refresh token,再写 Redis。Access Token 往往十几分钟过期,组织偏好必须活过「静静刷了一次 token」。

public void switchCurrentDept(Long userId, Long deptId, String accessToken) {
    if (!multiOrgSupport.isEnabled()) {
        throw exception(USER_ORG_DISABLED);
    }
    if (!userOrgService.hasOrg(userId, deptId)) {
        throw exception(USER_ORG_NOT_EXISTS);
    }
    OAuth2AccessTokenDO token = oauth2AccessTokenMapper.selectByAccessToken(accessToken);
    long ttl = Math.max(remainingSeconds(token.getExpiresTime()),
            Duration.ofDays(30).getSeconds());
    userOrgSessionRedisDAO.set(token.getRefreshToken(), deptId, ttl);
    Map userInfo = new HashMap(token.getUserInfo());
    userInfo.put(LoginUser.INFO_KEY_DEPT_ID, String.valueOf(deptId));
    token.setUserInfo(userInfo);
    oauth2AccessTokenMapper.updateById(token);
    oauth2AccessTokenRedisDAO.set(token);
}

要点:

  • 没有任职关系的 deptId 直接拒绝,防止改请求体切到别的部门
  • Redis key 形态是 user_org_session:{refreshToken},TTL 至少 30 天
  • Access Token 的 userInfo 同步改 deptId,网关/资源服务器读 Token 就能带上当前部门
  • 当前请求的 LoginUser 也立刻 overlay,避免本请求后半段仍用旧部门
微服务下网关可能把用户信息缓存约 1 分钟。拦截器会读请求头 current-dept-id,校验任职后覆盖 LoginUser,填这个窗口。前端在多组织开启时带上该头即可。

public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                         Object handler) {
    if (!multiOrgSupport.isEnabled()) {
        return true;
    }
    LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
    if (loginUser == null) {
        return true;
    }
    String header = request.getHeader("current-dept-id");
    if (StrUtil.isBlank(header) || !NumberUtil.isLong(header)) {
        return true;
    }
    userOrgSessionService.overlayLoginUserDept(loginUser.getId(), Long.valueOf(header));
    return true;
}

overlay 同样会 hasOrg 校验:请求头被改成未任职的部门时直接忽略,不会把数据范围切到别人的公司。这是防篡改,不是信任前端。


八、保存任职:默认角色是关开关后的救命绳

保存不是「有几条插几条」那么简单。后端按部门去重,缺省部门报错,默认必须恰好一条。默认行的角色若显式传了空数组,直接失败;roleIds == null 表示这次只调部门、不改授权,此时沿用档案上的全局角色——这是给老客户端留的兼容口。

if (defaultCount != 1 || defaultDeptId == null) {
    throw exception(USER_ORG_DEFAULT_REQUIRED);
}
if (defaultItem.getRoleIds() != null
        ? defaultItem.getRoleIds().isEmpty()
        : CollUtil.isEmpty(permissionService.getGlobalRoleIds(userId))) {
    throw exception(USER_ORG_DEFAULT_ROLE_REQUIRED);
}
String relationType = Boolean.TRUE.equals(item.getIsDefault())
        ? UserOrgDO.RELATION_PRIMARY : UserOrgDO.RELATION_CONCURRENT;

删除某条任职时,级联清掉该任职的角色和岗位,避免下次同一部门重建时继承旧授权。兼职行的 roleIds == null 同样表示不改,避免只改部门就把角色清空。

公司编号不手填:由部门向上解析「组织类型 = 公司」的祖先,写入 companyId。顶栏展示的「深圳总公司」来自这里,不是用户自己填的文本。


九、三张表怎么拆

不要把角色塞进任职表的 JSON 字段。切换组织时菜单计算要按 userOrgId 精确查角色,JSON 无法走关联、也无法做「关闭多组织时统计兼职角色条数」。

表一行代表什么关键字段system_user_org用户在某部门的一条任职userIddeptIdcompanyIdisDefaultrelationTypestatussortsystem_user_org_role该任职拥有的一个角色userIduserOrgIdcompanyIdroleIdsystem_user_org_post该任职拥有的一个岗位userIduserOrgIdcompanyIdpostId

角色表、岗位表要按 userOrgId / userId / roleId 收敛查询,不要误加租户列过滤。任职编号本身跨租户唯一;在「访问其它租户」这类上下文里,如果拦截器按当前 tenant_id 去滤关联表,会把角色查成空集,全站变成「没有该操作权限」。这是实施时最像权限 bug、说实话是过滤条件套错的一类问题。

用户档案仍保留一份全局 sys_user_role:它和默认任职对齐。关闭多组织后,运行时只读档案,不读任职角色——所以默认行必须有角色,否则关开关等于把人锁在空白开发台。


十、和多租户、数据权限分别对齐

三件事经常被配到同一个需求里,表格拆开更好讲:

概念隔离的是什么用户怎么操作多租户客户与客户,库或 schema 或 tenant_id登录时选租户,或域名绑定多组织同一客户内的公司/部门任职顶栏切换,不退出登录数据权限当前身份能看哪些行角色上的「全部 / 本部门 / 本部门及以下 / 仅本人 / 自定义」

切组织之后,「本部门」的口径跟着当前 deptId 走。人在深圳研发,本部门数据权限就只看研发;切到长沙分公司,同一套角色定义会作用在长沙的部门树上。不要指望切组织却仍按用户主档过滤——那是没 overlay LoginUser

新开单据的公司、部门默认值也取当前会话,而不是档案。客户验收时最直观的用例:切到长沙 → 发起用车 → 单据抬头是长沙。


十一、关闭多组织之后

关闭不是删数据。任职表、任职角色、任职岗位都还在。运行时:

1. isEnabled() 为 false,顶栏切换器消失
2. 用户列表「关联组织」隐藏
3. 菜单和数据范围回到用户档案的部门、角色、岗位
4. Redis 里的会话 deptId 不再被读取(开关关了 overlay 直接 return)

预览框里已经写了副作用:兼职上单独配的角色失效;档案和默认任职不一致时菜单会变;其它任职部门下的单据按「本部门」可能看不见。实施清单建议:关之前导出任职,确认默认行角色与档案一致,再通知相关账号重新登录。

重新打开时,旧任职立刻可用,不用重录——这就是「保留记录」的价值。

登录后的权限包会带上 multiOrgEnabledorgListdefaultDeptId 和当前 deptId。当前部门优先取会话里 overlay 过的值,没有则回用户档案:

private Long resolveCurrentDeptId(AdminUserDO user) {
    Long currentDeptId = user.getDeptId();
    Long loginDeptId = SecurityFrameworkUtils.getLoginUserDeptId();
    if (multiOrgSupport.isEnabled() && loginDeptId != null) {
        return loginDeptId;
    }
    return currentDeptId;
}

前端顶栏不自己调 my-list 拼菜单,而是吃权限接口里的 orgList。这样和按钮显隐用的是同一份数据,避免「列表有任职、顶栏没有」。

切换失败时不要只弹「系统错误」,产品码已经写清:

错误码语义什么时候出现用户怎么处理当前租户未启用用户多组织部署或租户开关没开仍调 switch先对两道开关用户未关联该组织,无法切换改了 deptId 或任职已删回到关联组织检查必须且只能指定一个默认组织保存时 0 条或多条默认弹窗里只留一个「默认」同一部门不能重复任职两条卡片选了同一个部门删掉重复行无法识别当前登录会话Access Token 对不上 refresh重新登录再切默认组织必须至少配置一个角色默认行角色空先给 PRIMARY 配角色


十二、设计对照:传统方案 vs 本方案

决策点传统 / 错法本方案理由一人两公司两个账号一条用户、多条任职待办、消息、人事主数据不分裂当前公司存在哪只放 VuexRedis 跟 refresh token + Token userInfo刷新 Access Token 不丢兼职角色共用全局角色每条任职独立 roleIds长沙不能看见深圳合同菜单关开关清空任职保留,运行时回主档可逆,实施敢关无角色任职允许保存默认行禁止空角色;切换时警告防止把自己切进空白工作台定时任务读会话部门无 tenantId 视为未开启Job 口径稳定


十三、技术亮点

设计要点实现方式价值双开关部署 multi-org.enable × 租户 multiOrgEnabled不是所有客户都要一人多公司默认唯一前后端都校验恰好一条 PRIMARY关开关有兜底身份整表保存PUT /save 覆盖 + 级联删授权不会留下孤儿角色行会话 TTLmax(Access 剩余, 30 天)组织偏好活过短 Token切完硬刷新关页签 + location.href动态菜单不会半新半旧请求头兜底current-dept-id 拦截器网关缓存窗口内仍正确公司冗余部门向上解析写入 companyId顶栏、统计不用每次爬树Job 隔离无线程租户上下文则关闭避免兼职污染批处理


十四、FAQ

Q1:能不能只开租户开关、部署保持 false?
不能当生产用法。表单能勾、运行时 isEnabled() 仍是 false,客户会以为功能坏了。演示或灰度必须两道门一起开。

Q2:切换组织后,已经打开的用车详情还算哪家公司?
页签会被关掉。请重新从列表进。不要在切换后指望旧页签里的表单公司字段自动变——那是另一份已加载的数据。

Q3:兼职没有角色,为什么还允许保存?
默认行不允许空角色。兼职行允许先建部门、后补角色,但顶栏切换会二次确认「菜单会空」。给实施一个「先挂上部门」的窗口,同时不让用户无提示地切进去。

Q4:这和 SaaS 多租户套餐有什么关系?
套餐决定租户能用哪些菜单;多组织决定同一租户里一个人能以几个部门身份工作。可以只开套餐不开多组织,反之则两道开关都要开。

Q5:移动端怎么切?
同一套 my-list + switch 接口。切完同样要清掉本地路由缓存并重拉权限,不要只改展示用的部门名称。

Q6:薪酬和考勤会不会跟着兼职部门走?
不会。弹窗说明写了:薪酬、考勤、员工档案认默认组织。兼职只影响「当前能点哪些菜单、新单算哪家公司」。要把工资发到长沙,应走人事调动或改默认,而不是顶栏切一下。

Q7:网关缓存 1 分钟,切组织后接口还用旧部门怎么办?
请求头带 current-dept-id,后端拦截器校验任职后覆盖本次 LoginUser。这是填缓存窗口,不是第二套会话存储。


十五、快速体验

在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)

建议路径:

1. 系统管理 → 租户列表 → 编辑当前租户,确认「用户多组织」为开启,且部署已打开 multi-org.enable
2. 系统管理 → 用户管理,找一个非超管账号,点「关联组织」,加一条其它公司的部门,给独立角色
3. 用该账号登录,看顶栏是否出现「公司/部门」
4. 切到兼职组织,确认菜单变化、新开单据的抬头公司变了
5. 再切回默认,页签应全部关掉并回到首页
6. (可选)关掉租户开关,确认顶栏消失、任职弹窗不再展示,重新打开后旧任职还在

实施上线前再对一次清单,避免「功能开了但菜单空」:

检查项通过标准部署 multi-org.enable与要启用的环境一致,默认 false 不要误以为「配了租户就够」租户 multiOrgEnabled目标租户为开启;其它租户保持关闭也可以每个用户恰好一条默认关联组织弹窗里默认单选唯一默认行至少一角色关开关后仍能进系统兼职角色按需空角色会在切换时警告,不是保存时报错顶栏能搜到兼职公司权限接口 orgList 非空切完新单抬头用车/报销所属单位变成当前公司薪酬考勤仍认默认不要用顶栏切换当人事调动

源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office


结语

一人多公司的正确抽象是任职,不是第二个账号,也不是换租户。默认行保证关开关后还能进系统;兼职行保证菜单和数据范围按当前公司收紧;会话跟 refresh token 走,顶栏切完硬刷新,客户才不会遇到「界面上换了公司、单子还开在原来的部门」。

集团型 OA、连锁门店、项目型组织(人挂多个项目部)都可以复用这套「任职表 + 会话 deptId」。先把两道开关和默认角色配明白,再谈移动端和数据权限口径。


💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!

暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论