perf(system): 项目授权校验改单行查询 + AuthController 切 FromCache
背景: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) <noreply@anthropic.com>
This commit is contained in:
@@ -35,4 +35,11 @@ public interface ProjectCommonApi {
|
||||
@Parameter(name = "userId", description = "用户编号", required = true)
|
||||
CommonResult<Long> getDefaultProjectId(@RequestParam("userId") Long userId);
|
||||
|
||||
@GetMapping(PREFIX + "/is-authorized")
|
||||
@Operation(summary = "校验该用户是否有权访问该项目")
|
||||
@Parameter(name = "userId", description = "用户编号", required = true)
|
||||
@Parameter(name = "projectId", description = "项目编号", required = true)
|
||||
CommonResult<Boolean> isProjectAuthorized(@RequestParam("userId") Long userId,
|
||||
@RequestParam("projectId") Long projectId);
|
||||
|
||||
}
|
||||
|
||||
@@ -103,9 +103,8 @@ public class ProjectSecurityWebFilter extends ApiRequestFilter {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 4. 请求已携带 project-id,校验用户是否有权限访问该项目
|
||||
List<Long> 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(
|
||||
|
||||
@@ -39,6 +39,15 @@ public interface ProjectFrameworkService {
|
||||
*/
|
||||
Long getDefaultProjectId(Long userId);
|
||||
|
||||
/**
|
||||
* 单项校验:该用户是否有权访问该项目(带本地短缓存)
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param projectId 项目编号
|
||||
* @return 是否授权
|
||||
*/
|
||||
boolean isProjectAuthorized(Long userId, Long projectId);
|
||||
|
||||
/**
|
||||
* 清除用户的授权项目缓存(授权变更时调用)
|
||||
*
|
||||
|
||||
@@ -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<AuthKey, Boolean> projectAuthorizedCache = buildAsyncReloadingCache(
|
||||
Duration.ofSeconds(60L),
|
||||
new CacheLoader<AuthKey, Boolean>() {
|
||||
|
||||
@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) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -41,4 +41,9 @@ public class ProjectApiImpl implements ProjectCommonApi {
|
||||
return success(projectService.getDefaultProjectId(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Boolean> isProjectAuthorized(Long userId, Long projectId) {
|
||||
return success(projectService.isProjectAuthorized(userId, projectId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Long> roleIds = permissionService.getUserRoleIdListByUserId(getLoginUserId());
|
||||
// 1.2 获得角色列表(走 Redis 缓存,避免每次登录/重连都打 system_user_role)
|
||||
Set<Long> 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<Long> menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId));
|
||||
List<MenuDO> 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 = "注册用户")
|
||||
|
||||
@@ -118,4 +118,16 @@ public interface ProjectService {
|
||||
*/
|
||||
List<ProjectDO> getAuthorizedEnabledProjects(Long userId);
|
||||
|
||||
/**
|
||||
* 单项校验:该用户是否有权访问该项目
|
||||
*
|
||||
* 相比 {@link #getAuthorizedProjectIds(Long)} 取全量再 contains,
|
||||
* 此方法对超管直接返回 true、对普通用户走主键索引单行查询,显著降低 Filter 每请求开销。
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param projectId 项目编号
|
||||
* @return 是否授权
|
||||
*/
|
||||
boolean isProjectAuthorized(Long userId, Long projectId);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ProjectDO> 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<UserProjectDO>()
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user