diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java index 57ed857..ce8de10 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java @@ -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 实例的本地缓存;关闭多租户时共用一个缓存 *
* 每分钟触发一次异步 reload,先读 max(update_time),没变就复用旧实例(避免 trie 重建),变了才重新读词库 + 重建。
* 单实例 CRUD 后另外通过 {@link #invalidateSensitiveWordBsCaches()} 立即让本机失效,多实例靠定时刷新最长 1 分钟内收敛。
@@ -87,8 +96,8 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
@Override
public ListenableFuture
- * 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。
+ * 关闭多租户时失效统一缓存;开启时仅失效当前租户,无租户上下文则失效所有租户。
*/
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);
}
diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java
index 5e7711d..8d366b1 100644
--- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java
+++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java
@@ -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