feat(system): 按 OAuth2 客户端 platform 过滤菜单,支持业务/物联双前端

问题:业务平台(biz)和物联运维平台(iot)共用一套用户体系和菜单表,但每个前端
只该看到自己域的菜单。原来没有按客户端过滤的机制。

方案:在 OAuth2 客户端维度打 platform 标签(biz/iot/NULL),菜单也打同样标签,
登录时下发菜单按二者匹配过滤。

链路:
- OAuth2AccessTokenCheckRespDTO / LoginUser(framework + gateway)新增 clientId 字段
- TokenAuthenticationFilter(framework + gateway)把 accessToken.clientId 带进 LoginUser
- WebFrameworkUtils.HEADER_CLIENT_ID="X-Client-Id":登录/refresh 等"无 token 入口"
  允许前端声明 client,避免硬编码 default
- AdminAuthServiceImpl.resolveClientId:未传 Header 时回退 OAuth2ClientConstants.CLIENT_ID_DEFAULT
- MenuDO / OAuth2ClientDO 各加 platform 列
- MenuService.filterMenusByPlatform:platform 为空(全平台共用)或匹配即保留

SQL 迁移按字母序编号:
- _01_oauth2_client_platform.sql:加列 + 给 default/iot-client 客户端打标 + 递归标
  IoT 菜单子树(root id=4000)为 iot
- _02_bulk_mark_biz_menus.sql:其余 platform=NULL 的菜单兜底标 biz
- 顺序依赖:_01 标完 iot 后 _02 才动剩余 NULL,避免 _02 把 IoT 菜单错标 biz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 13:32:26 +08:00
parent 4564eec893
commit cbbb048a4d
15 changed files with 729 additions and 559 deletions

View File

@@ -0,0 +1,51 @@
-- ============================================================
-- 多前端按 client → platform 过滤菜单
-- 配合后端代码AuthController.getPermissionInfo / MenuService.filterMenusByPlatform
-- 日期2026-04-20
--
-- platform 取值约定:
-- biz = 业务平台(对应 OAuth2 客户端 default
-- iot = 物联运维平台(对应 OAuth2 客户端 iot-client
-- NULL = 两个平台都展示(通用菜单,如系统管理、用户、部门等)
-- ============================================================
-- 1. system_oauth2_client 加 platform 列
ALTER TABLE `system_oauth2_client`
ADD COLUMN `platform` VARCHAR(10) NULL DEFAULT NULL
COMMENT '平台标识biz-业务平台iot-物联运维平台NULL-不按客户端过滤菜单'
AFTER `additional_information`;
-- 2. 矫正 system_menu.platform 列的注释(旧注释写的是 ops/sys与代码约定不一致更新为 biz/iot
ALTER TABLE `system_menu`
MODIFY COLUMN `platform` VARCHAR(10) NULL DEFAULT NULL
COMMENT '平台标识biz-业务平台iot-物联运维平台NULL-两个平台都展示';
-- 3. 给两个内部 SSO 客户端打 platform 标
-- 业务平台复用 yudao 默认 default 客户端 → biz
-- 物联运维平台用独立 iot-client → iot
UPDATE `system_oauth2_client` SET `platform` = 'biz' WHERE `client_id` = 'default';
UPDATE `system_oauth2_client` SET `platform` = 'iot' WHERE `client_id` = 'iot-client';
-- 4. 给 IoT 模块的菜单打 iot 标记。从 sql/mysql/system_menu.sql 看,
-- IoT 设备接入 root id=4000整个子树都属于 iot 平台。
UPDATE `system_menu` SET `platform` = 'iot'
WHERE `id` IN (
SELECT t.id FROM (
-- 递归取 4000 子树。MySQL 8 用 CTE旧版自行替换为多次 UPDATE
WITH RECURSIVE iot_tree(id) AS (
SELECT id FROM system_menu WHERE id = 4000
UNION ALL
SELECT m.id FROM system_menu m JOIN iot_tree it ON m.parent_id = it.id
)
SELECT id FROM iot_tree
) t
);
-- 5. 业务平台独有菜单标 biz可选不标的话默认 NULL = 两边都显示)
-- 例如 OA 示例id=5
-- UPDATE `system_menu` SET `platform` = 'biz' WHERE `id` IN (5);
-- 6. 系统管理 / 基础设施 / 用户 / 部门 / 字典 等通用菜单保持 NULL两边共用。
-- 7. 改完客户端后,记得在后台 OAuth2 客户端管理页面"保存"一次刷新缓存;
-- 或重启后端清缓存Redis key: oauth2_client

View File

@@ -0,0 +1,18 @@
-- ============================================================
-- 批量给「非 IoT 菜单」打上 platform='biz'
-- 策略iot 平台只看设备接入子树4000其余含系统管理、基础设施、OA、各 demo一律归业务平台
-- 日期2026-04-20
-- ============================================================
-- 所有 platform 还是 NULL 的,一律改为 biz
-- platform='iot' 的行已经在上一次迁移里设过,不会被动)
UPDATE system_menu
SET platform = 'biz'
WHERE deleted = 0 AND platform IS NULL;
-- 验证
SELECT platform, COUNT(*) AS cnt FROM system_menu WHERE deleted = 0 GROUP BY platform;
-- 预期:
-- biz = 大部分(系统管理/基础设施/OA/demos 等)
-- iot = 设备接入子树(~50
-- NULL = 0

View File

@@ -18,6 +18,9 @@ public class OAuth2AccessTokenCheckRespDTO implements Serializable {
@Schema(description = "用户类型,参见 UserTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer userType;
@Schema(description = "OAuth2 客户端编号,用于按客户端区分前端来源(如 default、iot-client", example = "iot-client")
private String clientId;
@Schema(description = "用户信息", example = "{\"nickname\": \"芋道\"}")
private Map<String, String> userInfo;

View File

@@ -43,6 +43,14 @@ public class LoginUser {
* 授权范围
*/
private List<String> scopes;
/**
* OAuth2 客户端编号
*
* 用于区分用户从哪个前端登录进来,做按客户端的菜单/权限过滤。客户端 → platform 映射:
* - {@code default}(业务平台) → platform=biz
* - {@code iot-client}(物联运维平台) → platform=iot
*/
private String clientId;
/**
* 过期时间
*/

View File

@@ -97,7 +97,8 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType())
.setInfo(accessToken.getUserInfo()) // 额外的用户信息
.setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes())
.setExpiresTime(accessToken.getExpiresTime());
.setExpiresTime(accessToken.getExpiresTime())
.setClientId(accessToken.getClientId()); // OAuth2 客户端编号,用于按前端来源过滤菜单
} catch (ServiceException serviceException) {
// 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
return null;

View File

@@ -1,6 +1,7 @@
package com.viewsh.framework.web.core.util;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.ServletUtil;
import com.viewsh.framework.common.enums.RpcConstants;
import com.viewsh.framework.common.enums.TerminalEnum;
@@ -30,6 +31,14 @@ public class WebFrameworkUtils {
public static final String HEADER_TENANT_ID = "tenant-id";
public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id";
public static final String HEADER_PROJECT_ID = "project-id";
/**
* OAuth2 客户端编号 Header
*
* 由前端(业务平台 / 物联运维平台)声明自己是哪个 OAuth2 client
* 后端在密码登录、refresh-token 等"还没有 token 的入口"用它代替写死的 default。
* 一旦 token 创建出来,后续请求就走 token 自带的 client_id无需此 Header。
*/
public static final String HEADER_CLIENT_ID = "X-Client-Id";
/**
* 终端的 Header
@@ -68,6 +77,20 @@ public class WebFrameworkUtils {
return NumberUtil.isNumber(projectId) ? Long.valueOf(projectId) : null;
}
/**
* 获得 OAuth2 客户端编号,从 header 中。
* 未携带时返回 null由调用方决定回退默认值。
*
* @param request 请求
* @return 客户端编号
*/
public static String getClientId(HttpServletRequest request) {
if (request == null) {
return null;
}
return StrUtil.trimToNull(request.getHeader(HEADER_CLIENT_ID));
}
/**
* 获得访问的租户编号,从 header 中
* 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供

View File

@@ -36,6 +36,10 @@ public class LoginUser {
* 授权范围
*/
private List<String> scopes;
/**
* OAuth2 客户端编号
*/
private String clientId;
/**
* 过期时间
*/

View File

@@ -157,7 +157,8 @@ public class TokenAuthenticationFilter implements GlobalFilter, Ordered {
return new LoginUser().setId(tokenInfo.getUserId()).setUserType(tokenInfo.getUserType())
.setInfo(tokenInfo.getUserInfo()) // 额外的用户信息
.setTenantId(tokenInfo.getTenantId()).setScopes(tokenInfo.getScopes())
.setExpiresTime(tokenInfo.getExpiresTime());
.setExpiresTime(tokenInfo.getExpiresTime())
.setClientId(tokenInfo.getClientId()); // OAuth2 客户端编号,下游按它做按前端来源过滤
}
@Override

View File

@@ -63,6 +63,9 @@ public class MenuRespVO {
@Schema(description = "是否总是显示", example = "false")
private Boolean alwaysShow;
@Schema(description = "平台标识biz-业务平台iot-物联运维平台null-两个平台都展示", example = "biz")
private String platform;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "时间戳格式")
private LocalDateTime createTime;

View File

@@ -61,4 +61,7 @@ public class MenuSaveVO {
@Schema(description = "是否总是显示", example = "false")
private Boolean alwaysShow;
@Schema(description = "平台标识biz-业务平台iot-物联运维平台null-两个平台都展示", example = "biz")
private String platform;
}

View File

@@ -105,5 +105,12 @@ public class OAuth2ClientDO extends BaseDO {
* 附加信息JSON 格式
*/
private String additionalInformation;
/**
* 平台标识,决定该客户端登录后能看到的菜单子集
*
* 例如:{@code biz} 业务平台 / {@code iot} 物联运维平台。NULL 表示不做按客户端的菜单过滤。
* 与 {@link com.viewsh.module.system.dal.dataobject.permission.MenuDO#getPlatform()} 对齐。
*/
private String platform;
}

View File

@@ -105,5 +105,12 @@ public class MenuDO extends BaseDO {
* 如果为 false 时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单
*/
private Boolean alwaysShow;
/**
* 平台标识biz-业务平台iot-物联运维平台NULL-两个平台都展示
*
* 与 {@link com.viewsh.module.system.dal.dataobject.oauth2.OAuth2ClientDO#getPlatform()} 对齐:
* 用户登录的 OAuth2 客户端的 platform 等于本字段,或本字段为 NULL则该菜单展示。
*/
private String platform;
}

View File

@@ -7,6 +7,9 @@ import com.viewsh.framework.common.util.monitor.TracerUtils;
import com.viewsh.framework.common.util.object.BeanUtils;
import com.viewsh.framework.common.util.servlet.ServletUtils;
import com.viewsh.framework.common.util.validation.ValidationUtils;
import com.viewsh.framework.web.core.util.WebFrameworkUtils;
import cn.hutool.core.util.StrUtil;
import jakarta.servlet.http.HttpServletRequest;
import com.viewsh.framework.datapermission.core.annotation.DataPermission;
import com.viewsh.module.system.api.logger.dto.LoginLogCreateReqDTO;
import com.viewsh.module.system.api.sms.SmsCodeApi;
@@ -267,19 +270,34 @@ public class AdminAuthServiceImpl implements AdminAuthService {
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
// 插入登陆日志
createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
// 创建访问令牌
// 创建访问令牌client_id 优先取请求头 X-Client-Id缺省回退 default
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
resolveClientId(), null);
// 构建返回结果
return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
}
@Override
public AuthLoginRespVO refreshToken(String refreshToken) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, resolveClientId());
return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
}
/**
* 解析当前请求声明的 OAuth2 客户端编号。
*
* 用于密码登录、refresh-token 等"还没有 token 的入口"——前端通过请求头 X-Client-Id 声明自己身份,
* 决定 token 绑定到哪个客户端,进而影响 {@code AuthController#getPermissionInfo} 按 platform 过滤的菜单。
*
* 缺省回退到 {@link OAuth2ClientConstants#CLIENT_ID_DEFAULT}"default"),即原生业务平台客户端;
* 在 DB 层通过 system_oauth2_client.platform='biz' 标识。
*/
private String resolveClientId() {
HttpServletRequest request = WebFrameworkUtils.getRequest();
String clientId = WebFrameworkUtils.getClientId(request);
return StrUtil.isNotBlank(clientId) ? clientId : OAuth2ClientConstants.CLIENT_ID_DEFAULT;
}
@Override
public void logout(String token, Integer logType) {
// 删除访问令牌

View File

@@ -67,6 +67,15 @@ public interface MenuService {
*/
List<MenuDO> filterDisableMenus(List<MenuDO> list);
/**
* 按平台标识过滤菜单。保留 platform 为空(全平台共用)或与入参相同的菜单
*
* @param list 菜单列表
* @param platform 平台标识(来自 OAuth2 客户端的 platform 字段)。为空时不过滤,原样返回
* @return 过滤后的菜单列表
*/
List<MenuDO> filterMenusByPlatform(List<MenuDO> list, String platform);
/**
* 筛选菜单列表
*

View File

@@ -136,6 +136,20 @@ public class MenuServiceImpl implements MenuService {
return menus;
}
@Override
public List<MenuDO> filterMenusByPlatform(List<MenuDO> menuList, String platform) {
if (CollUtil.isEmpty(menuList) || StrUtil.isBlank(platform)) {
return menuList;
}
List<MenuDO> result = new ArrayList<>(menuList.size());
for (MenuDO menu : menuList) {
if (StrUtil.isBlank(menu.getPlatform()) || platform.equals(menu.getPlatform())) {
result.add(menu);
}
}
return result;
}
@Override
public List<MenuDO> filterDisableMenus(List<MenuDO> menuList) {
if (CollUtil.isEmpty(menuList)){