From 459986bfca82fe006c7d111f43bcfaa6c5891d4e Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 26 Sep 2026 17:44:32 +0800 Subject: [PATCH] fix(im): 修复关闭多租户后敏感词检测报错的问题 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java | 29 +++++++++++++++++++++++------ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java | 225 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------- 2 files changed, 183 insertions(+), 71 deletions(-) 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 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 words = sensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); @@ -122,9 +131,16 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { /** * 强制让敏感词缓存失效,下次访问按最新 DB 重建 *

- * 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。 + * 关闭多租户时失效统一缓存;开启时仅失效当前租户,无租户上下文则失效所有租户。 */ 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 cache = (LoadingCache) 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()); } -- libgit2 0.21.4