diff --git a/src/main/java/com/yunhai/userbase/controller/api/CacheRegistryController.java b/src/main/java/com/yunhai/userbase/controller/api/CacheRegistryController.java index bbf29c8..f9599e8 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/CacheRegistryController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/CacheRegistryController.java @@ -5,6 +5,7 @@ import com.yunhai.userbase.config.CacheConfig; import com.yunhai.userbase.config.CacheNames; import com.yunhai.userbase.config.CacheNames.Meta; import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.service.impl.IdentityService; import io.swagger.v3.oas.annotations.Operation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -32,6 +33,9 @@ import java.util.TreeSet; * × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats); *
  • unregisteredRuntimeCaches:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);
  • *
  • declaredButUnusedCaches:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。
  • + *
  • identityCurrentNullLookups:无身份身份查询累计次数(进程级)。identityCurrent 对 null 不缓存 + * (unless 拦截),故「无身份请求数」可直接读出;与埋点里 identity/current 的调用数相除即得空值占比, + * 是判断是否值得为 null 加哨兵缓存的唯一依据。Redis 侧缓存无命中率输出,此计数是其替代观测面。
  • * * 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」—— * 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。
    @@ -44,6 +48,10 @@ public class CacheRegistryController { @Autowired private CacheManager cacheManager; + /** 无身份查询计数来源(identityCurrent 对 null 不缓存,计数即无身份请求数)。 */ + @Autowired + private IdentityService identityService; + /** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */ @Autowired @Qualifier("caffeineCacheManager") @@ -91,6 +99,7 @@ public class CacheRegistryController { result.put("caches", caches); result.put("unregisteredRuntimeCaches", unregistered); result.put("declaredButUnusedCaches", unused); + result.put("identityCurrentNullLookups", identityService.getNullIdentityLookupCount()); return result; } diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java index 342b6a2..ae26459 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; /** @@ -60,6 +61,19 @@ public class IdentityService { @Autowired private ApplicationEventPublisher eventPublisher; + /** + * 无身份({@link #getCurrent} 走到 null)请求累计计数。
    + * 与 {@code unless="#result == null"} 呼应:null 不入缓存,故该分支每次请求都会执行, + * 计数即「无身份请求数」——与埋点里 identity/current 的调用数相除即得空值占比, + * 是判断是否值得为 null 加哨兵缓存的唯一依据(对账接口 {@code /cache/registry} 输出)。 + */ + private final AtomicLong nullIdentityLookups = new AtomicLong(); + + /** 无身份身份查询累计次数(进程级累计,重启归零)。 */ + public long getNullIdentityLookupCount() { + return nullIdentityLookups.get(); + } + /** * 用户全部可选身份(正常态),用于「切换身份」页渲染。 * 排序:主身份优先 → sortNo 升序 → id 升序(「未切换默认第一个」的确定性保证)。 @@ -101,7 +115,8 @@ public class IdentityService { if (mainRel != null) { return toBoaRoleInfo(mainRel.getRoleId(), mainRel.getSchoolId()); } - // 连主身份都没有(测试期初数据尚未建立):返回 null + // 连主身份都没有(测试期初数据尚未建立):返回 null(unless 拦下不入缓存,故此处每次请求都到) + nullIdentityLookups.incrementAndGet(); return null; } @@ -144,42 +159,65 @@ public class IdentityService { * 单用户主身份(替代 sys_user_role_rel.is_main 物化字段的读时计算,语义与原「主校首行置 1」一致):
    * 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) → * 该校正常态身份行中 sortNo 最小、并列取 id 最小的一条。
    - * 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。 + * 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。

    + * 求值顺序:先取身份行,空则立即 null——身份行是单表带索引的廉价查询,而 resolveSchoolId 沿 + * sys_group 祖先链逐级 findById(全项目无组缓存);无身份用户在旧顺序下把整条组链走完仍注定 null, + * 新顺序把这条路径由 6~8 次 SQL 降到 2 次。有身份用户的结果与查询次数不变,只是先后互换。 */ public SysUserRoleRel resolveMainIdentity(Integer userId) { + List rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL); + if (rels == null || rels.isEmpty()) { + return null; + } SysUserGroupRel mainGroup = mainGroupResolver.resolve(userId).orElse(null); if (mainGroup == null) { return null; } Integer mainSchoolId = sysGroupService.resolveSchoolId(mainGroup.getGroupId()); - return pickMainIdentity(mainSchoolId, sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL)); + return pickMainIdentity(mainSchoolId, rels); } /** - * 批量主身份解析:列表页身份名列 + 导出用,全程 2 次组查询 + 1 次身份行 IN 查询(无 N+1)。
    - * 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。 + * 批量主身份解析:列表页身份名列 + 导出用,全程 1 次身份行 IN 查询 + 2 次组查询(无 N+1)。
    + * 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。
    + * 求值顺序同 {@link #resolveMainIdentity}:先取身份行,一个都没有则直接返回、完全不碰组链; + * 只对确有身份行的用户解析主校——resolveSchoolIds 会全量加载 sys_group 建内存表,跳过空页收益明显。 */ public Map resolveMainIdentityByUser(List userIds) { Map result = new HashMap<>(); if (userIds == null || userIds.isEmpty()) { return result; } + Map> roleRelsByUser = sysUserRoleRelRepository + .findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream() + .filter(r -> r.getUserId() != null) + .collect(Collectors.groupingBy(SysUserRoleRel::getUserId)); + if (roleRelsByUser.isEmpty()) { + return result; + } Map mainGroupMap = mainGroupResolver.resolveByUserIds(userIds); if (mainGroupMap.isEmpty()) { return result; } - Map schoolByGroup = sysGroupService.resolveSchoolIds( - mainGroupMap.values().stream().map(SysUserGroupRel::getGroupId).collect(Collectors.toList())); - Map> roleRelsByUser = sysUserRoleRelRepository - .findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream() - .collect(Collectors.groupingBy(SysUserRoleRel::getUserId)); + List groupIds = mainGroupMap.entrySet().stream() + .filter(e -> roleRelsByUser.containsKey(e.getKey())) + .map(e -> e.getValue().getGroupId()) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map schoolByGroup = groupIds.isEmpty() + ? Collections.emptyMap() + : sysGroupService.resolveSchoolIds(groupIds); for (Map.Entry e : mainGroupMap.entrySet()) { + List rows = roleRelsByUser.get(e.getKey()); + if (rows == null || rows.isEmpty()) { + continue; + } Integer mainSchoolId = schoolByGroup.get(e.getValue().getGroupId()); if (mainSchoolId == null) { continue; } - SysUserRoleRel main = pickMainIdentity(mainSchoolId, - roleRelsByUser.getOrDefault(e.getKey(), Collections.emptyList())); + SysUserRoleRel main = pickMainIdentity(mainSchoolId, rows); if (main != null) { result.put(e.getKey(), main); } diff --git a/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java index bda2cdd..e4b5f41 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java @@ -26,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -75,13 +77,27 @@ class IdentityServiceTest { // ===================== 单用户 resolveMainIdentity ===================== - /** 无组关系 → null */ + /** 有身份行但无组关系 → null */ @Test void resolveMainIdentity_noGroup_returnsNull() { + when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL)) + .thenReturn(Collections.singletonList(roleRel(10, 1, 16, 0))); when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty()); assertNull(identityService.resolveMainIdentity(1)); } + /** + * 无身份行 → 立即 null,且完全不触碰组链。 + * 这是重排求值顺序的核心契约:无身份用户在旧顺序下走完整条 sys_group 祖先链(6~8 次 SQL)仍返回 null。 + */ + @Test + void resolveMainIdentity_noIdentityRows_shortCircuitsWithoutGroupQueries() { + when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL)) + .thenReturn(Collections.emptyList()); + assertNull(identityService.resolveMainIdentity(1)); + verifyNoInteractions(mainGroupResolver, sysGroupService); + } + /** 主校无身份行(身份行全在其它学校)→ null */ @Test void resolveMainIdentity_mainSchoolHasNoRows_returnsNull() { @@ -136,6 +152,48 @@ class IdentityServiceTest { assertTrue(identityService.resolveMainIdentityByUser(Collections.emptyList()).isEmpty()); } + /** 批量:全无身份行 → 不触碰组链(resolveSchoolIds 会全量加载 sys_group 建内存表) */ + @Test + void resolveMainIdentityByUser_noRows_skipsGroupResolution() { + when(sysUserRoleRelRepository.findByUserIdInAndStatus(Arrays.asList(1, 2), StatusEnum.NORMAL)) + .thenReturn(Collections.emptyList()); + assertTrue(identityService.resolveMainIdentityByUser(Arrays.asList(1, 2)).isEmpty()); + verifyNoInteractions(mainGroupResolver, sysGroupService); + } + + /** 批量:只为确有身份行的用户解析主校,无身份行用户的主组不进 resolveSchoolIds */ + @Test + void resolveMainIdentityByUser_onlyResolvesSchoolsForUsersWithRows() { + Map mainGroupMap = new HashMap<>(); + mainGroupMap.put(1, groupRel(16, 1)); + mainGroupMap.put(2, groupRel(20, 1)); // 用户 2 有主组但无身份行 + when(mainGroupResolver.resolveByUserIds(Arrays.asList(1, 2))).thenReturn(mainGroupMap); + when(sysUserRoleRelRepository.findByUserIdInAndStatus(Arrays.asList(1, 2), StatusEnum.NORMAL)) + .thenReturn(Collections.singletonList(roleRel(30, 1, 16, 1))); + Map schoolByGroup = new HashMap<>(); + schoolByGroup.put(16, 16); + when(sysGroupService.resolveSchoolIds(Collections.singletonList(16))).thenReturn(schoolByGroup); + + Map result = identityService.resolveMainIdentityByUser(Arrays.asList(1, 2)); + + assertEquals(1, result.size()); + assertEquals(Integer.valueOf(30), result.get(1).getId()); + verify(sysGroupService).resolveSchoolIds(Collections.singletonList(16)); + } + + // ===================== getCurrent 无身份计数 ===================== + + /** 无身份用户 getCurrent 返回 null,并计入无身份计数(是否加哨兵缓存的判断数据源) */ + @Test + void getCurrent_noIdentity_countsNullLookup() { + when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty()); + when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL)) + .thenReturn(Collections.emptyList()); + assertNull(identityService.getCurrent(1)); + assertEquals(1L, identityService.getNullIdentityLookupCount()); + verifyNoInteractions(mainGroupResolver, sysGroupService); + } + // ===================== getCurrent 缓存注解契约 ===================== /**