- 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;
|
||||||
import com.yunhai.userbase.config.CacheNames.Meta;
|
import com.yunhai.userbase.config.CacheNames.Meta;
|
||||||
import com.yunhai.userbase.config.Config;
|
import com.yunhai.userbase.config.Config;
|
||||||
|
import com.yunhai.userbase.service.impl.IdentityService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
@@ -32,6 +33,9 @@ import java.util.TreeSet;
|
|||||||
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
|
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
|
||||||
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
|
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
|
||||||
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
|
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
|
||||||
|
* <li><b>identityCurrentNullLookups</b>:无身份身份查询累计次数(进程级)。identityCurrent 对 null 不缓存
|
||||||
|
* (unless 拦截),故「无身份请求数」可直接读出;与埋点里 identity/current 的调用数相除即得空值占比,
|
||||||
|
* 是判断是否值得为 null 加哨兵缓存的唯一依据。Redis 侧缓存无命中率输出,此计数是其替代观测面。</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
|
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
|
||||||
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
|
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
|
||||||
@@ -44,6 +48,10 @@ public class CacheRegistryController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private CacheManager cacheManager;
|
private CacheManager cacheManager;
|
||||||
|
|
||||||
|
/** 无身份查询计数来源(identityCurrent 对 null 不缓存,计数即无身份请求数)。 */
|
||||||
|
@Autowired
|
||||||
|
private IdentityService identityService;
|
||||||
|
|
||||||
/** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */
|
/** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("caffeineCacheManager")
|
@Qualifier("caffeineCacheManager")
|
||||||
@@ -91,6 +99,7 @@ public class CacheRegistryController {
|
|||||||
result.put("caches", caches);
|
result.put("caches", caches);
|
||||||
result.put("unregisteredRuntimeCaches", unregistered);
|
result.put("unregisteredRuntimeCaches", unregistered);
|
||||||
result.put("declaredButUnusedCaches", unused);
|
result.put("declaredButUnusedCaches", unused);
|
||||||
|
result.put("identityCurrentNullLookups", identityService.getNullIdentityLookupCount());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +61,19 @@ public class IdentityService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ApplicationEventPublisher eventPublisher;
|
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 升序(「未切换默认第一个」的确定性保证)。
|
* 排序:主身份优先 → sortNo 升序 → id 升序(「未切换默认第一个」的确定性保证)。
|
||||||
@@ -101,7 +115,8 @@ public class IdentityService {
|
|||||||
if (mainRel != null) {
|
if (mainRel != null) {
|
||||||
return toBoaRoleInfo(mainRel.getRoleId(), mainRel.getSchoolId());
|
return toBoaRoleInfo(mainRel.getRoleId(), mainRel.getSchoolId());
|
||||||
}
|
}
|
||||||
// 连主身份都没有(测试期初数据尚未建立):返回 null
|
// 连主身份都没有(测试期初数据尚未建立):返回 null(unless 拦下不入缓存,故此处每次请求都到)
|
||||||
|
nullIdentityLookups.incrementAndGet();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,42 +159,65 @@ public class IdentityService {
|
|||||||
* 单用户主身份(替代 sys_user_role_rel.is_main 物化字段的读时计算,语义与原「主校首行置 1」一致):<br>
|
* 单用户主身份(替代 sys_user_role_rel.is_main 物化字段的读时计算,语义与原「主校首行置 1」一致):<br>
|
||||||
* 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) →
|
* 主组(MainGroupResolver,isMain=1 取 id 最小,无主兜底 id 最小)→ resolveSchoolId(主组) →
|
||||||
* 该校正常态身份行中 sortNo 最小、并列取 id 最小的一条。<br>
|
* 该校正常态身份行中 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) {
|
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);
|
SysUserGroupRel mainGroup = mainGroupResolver.resolve(userId).orElse(null);
|
||||||
if (mainGroup == null) {
|
if (mainGroup == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
Integer mainSchoolId = sysGroupService.resolveSchoolId(mainGroup.getGroupId());
|
Integer mainSchoolId = sysGroupService.resolveSchoolId(mainGroup.getGroupId());
|
||||||
return pickMainIdentity(mainSchoolId, sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL));
|
return pickMainIdentity(mainSchoolId, rels);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量主身份解析:列表页身份名列 + 导出用,全程 2 次组查询 + 1 次身份行 IN 查询(无 N+1)。<br>
|
* 批量主身份解析:列表页身份名列 + 导出用,全程 1 次身份行 IN 查询 + 2 次组查询(无 N+1)。<br>
|
||||||
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。
|
* 返回 userId → 主身份行;无主组 / 主校无身份行的用户不出现。<br>
|
||||||
|
* 求值顺序同 {@link #resolveMainIdentity}:先取身份行,一个都没有则直接返回、完全不碰组链;
|
||||||
|
* 只对确有身份行的用户解析主校——resolveSchoolIds 会全量加载 sys_group 建内存表,跳过空页收益明显。
|
||||||
*/
|
*/
|
||||||
public Map<Integer, SysUserRoleRel> resolveMainIdentityByUser(List<Integer> userIds) {
|
public Map<Integer, SysUserRoleRel> resolveMainIdentityByUser(List<Integer> userIds) {
|
||||||
Map<Integer, SysUserRoleRel> result = new HashMap<>();
|
Map<Integer, SysUserRoleRel> result = new HashMap<>();
|
||||||
if (userIds == null || userIds.isEmpty()) {
|
if (userIds == null || userIds.isEmpty()) {
|
||||||
return result;
|
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);
|
Map<Integer, SysUserGroupRel> mainGroupMap = mainGroupResolver.resolveByUserIds(userIds);
|
||||||
if (mainGroupMap.isEmpty()) {
|
if (mainGroupMap.isEmpty()) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
Map<Integer, Integer> schoolByGroup = sysGroupService.resolveSchoolIds(
|
List<Integer> groupIds = mainGroupMap.entrySet().stream()
|
||||||
mainGroupMap.values().stream().map(SysUserGroupRel::getGroupId).collect(Collectors.toList()));
|
.filter(e -> roleRelsByUser.containsKey(e.getKey()))
|
||||||
Map<Integer, List<SysUserRoleRel>> roleRelsByUser = sysUserRoleRelRepository
|
.map(e -> e.getValue().getGroupId())
|
||||||
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL).stream()
|
.filter(Objects::nonNull)
|
||||||
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
Map<Integer, Integer> schoolByGroup = groupIds.isEmpty()
|
||||||
|
? Collections.emptyMap()
|
||||||
|
: sysGroupService.resolveSchoolIds(groupIds);
|
||||||
for (Map.Entry<Integer, SysUserGroupRel> e : mainGroupMap.entrySet()) {
|
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());
|
Integer mainSchoolId = schoolByGroup.get(e.getValue().getGroupId());
|
||||||
if (mainSchoolId == null) {
|
if (mainSchoolId == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SysUserRoleRel main = pickMainIdentity(mainSchoolId,
|
SysUserRoleRel main = pickMainIdentity(mainSchoolId, rows);
|
||||||
roleRelsByUser.getOrDefault(e.getKey(), Collections.emptyList()));
|
|
||||||
if (main != null) {
|
if (main != null) {
|
||||||
result.put(e.getKey(), main);
|
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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
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;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,13 +77,27 @@ class IdentityServiceTest {
|
|||||||
|
|
||||||
// ===================== 单用户 resolveMainIdentity =====================
|
// ===================== 单用户 resolveMainIdentity =====================
|
||||||
|
|
||||||
/** 无组关系 → null */
|
/** 有身份行但无组关系 → null */
|
||||||
@Test
|
@Test
|
||||||
void resolveMainIdentity_noGroup_returnsNull() {
|
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());
|
when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
|
||||||
assertNull(identityService.resolveMainIdentity(1));
|
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 */
|
/** 主校无身份行(身份行全在其它学校)→ null */
|
||||||
@Test
|
@Test
|
||||||
void resolveMainIdentity_mainSchoolHasNoRows_returnsNull() {
|
void resolveMainIdentity_mainSchoolHasNoRows_returnsNull() {
|
||||||
@@ -136,6 +152,48 @@ class IdentityServiceTest {
|
|||||||
assertTrue(identityService.resolveMainIdentityByUser(Collections.emptyList()).isEmpty());
|
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 缓存注解契约 =====================
|
// ===================== getCurrent 缓存注解契约 =====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user