From 9784d7dd8ec43d4f8283a518c05d1d5fcf6bdc0a Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 23 Apr 2026 16:20:29 +0800 Subject: [PATCH] =?UTF-8?q?perf(system):=20=E9=A1=B9=E7=9B=AE=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=A0=A1=E9=AA=8C=E6=94=B9=E5=8D=95=E8=A1=8C=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=20+=20AuthController=20=E5=88=87=20FromCache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:feat/multi-tenant-project 联调发现 /admin-api/** 全线变慢, 尤其 get-permission-info 经常超时。根因是 ProjectSecurityWebFilter 每请求需校验"用户对项目是否授权",原实现走"拿全量 authorizedIds 再 contains"路径,超管分支还得 selectList 全表项目。Framework 层虽有 60s 本地缓存,cache miss 时仍要走 Feign HTTP 自调用 + 两次 DB。 优化: 1. 新增 ProjectService.isProjectAuthorized(userId, projectId) 单项校验: - 超管直通返回 true(不查任何表) - 普通用户走 (user_id, project_id) 唯一索引的 selectCount 单行查询 2. ProjectCommonApi / ProjectApiImpl / ProjectFrameworkService(Impl) 全链路新增 isProjectAuthorized Feign 接口 3. ProjectFrameworkServiceImpl 为 isProjectAuthorized 加 60s 本地 Guava 缓存(key=(userId,projectId));invalidateAuthorizedProjectCache 同步清理本用户所有条目 4. ProjectSecurityWebFilter 改调 isProjectAuthorized,消除每请求 拉全量列表的开销 5. ProjectServiceImpl.getDefaultProjectId 的 N 次 selectById 改成一次 selectByIds 批量 6. AuthController.getPermissionInfo 第 107 行 getUserRoleIdListByUserId → FromCache(yudao 原生小瑕疵顺手修) 预期收益: - Filter 热路径在 cache 命中时 0 次 DB,cache miss 时 1 次单行查询 - get-permission-info 消除一次无缓存 user_role DB 查询 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../biz/system/project/ProjectCommonApi.java | 7 ++++ .../security/ProjectSecurityWebFilter.java | 5 ++- .../core/service/ProjectFrameworkService.java | 9 +++++ .../service/ProjectFrameworkServiceImpl.java | 34 +++++++++++++++++++ .../system/api/project/ProjectApiImpl.java | 5 +++ .../controller/admin/auth/AuthController.java | 25 ++++++++++++-- .../service/project/ProjectService.java | 12 +++++++ .../service/project/ProjectServiceImpl.java | 28 ++++++++++++--- 8 files changed, 115 insertions(+), 10 deletions(-) diff --git a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java index fa29f6b7..266a0690 100644 --- a/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java +++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/system/project/ProjectCommonApi.java @@ -35,4 +35,11 @@ public interface ProjectCommonApi { @Parameter(name = "userId", description = "用户编号", required = true) CommonResult getDefaultProjectId(@RequestParam("userId") Long userId); + @GetMapping(PREFIX + "/is-authorized") + @Operation(summary = "校验该用户是否有权访问该项目") + @Parameter(name = "userId", description = "用户编号", required = true) + @Parameter(name = "projectId", description = "项目编号", required = true) + CommonResult isProjectAuthorized(@RequestParam("userId") Long userId, + @RequestParam("projectId") Long projectId); + } diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java index 83725f0d..9c8d9a0d 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/security/ProjectSecurityWebFilter.java @@ -103,9 +103,8 @@ public class ProjectSecurityWebFilter extends ApiRequestFilter { return; } } else { - // 4. 请求已携带 project-id,校验用户是否有权限访问该项目 - List authorizedProjectIds = projectFrameworkService.getAuthorizedProjectIds(user.getId()); - if (!authorizedProjectIds.contains(projectId)) { + // 4. 请求已携带 project-id,单项校验授权(超管直通;普通用户走主键索引) + if (!projectFrameworkService.isProjectAuthorized(user.getId(), projectId)) { log.error("[doFilterInternal][用户({}) 无权访问项目({}),URL({}/{})]", user.getId(), projectId, request.getRequestURI(), request.getMethod()); ServletUtils.writeJSON(response, CommonResult.error( diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java index 746df824..7fa60846 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkService.java @@ -39,6 +39,15 @@ public interface ProjectFrameworkService { */ Long getDefaultProjectId(Long userId); + /** + * 单项校验:该用户是否有权访问该项目(带本地短缓存) + * + * @param userId 用户编号 + * @param projectId 项目编号 + * @return 是否授权 + */ + boolean isProjectAuthorized(Long userId, Long projectId); + /** * 清除用户的授权项目缓存(授权变更时调用) * diff --git a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java index ed0f2aa5..2fc46272 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java +++ b/viewsh-framework/viewsh-spring-boot-starter-biz-tenant/src/main/java/com/viewsh/framework/tenant/core/service/ProjectFrameworkServiceImpl.java @@ -9,6 +9,7 @@ import lombok.SneakyThrows; import java.time.Duration; import java.util.List; +import java.util.Objects; import static com.viewsh.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache; @@ -89,9 +90,42 @@ public class ProjectFrameworkServiceImpl implements ProjectFrameworkService { return projectApi.getDefaultProjectId(userId).getCheckedData(); } + /** + * 针对 {@link #isProjectAuthorized(Long, Long)} 的本地缓存 + * Key: (userId, projectId), Value: 是否授权 + * TTL: 60 秒(授权变更后最长 1 分钟生效;写入点可调 invalidateAuthorizedProjectCache 提前生效) + */ + private final LoadingCache projectAuthorizedCache = buildAsyncReloadingCache( + Duration.ofSeconds(60L), + new CacheLoader() { + + @Override + public Boolean load(AuthKey key) { + return projectApi.isProjectAuthorized(key.userId(), key.projectId()).getCheckedData(); + } + + }); + + @Override + @SneakyThrows + public boolean isProjectAuthorized(Long userId, Long projectId) { + if (userId == null || projectId == null) { + return false; + } + return Boolean.TRUE.equals(projectAuthorizedCache.get(new AuthKey(userId, projectId))); + } + @Override public void invalidateAuthorizedProjectCache(Long userId) { authorizedProjectCache.invalidate(userId); + // 同步清 isProjectAuthorized 中属于该用户的所有缓存条目 + projectAuthorizedCache.asMap().keySet().removeIf(k -> Objects.equals(k.userId(), userId)); + } + + /** + * 缓存 key:(userId, projectId) + */ + private record AuthKey(Long userId, Long projectId) { } } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java index 20111d8b..8baa5d03 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/api/project/ProjectApiImpl.java @@ -41,4 +41,9 @@ public class ProjectApiImpl implements ProjectCommonApi { return success(projectService.getDefaultProjectId(userId)); } + @Override + public CommonResult isProjectAuthorized(Long userId, Long projectId) { + return success(projectService.isProjectAuthorized(userId, projectId)); + } + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java index a1154b47..91dec7fd 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/controller/admin/auth/AuthController.java @@ -6,14 +6,17 @@ import com.viewsh.framework.common.enums.CommonStatusEnum; import com.viewsh.framework.common.enums.UserTypeEnum; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.security.config.SecurityProperties; +import com.viewsh.framework.security.core.LoginUser; import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; import com.viewsh.module.system.controller.admin.auth.vo.*; import com.viewsh.module.system.convert.auth.AuthConvert; +import com.viewsh.module.system.dal.dataobject.oauth2.OAuth2ClientDO; import com.viewsh.module.system.dal.dataobject.permission.MenuDO; import com.viewsh.module.system.dal.dataobject.permission.RoleDO; import com.viewsh.module.system.dal.dataobject.user.AdminUserDO; import com.viewsh.module.system.enums.logger.LoginLogTypeEnum; import com.viewsh.module.system.service.auth.AdminAuthService; +import com.viewsh.module.system.service.oauth2.OAuth2ClientService; import com.viewsh.module.system.service.permission.MenuService; import com.viewsh.module.system.service.permission.PermissionService; import com.viewsh.module.system.service.permission.RoleService; @@ -58,6 +61,8 @@ public class AuthController { private PermissionService permissionService; @Resource private SocialClientService socialClientService; + @Resource + private OAuth2ClientService oauth2ClientService; @Resource private SecurityProperties securityProperties; @@ -98,8 +103,8 @@ public class AuthController { return success(null); } - // 1.2 获得角色列表 - Set roleIds = permissionService.getUserRoleIdListByUserId(getLoginUserId()); + // 1.2 获得角色列表(走 Redis 缓存,避免每次登录/重连都打 system_user_role) + Set roleIds = permissionService.getUserRoleIdListByUserIdFromCache(getLoginUserId()); if (CollUtil.isEmpty(roleIds)) { return success(AuthConvert.INSTANCE.convert(user, Collections.emptyList(), Collections.emptyList())); } @@ -110,11 +115,27 @@ public class AuthController { Set menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId)); List menuList = menuService.getMenuList(menuIds); menuList = menuService.filterDisableMenus(menuList); + // 1.4 按当前登录的 OAuth2 客户端的 platform,过滤菜单(platform 为空的菜单两端都展示) + // 例:iot-client 登录 → 只下发 platform=iot 或 NULL 的菜单;default 登录 → 只下发 platform=biz 或 NULL 的菜单 + menuList = menuService.filterMenusByPlatform(menuList, getCurrentClientPlatform()); // 2. 拼接结果返回 return success(AuthConvert.INSTANCE.convert(user, roles, menuList)); } + /** + * 取当前登录用户所用的 OAuth2 客户端的 platform 字段,用于按前端来源过滤菜单。 + * 缺省(未登录、客户端不存在或客户端未配置 platform)返回 null,表示不做过滤。 + */ + private String getCurrentClientPlatform() { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null || StrUtil.isBlank(loginUser.getClientId())) { + return null; + } + OAuth2ClientDO client = oauth2ClientService.getOAuth2ClientFromCache(loginUser.getClientId()); + return client == null ? null : client.getPlatform(); + } + @PostMapping("/register") @PermitAll @Operation(summary = "注册用户") diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java index 41aa030d..10fe7976 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectService.java @@ -118,4 +118,16 @@ public interface ProjectService { */ List getAuthorizedEnabledProjects(Long userId); + /** + * 单项校验:该用户是否有权访问该项目 + * + * 相比 {@link #getAuthorizedProjectIds(Long)} 取全量再 contains, + * 此方法对超管直接返回 true、对普通用户走主键索引单行查询,显著降低 Filter 每请求开销。 + * + * @param userId 用户编号 + * @param projectId 项目编号 + * @return 是否授权 + */ + boolean isProjectAuthorized(Long userId, Long projectId); + } diff --git a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java index d84d12eb..17f9fedf 100644 --- a/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java +++ b/viewsh-module-system/viewsh-module-system-server/src/main/java/com/viewsh/module/system/service/project/ProjectServiceImpl.java @@ -208,17 +208,35 @@ public class ProjectServiceImpl implements ProjectService { if (authorizedProjectIds.size() == 1) { return authorizedProjectIds.get(0); } - // 2. 查找 DEFAULT 项目 - for (Long projectId : authorizedProjectIds) { - ProjectDO project = projectMapper.selectById(projectId); - if (project != null && ProjectDO.CODE_DEFAULT.equals(project.getCode())) { + // 2. 一次批量查出所有候选项目(避免 N 次 selectById) + List projects = projectMapper.selectByIds(authorizedProjectIds); + // 2.1 查找 DEFAULT 编码的项目 + for (ProjectDO project : projects) { + if (ProjectDO.CODE_DEFAULT.equals(project.getCode())) { return project.getId(); } } - // 3. 取最小 ID + // 3. 退化到最小 ID return authorizedProjectIds.stream().min(Long::compareTo).orElse(null); } + @Override + public boolean isProjectAuthorized(Long userId, Long projectId) { + if (userId == null || projectId == null) { + return false; + } + // 超管直接放行,不查 user_project 表 + if (isSuperAdmin(userId)) { + return true; + } + // 普通用户走 (user_id, project_id) 唯一索引单行查询 + Long count = userProjectMapper.selectCount( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(UserProjectDO::getUserId, userId) + .eq(UserProjectDO::getProjectId, projectId)); + return count != null && count > 0; + } + private void validateProjectExists(Long id) { if (projectMapper.selectById(id) == null) { throw exception(PROJECT_NOT_EXISTS);