fix(im): 修复关闭多租户后敏感词检测报错的问题

This commit is contained in:
YunaiV
2026-09-26 17:44:32 +08:00
parent d1937e4330
commit 459986bfca
2 changed files with 183 additions and 71 deletions
@@ -22,6 +22,7 @@ import jakarta.annotation.Resource;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@@ -48,6 +49,14 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD
@Slf4j
public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
/**
* 关闭多租户时的统一缓存键,仅用于缓存,不作为查询的租户编号
*/
private static final Long SINGLE_TENANT_CACHE_KEY = 0L;
@Value("${yudao.tenant.enable:true}")
private boolean tenantEnable = true;
@Resource
private ImSensitiveWordMapper sensitiveWordMapper;
@@ -70,7 +79,7 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
}
/**
* 租户 → SensitiveWordBs 实例的本地缓存
* 租户 → SensitiveWordBs 实例的本地缓存;关闭多租户时共用一个缓存
* <p>
* 每分钟触发一次异步 reload,先读 max(update_time),没变就复用旧实例(避免 trie 重建),变了才重新读词库 + 重建。
* 单实例 CRUD 后另外通过 {@link #invalidateSensitiveWordBsCaches()} 立即让本机失效,多实例靠定时刷新最长 1 分钟内收敛。
@@ -87,8 +96,8 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
@Override
public ListenableFuture<SensitiveWordBsCache> reload(Long tenantId, SensitiveWordBsCache oldValue) {
// 异步刷新线程独立于业务线程,没有租户上下文;必须显式 TenantUtils.execute 设置,否则租户拦截器会按当前线程的空上下文拼 SQL
return Futures.immediateFuture(TenantUtils.execute(tenantId, () -> {
// 异步刷新时恢复租户上下文;关闭多租户时不设置租户编号
return Futures.immediateFuture(TenantUtils.execute(tenantEnable ? tenantId : null, () -> {
LocalDateTime currentMax = sensitiveWordMapper.selectMaxUpdateTime(tenantId);
// 没变 → 复用旧实例,避免无谓地重建 trie
if (Objects.equals(oldValue.getMaxUpdateTime(), currentMax)) {
@@ -102,7 +111,7 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
});
private SensitiveWordBsCache loadFresh(Long tenantId) {
return TenantUtils.execute(tenantId, () -> {
return TenantUtils.execute(tenantEnable ? tenantId : null, () -> {
// 先取基线时间再读词库:反过来在两次查询之间出现的新插入会被漏感知
LocalDateTime maxUpdateTime = sensitiveWordMapper.selectMaxUpdateTime(tenantId);
List<ImSensitiveWordDO> words = sensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
@@ -122,9 +131,16 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
/**
* 强制让敏感词缓存失效,下次访问按最新 DB 重建
* <p>
* 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。
* 关闭多租户时失效统一缓存;开启时仅失效当前租户,无租户上下文则失效所有租户。
*/
private void invalidateSensitiveWordBsCaches() {
// 情况一:租户未开启的清理
if (!tenantEnable) {
sensitiveWordBsCaches.invalidate(SINGLE_TENANT_CACHE_KEY);
return;
}
// 情况二:租户已开启的清理
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
sensitiveWordBsCaches.invalidate(tenantId);
@@ -138,7 +154,8 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
if (StrUtil.isBlank(text)) {
return;
}
SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(TenantContextHolder.getRequiredTenantId()).getBs();
Long cacheKey = tenantEnable ? TenantContextHolder.getRequiredTenantId() : SINGLE_TENANT_CACHE_KEY;
SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(cacheKey).getBs();
if (bs.contains(text)) {
throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED);
}
@@ -2,24 +2,28 @@ package cn.iocoder.yudao.module.im.service.sensitiveword;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO;
import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper;
import com.google.common.cache.LoadingCache;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_DUPLICATED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_NOT_EXISTS;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -40,10 +44,9 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
@BeforeEach
public void setUp() {
// 设置租户上下文;validateText 与 loadFresh 都依赖 TenantContextHolder
// 设置租户上下文
TenantContextHolder.setTenantId(TENANT_ID);
// mock 启用敏感词列表;LoadingCache 在首次 validateText 时懒加载,无需主动 init
// 用 lenient 避免 null / empty 等不触发 cache load 的用例报 UnnecessaryStubbing
// mock 启用的敏感词,部分用例不触发缓存加载
lenient().when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("badword")
@@ -55,69 +58,165 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
@AfterEach
public void tearDown() {
// 清理租户上下文,避免污染其它测试
// 清理租户上下文
TenantContextHolder.clear();
}
@Test
public void testValidateText_null() {
// null 直接返回,不抛异常
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText(null));
}
@Test
public void testValidateText_empty() {
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText(""));
}
@Test
public void testValidateText_clean() {
// 正常文本不应命中
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world"));
}
@Test
public void testValidateText_tenantDisabled() {
// 准备上下文
ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false);
TenantContextHolder.clear();
// 调用,并断言
assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world"));
assertServiceException(() -> sensitiveWordService.validateText("this contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
assertNull(TenantContextHolder.getTenantId());
}
@Test
public void testValidateText_tenantEnabledWithoutTenantId() {
// 准备上下文
TenantContextHolder.clear();
// 调用,并断言异常
assertThrows(NullPointerException.class, () -> sensitiveWordService.validateText("hello world"));
// 断言
verifyNoInteractions(imSensitiveWordMapper);
}
@Test
public void testValidateText_tenantIsolation() {
// mock 不同租户的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenAnswer(invocation -> List.of(ImSensitiveWordDO.builder()
.word(TenantContextHolder.getRequiredTenantId().equals(TENANT_ID) ? "firstbad" : "secondbad")
.build()));
// 调用,并断言当前租户的词库
assertServiceException(() -> sensitiveWordService.validateText("firstbad"), MESSAGE_SENSITIVE_WORD_BLOCKED);
assertDoesNotThrow(() -> sensitiveWordService.validateText("secondbad"));
// 调用,并断言切换租户后的词库
TenantContextHolder.setTenantId(2L);
assertDoesNotThrow(() -> sensitiveWordService.validateText("firstbad"));
assertServiceException(() -> sensitiveWordService.validateText("secondbad"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 调用,并断言切回租户后复用缓存
TenantContextHolder.setTenantId(TENANT_ID);
assertServiceException(() -> sensitiveWordService.validateText("firstbad"), MESSAGE_SENSITIVE_WORD_BLOCKED);
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWordList_tenantDisabled() {
// 准备上下文
ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false);
TenantContextHolder.clear();
// 准备缓存
sensitiveWordService.validateText("hello world");
// 调用,并断言携带租户编号时仍复用缓存
TenantContextHolder.setTenantId(2L);
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// mock 删除后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of());
// 调用
sensitiveWordService.deleteSensitiveWordList(ListUtil.of(1L, 2L));
// 断言缓存已刷新,上下文不变
assertDoesNotThrow(() -> sensitiveWordService.validateText("badword"));
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
assertEquals(2L, TenantContextHolder.getTenantId());
}
@Test
@SuppressWarnings("unchecked")
public void testValidateText_tenantDisabledRefresh() {
// 准备上下文
ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false);
TenantContextHolder.clear();
// mock 初始更新时间
when(imSensitiveWordMapper.selectMaxUpdateTime(0L)).thenReturn(LocalDateTime.of(2026, 9, 1, 0, 0));
// 准备缓存
sensitiveWordService.validateText("hello world");
// mock 词库更新,并断言刷新线程无租户编号
when(imSensitiveWordMapper.selectMaxUpdateTime(0L)).thenAnswer(invocation -> {
assertNull(TenantContextHolder.getTenantId());
return LocalDateTime.of(2026, 9, 2, 0, 0);
});
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(List.of(ImSensitiveWordDO.builder().word("newbad").build()));
// 调用
LoadingCache<Long, ?> cache = (LoadingCache<Long, ?>) ReflectionTestUtils.getField(
sensitiveWordService, "sensitiveWordBsCaches");
cache.refresh(0L);
// 断言缓存异步刷新
await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertServiceException(() -> sensitiveWordService.validateText("newbad"), MESSAGE_SENSITIVE_WORD_BLOCKED));
assertDoesNotThrow(() -> sensitiveWordService.validateText("badword"));
}
@Test
public void testValidateText_hitEnglish() {
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("this contains badword here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.validateText("this contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
}
@Test
public void testValidateText_hitChinese() {
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"), MESSAGE_SENSITIVE_WORD_BLOCKED);
}
@Test
public void testValidateText_lazyLoadsCacheOnFirstCall() {
// 调用:首次 validateText 应触发 cache load
// 调用
sensitiveWordService.validateText("hello world");
// 断言:mapper 各调用 1 次(loadFresh 里先取 maxUpdateTime 再读词库)
// 断言
verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testValidateText_reusesCachedBsAcrossCalls() {
// 调用:连续两次 validateText
// 调用
sensitiveWordService.validateText("hello world");
sensitiveWordService.validateText("another text");
// 断言:第二次复用 cache,mapper 仍只被调用 1 次
// 断言复用缓存
verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testCreateSensitiveWord_invalidatesCacheAndReloadsOnNextValidate() {
// 准备:首次 validateText 触发 cache load(旧词库)
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:mapper 返回新词库(额外多一个 newbad),并让 createSensitiveWord 走通
// mock 新增后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("badword")
@@ -129,149 +228,145 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
));
when(imSensitiveWordMapper.selectByWord("newbad")).thenReturn(null);
// 调用:新增敏感词,触发 invalidate
// 准备参数
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setWord("newbad");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
// 调用
sensitiveWordService.createSensitiveWord(reqVO);
// 调用:再次 validateText,应触发重新 load
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains newbad here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 旧词依然命中
assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains badword here"));
// 断言缓存已刷新
assertServiceException(() -> sensitiveWordService.validateText("contains newbad here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言原有敏感词仍然生效
assertServiceException(() -> sensitiveWordService.validateText("contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言:selectListByStatus 共被调用 2 次(首次 load + invalidate 后 reload)
// 断言词库重新加载
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testCreateSensitiveWord_duplicateWord_throws() {
// 准备:mock 已存在同名敏感词
// mock 同名敏感词
when(imSensitiveWordMapper.selectByWord("dup")).thenReturn(
ImSensitiveWordDO.builder().id(99L).word("dup")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
// 调用 + 断言:重复敏感词抛 SENSITIVE_WORD_DUPLICATED
// 准备参数
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setWord("dup");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.createSensitiveWord(reqVO));
assertEquals(SENSITIVE_WORD_DUPLICATED.getCode(), exception.getCode());
// 断言:未走到 insert
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.createSensitiveWord(reqVO), SENSITIVE_WORD_DUPLICATED, reqVO.getWord());
// 断言
verify(imSensitiveWordMapper, never()).insert(any(ImSensitiveWordDO.class));
}
@Test
public void testUpdateSensitiveWord_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:让 update 校验通过
// mock 原敏感词
when(imSensitiveWordMapper.selectById(1L)).thenReturn(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
when(imSensitiveWordMapper.selectByWord("updatedbad")).thenReturn(null);
// 准备:reload 时返回新词库
// mock 修改后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(1L).word("updatedbad")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
// 调用:更新敏感词,触发 invalidate
// 准备参数
ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
reqVO.setId(1L);
reqVO.setWord("updatedbad");
reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
// 调用
sensitiveWordService.updateSensitiveWord(reqVO);
// 调用 + 断言:再次 validateText 应使用新词库
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("contains updatedbad here"));
assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
// 断言缓存已刷新
assertServiceException(() -> sensitiveWordService.validateText("contains updatedbad here"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言:selectListByStatus 共被调用 2 次
// 断言词库重新加载
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWord_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:让 delete 校验通过
// mock 原敏感词
when(imSensitiveWordMapper.selectById(1L)).thenReturn(
ImSensitiveWordDO.builder().id(1L).word("badword")
.status(CommonStatusEnum.ENABLE.getStatus()).build());
// 准备:reload 时返回剩余词库(只剩中文那条)
// mock 删除后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of(
ImSensitiveWordDO.builder().id(2L).word("违禁词")
.status(CommonStatusEnum.ENABLE.getStatus()).build()
));
// 调用:删除 badword,触发 invalidate
// 调用
sensitiveWordService.deleteSensitiveWord(1L);
// 调用 + 断言:badword 已被删,不再命中
// 断言缓存已刷新
assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
// 中文词依然在
assertThrows(ServiceException.class,
() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
// 断言未删除的敏感词仍然生效
assertServiceException(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"), MESSAGE_SENSITIVE_WORD_BLOCKED);
// 断言:selectListByStatus 共被调用 2 次
// 断言词库重新加载
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWord_notExists_throws() {
// 准备:selectById 返回 null
// mock 敏感词不存在
when(imSensitiveWordMapper.selectById(999L)).thenReturn(null);
// 调用 + 断言:抛 SENSITIVE_WORD_NOT_EXISTS
ServiceException exception = assertThrows(ServiceException.class,
() -> sensitiveWordService.deleteSensitiveWord(999L));
assertEquals(SENSITIVE_WORD_NOT_EXISTS.getCode(), exception.getCode());
// 调用,并断言异常
assertServiceException(() -> sensitiveWordService.deleteSensitiveWord(999L), SENSITIVE_WORD_NOT_EXISTS);
// 断言:未走到 deleteById
// 断言
verify(imSensitiveWordMapper, never()).deleteById(anyLong());
}
@Test
public void testDeleteSensitiveWordList_invalidatesCache() {
// 准备:首次 validateText 触发 cache load
// 准备缓存
sensitiveWordService.validateText("hello world");
verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
// 准备:reload 时返回空词库
// mock 删除后的词库
when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
.thenReturn(ListUtil.of());
// 调用:批量删除,触发 invalidate
// 调用
sensitiveWordService.deleteSensitiveWordList(ListUtil.of(1L, 2L));
// 调用 + 断言:所有词都不再命中
// 断言缓存已刷新
assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
assertDoesNotThrow(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
// 断言:deleteByIds 被调用 1 次;selectListByStatus 共 2 次
// 断言
verify(imSensitiveWordMapper, times(1)).deleteByIds(ListUtil.of(1L, 2L));
verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
}
@Test
public void testDeleteSensitiveWordList_emptyIds_skip() {
// 调用:空列表直接返回
// 调用
sensitiveWordService.deleteSensitiveWordList(ListUtil.of());
// 断言:mapper 不被调用
// 断言
verify(imSensitiveWordMapper, never()).deleteByIds(anyList());
}