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 缓存注解契约 =====================
/**