- resolveMainIdentity / resolveMainIdentityByUser 求值顺序反转:先取身份行, 为空立即 null,不再先走 resolveSchoolId(沿 sys_group 祖先链无缓存、6~8 次 SQL); 批量版只对确有身份行的用户解析主校,跳过空页。有身份用户结果与查询次数不变。 - IdentityService 新增进程级 nullIdentityLookups,getCurrent 落 null 分支自增, 经 getNullIdentityLookupCount() 暴露;CacheRegistryController 在 /cache/registry 输出 identityCurrentNullLookups,作为「是否给 null 加哨兵缓存」的唯一数据源。 - IdentityServiceTest 补 4 用例锁住短路契约与计数,旧 noGroup 用例补桩身份行。
This commit is contained in:
@@ -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);</li>
|
||||
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
|
||||
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
|
||||
* <li><b>identityCurrentNullLookups</b>:无身份身份查询累计次数(进程级)。identityCurrent 对 null 不缓存
|
||||
* (unless 拦截),故「无身份请求数」可直接读出;与埋点里 identity/current 的调用数相除即得空值占比,
|
||||
* 是判断是否值得为 null 加哨兵缓存的唯一依据。Redis 侧缓存无命中率输出,此计数是其替代观测面。</li>
|
||||
* </ul>
|
||||
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
|
||||
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)请求累计计数。<br>
|
||||
* 与 {@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」一致):<br>
|
||||
* 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) →
|
||||
* 该校正常态身份行中 sortNo 最小、并列取 id 最小的一条。<br>
|
||||
* 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。
|
||||
* 无组关系 / 主校无身份行 → null(getCurrent 兜底与 UserCenterService 共用)。<br><br>
|
||||
* 求值顺序:先取身份行,空则立即 null——身份行是单表带索引的廉价查询,而 resolveSchoolId 沿
|
||||
* sys_group 祖先链逐级 findById(全项目无组缓存);无身份用户在旧顺序下把整条组链走完仍注定 null,
|
||||
* 新顺序把这条路径由 6~8 次 SQL 降到 2 次。有身份用户的结果与查询次数不变,只是先后互换。
|
||||
*/
|
||||
public SysUserRoleRel resolveMainIdentity(Integer userId) {
|
||||
List<SysUserRoleRel> 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)。<br>
|
||||
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。
|
||||
* 批量主身份解析:列表页身份名列 + 导出用,全程 1 次身份行 IN 查询 + 2 次组查询(无 N+1)。<br>
|
||||
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。<br>
|
||||
* 求值顺序同 {@link #resolveMainIdentity}:先取身份行,一个都没有则直接返回、完全不碰组链;
|
||||
* 只对确有身份行的用户解析主校——resolveSchoolIds 会全量加载 sys_group 建内存表,跳过空页收益明显。
|
||||
*/
|
||||
public Map<Integer, SysUserRoleRel> resolveMainIdentityByUser(List<Integer> userIds) {
|
||||
Map<Integer, SysUserRoleRel> result = new HashMap<>();
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Integer, List<SysUserRoleRel>> roleRelsByUser = sysUserRoleRelRepository
|
||||
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream()
|
||||
.filter(r -> r.getUserId() != null)
|
||||
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
|
||||
if (roleRelsByUser.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Integer, SysUserGroupRel> mainGroupMap = mainGroupResolver.resolveByUserIds(userIds);
|
||||
if (mainGroupMap.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Integer, Integer> schoolByGroup = sysGroupService.resolveSchoolIds(
|
||||
mainGroupMap.values().stream().map(SysUserGroupRel::getGroupId).collect(Collectors.toList()));
|
||||
Map<Integer, List<SysUserRoleRel>> roleRelsByUser = sysUserRoleRelRepository
|
||||
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream()
|
||||
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
|
||||
List<Integer> groupIds = mainGroupMap.entrySet().stream()
|
||||
.filter(e -> roleRelsByUser.containsKey(e.getKey()))
|
||||
.map(e -> e.getValue().getGroupId())
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<Integer, Integer> schoolByGroup = groupIds.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: sysGroupService.resolveSchoolIds(groupIds);
|
||||
for (Map.Entry<Integer, SysUserGroupRel> e : mainGroupMap.entrySet()) {
|
||||
List<SysUserRoleRel> 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);
|
||||
}
|
||||
|
||||
@@ -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<Integer, SysUserGroupRel> 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<Integer, Integer> schoolByGroup = new HashMap<>();
|
||||
schoolByGroup.put(16, 16);
|
||||
when(sysGroupService.resolveSchoolIds(Collections.singletonList(16))).thenReturn(schoolByGroup);
|
||||
|
||||
Map<Integer, SysUserRoleRel> 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 缓存注解契约 =====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user