Commit 459986bfca82fe006c7d111f43bcfaa6c5891d4e
1 parent
d1937e43
fix(im): 修复关闭多租户后敏感词检测报错的问题
Showing
2 changed files
with
183 additions
and
71 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java
| ... | ... | @@ -22,6 +22,7 @@ import jakarta.annotation.Resource; |
| 22 | 22 | import lombok.AllArgsConstructor; |
| 23 | 23 | import lombok.Data; |
| 24 | 24 | import lombok.extern.slf4j.Slf4j; |
| 25 | +import org.springframework.beans.factory.annotation.Value; | |
| 25 | 26 | import org.springframework.stereotype.Service; |
| 26 | 27 | import org.springframework.validation.annotation.Validated; |
| 27 | 28 | |
| ... | ... | @@ -48,6 +49,14 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD |
| 48 | 49 | @Slf4j |
| 49 | 50 | public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { |
| 50 | 51 | |
| 52 | + /** | |
| 53 | + * 关闭多租户时的统一缓存键,仅用于缓存,不作为查询的租户编号 | |
| 54 | + */ | |
| 55 | + private static final Long SINGLE_TENANT_CACHE_KEY = 0L; | |
| 56 | + | |
| 57 | + @Value("${yudao.tenant.enable:true}") | |
| 58 | + private boolean tenantEnable = true; | |
| 59 | + | |
| 51 | 60 | @Resource |
| 52 | 61 | private ImSensitiveWordMapper sensitiveWordMapper; |
| 53 | 62 | |
| ... | ... | @@ -70,7 +79,7 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { |
| 70 | 79 | } |
| 71 | 80 | |
| 72 | 81 | /** |
| 73 | - * 租户 → SensitiveWordBs 实例的本地缓存 | |
| 82 | + * 租户 → SensitiveWordBs 实例的本地缓存;关闭多租户时共用一个缓存 | |
| 74 | 83 | * <p> |
| 75 | 84 | * 每分钟触发一次异步 reload,先读 max(update_time),没变就复用旧实例(避免 trie 重建),变了才重新读词库 + 重建。 |
| 76 | 85 | * 单实例 CRUD 后另外通过 {@link #invalidateSensitiveWordBsCaches()} 立即让本机失效,多实例靠定时刷新最长 1 分钟内收敛。 |
| ... | ... | @@ -87,8 +96,8 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { |
| 87 | 96 | |
| 88 | 97 | @Override |
| 89 | 98 | public ListenableFuture<SensitiveWordBsCache> reload(Long tenantId, SensitiveWordBsCache oldValue) { |
| 90 | - // 异步刷新线程独立于业务线程,没有租户上下文;必须显式 TenantUtils.execute 设置,否则租户拦截器会按当前线程的空上下文拼 SQL | |
| 91 | - return Futures.immediateFuture(TenantUtils.execute(tenantId, () -> { | |
| 99 | + // 异步刷新时恢复租户上下文;关闭多租户时不设置租户编号 | |
| 100 | + return Futures.immediateFuture(TenantUtils.execute(tenantEnable ? tenantId : null, () -> { | |
| 92 | 101 | LocalDateTime currentMax = sensitiveWordMapper.selectMaxUpdateTime(tenantId); |
| 93 | 102 | // 没变 → 复用旧实例,避免无谓地重建 trie |
| 94 | 103 | if (Objects.equals(oldValue.getMaxUpdateTime(), currentMax)) { |
| ... | ... | @@ -102,7 +111,7 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { |
| 102 | 111 | }); |
| 103 | 112 | |
| 104 | 113 | private SensitiveWordBsCache loadFresh(Long tenantId) { |
| 105 | - return TenantUtils.execute(tenantId, () -> { | |
| 114 | + return TenantUtils.execute(tenantEnable ? tenantId : null, () -> { | |
| 106 | 115 | // 先取基线时间再读词库:反过来在两次查询之间出现的新插入会被漏感知 |
| 107 | 116 | LocalDateTime maxUpdateTime = sensitiveWordMapper.selectMaxUpdateTime(tenantId); |
| 108 | 117 | List<ImSensitiveWordDO> words = sensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| ... | ... | @@ -122,9 +131,16 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { |
| 122 | 131 | /** |
| 123 | 132 | * 强制让敏感词缓存失效,下次访问按最新 DB 重建 |
| 124 | 133 | * <p> |
| 125 | - * 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。 | |
| 134 | + * 关闭多租户时失效统一缓存;开启时仅失效当前租户,无租户上下文则失效所有租户。 | |
| 126 | 135 | */ |
| 127 | 136 | private void invalidateSensitiveWordBsCaches() { |
| 137 | + // 情况一:租户未开启的清理 | |
| 138 | + if (!tenantEnable) { | |
| 139 | + sensitiveWordBsCaches.invalidate(SINGLE_TENANT_CACHE_KEY); | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + | |
| 143 | + // 情况二:租户已开启的清理 | |
| 128 | 144 | Long tenantId = TenantContextHolder.getTenantId(); |
| 129 | 145 | if (tenantId != null) { |
| 130 | 146 | sensitiveWordBsCaches.invalidate(tenantId); |
| ... | ... | @@ -138,7 +154,8 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { |
| 138 | 154 | if (StrUtil.isBlank(text)) { |
| 139 | 155 | return; |
| 140 | 156 | } |
| 141 | - SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(TenantContextHolder.getRequiredTenantId()).getBs(); | |
| 157 | + Long cacheKey = tenantEnable ? TenantContextHolder.getRequiredTenantId() : SINGLE_TENANT_CACHE_KEY; | |
| 158 | + SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(cacheKey).getBs(); | |
| 142 | 159 | if (bs.contains(text)) { |
| 143 | 160 | throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED); |
| 144 | 161 | } | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.ListUtil; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | -import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 6 | 5 | import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; |
| 7 | 6 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 8 | 7 | import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO; |
| 9 | 8 | import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO; |
| 10 | 9 | import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper; |
| 10 | +import com.google.common.cache.LoadingCache; | |
| 11 | 11 | import org.junit.jupiter.api.AfterEach; |
| 12 | 12 | import org.junit.jupiter.api.BeforeEach; |
| 13 | 13 | import org.junit.jupiter.api.Test; |
| 14 | 14 | import org.mockito.InjectMocks; |
| 15 | 15 | import org.mockito.Mock; |
| 16 | +import org.springframework.test.util.ReflectionTestUtils; | |
| 16 | 17 | |
| 18 | +import java.time.Duration; | |
| 17 | 19 | import java.time.LocalDateTime; |
| 18 | 20 | import java.util.List; |
| 19 | 21 | |
| 22 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | |
| 20 | 23 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED; |
| 21 | 24 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_DUPLICATED; |
| 22 | 25 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_NOT_EXISTS; |
| 26 | +import static org.awaitility.Awaitility.await; | |
| 23 | 27 | import static org.junit.jupiter.api.Assertions.*; |
| 24 | 28 | import static org.mockito.Mockito.*; |
| 25 | 29 | |
| ... | ... | @@ -40,10 +44,9 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { |
| 40 | 44 | |
| 41 | 45 | @BeforeEach |
| 42 | 46 | public void setUp() { |
| 43 | - // 设置租户上下文;validateText 与 loadFresh 都依赖 TenantContextHolder | |
| 47 | + // 设置租户上下文 | |
| 44 | 48 | TenantContextHolder.setTenantId(TENANT_ID); |
| 45 | - // mock 启用敏感词列表;LoadingCache 在首次 validateText 时懒加载,无需主动 init | |
| 46 | - // 用 lenient 避免 null / empty 等不触发 cache load 的用例报 UnnecessaryStubbing | |
| 49 | + // mock 启用的敏感词,部分用例不触发缓存加载 | |
| 47 | 50 | lenient().when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) |
| 48 | 51 | .thenReturn(ListUtil.of( |
| 49 | 52 | ImSensitiveWordDO.builder().id(1L).word("badword") |
| ... | ... | @@ -55,69 +58,165 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { |
| 55 | 58 | |
| 56 | 59 | @AfterEach |
| 57 | 60 | public void tearDown() { |
| 58 | - // 清理租户上下文,避免污染其它测试 | |
| 61 | + // 清理租户上下文 | |
| 59 | 62 | TenantContextHolder.clear(); |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | 65 | @Test |
| 63 | 66 | public void testValidateText_null() { |
| 64 | - // null 直接返回,不抛异常 | |
| 67 | + // 调用,并断言 | |
| 65 | 68 | assertDoesNotThrow(() -> sensitiveWordService.validateText(null)); |
| 66 | 69 | } |
| 67 | 70 | |
| 68 | 71 | @Test |
| 69 | 72 | public void testValidateText_empty() { |
| 73 | + // 调用,并断言 | |
| 70 | 74 | assertDoesNotThrow(() -> sensitiveWordService.validateText("")); |
| 71 | 75 | } |
| 72 | 76 | |
| 73 | 77 | @Test |
| 74 | 78 | public void testValidateText_clean() { |
| 75 | - // 正常文本不应命中 | |
| 79 | + // 调用,并断言 | |
| 76 | 80 | assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world")); |
| 77 | 81 | } |
| 78 | 82 | |
| 79 | 83 | @Test |
| 84 | + public void testValidateText_tenantDisabled() { | |
| 85 | + // 准备上下文 | |
| 86 | + ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false); | |
| 87 | + TenantContextHolder.clear(); | |
| 88 | + | |
| 89 | + // 调用,并断言 | |
| 90 | + assertDoesNotThrow(() -> sensitiveWordService.validateText("hello world")); | |
| 91 | + assertServiceException(() -> sensitiveWordService.validateText("this contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 92 | + assertNull(TenantContextHolder.getTenantId()); | |
| 93 | + } | |
| 94 | + | |
| 95 | + @Test | |
| 96 | + public void testValidateText_tenantEnabledWithoutTenantId() { | |
| 97 | + // 准备上下文 | |
| 98 | + TenantContextHolder.clear(); | |
| 99 | + | |
| 100 | + // 调用,并断言异常 | |
| 101 | + assertThrows(NullPointerException.class, () -> sensitiveWordService.validateText("hello world")); | |
| 102 | + // 断言 | |
| 103 | + verifyNoInteractions(imSensitiveWordMapper); | |
| 104 | + } | |
| 105 | + | |
| 106 | + @Test | |
| 107 | + public void testValidateText_tenantIsolation() { | |
| 108 | + // mock 不同租户的词库 | |
| 109 | + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) | |
| 110 | + .thenAnswer(invocation -> List.of(ImSensitiveWordDO.builder() | |
| 111 | + .word(TenantContextHolder.getRequiredTenantId().equals(TENANT_ID) ? "firstbad" : "secondbad") | |
| 112 | + .build())); | |
| 113 | + | |
| 114 | + // 调用,并断言当前租户的词库 | |
| 115 | + assertServiceException(() -> sensitiveWordService.validateText("firstbad"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 116 | + assertDoesNotThrow(() -> sensitiveWordService.validateText("secondbad")); | |
| 117 | + // 调用,并断言切换租户后的词库 | |
| 118 | + TenantContextHolder.setTenantId(2L); | |
| 119 | + assertDoesNotThrow(() -> sensitiveWordService.validateText("firstbad")); | |
| 120 | + assertServiceException(() -> sensitiveWordService.validateText("secondbad"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 121 | + // 调用,并断言切回租户后复用缓存 | |
| 122 | + TenantContextHolder.setTenantId(TENANT_ID); | |
| 123 | + assertServiceException(() -> sensitiveWordService.validateText("firstbad"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 124 | + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); | |
| 125 | + } | |
| 126 | + | |
| 127 | + @Test | |
| 128 | + public void testDeleteSensitiveWordList_tenantDisabled() { | |
| 129 | + // 准备上下文 | |
| 130 | + ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false); | |
| 131 | + TenantContextHolder.clear(); | |
| 132 | + // 准备缓存 | |
| 133 | + sensitiveWordService.validateText("hello world"); | |
| 134 | + | |
| 135 | + // 调用,并断言携带租户编号时仍复用缓存 | |
| 136 | + TenantContextHolder.setTenantId(2L); | |
| 137 | + sensitiveWordService.validateText("hello world"); | |
| 138 | + verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); | |
| 139 | + // mock 删除后的词库 | |
| 140 | + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of()); | |
| 141 | + | |
| 142 | + // 调用 | |
| 143 | + sensitiveWordService.deleteSensitiveWordList(ListUtil.of(1L, 2L)); | |
| 144 | + | |
| 145 | + // 断言缓存已刷新,上下文不变 | |
| 146 | + assertDoesNotThrow(() -> sensitiveWordService.validateText("badword")); | |
| 147 | + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); | |
| 148 | + assertEquals(2L, TenantContextHolder.getTenantId()); | |
| 149 | + } | |
| 150 | + | |
| 151 | + @Test | |
| 152 | + @SuppressWarnings("unchecked") | |
| 153 | + public void testValidateText_tenantDisabledRefresh() { | |
| 154 | + // 准备上下文 | |
| 155 | + ReflectionTestUtils.setField(sensitiveWordService, "tenantEnable", false); | |
| 156 | + TenantContextHolder.clear(); | |
| 157 | + // mock 初始更新时间 | |
| 158 | + when(imSensitiveWordMapper.selectMaxUpdateTime(0L)).thenReturn(LocalDateTime.of(2026, 9, 1, 0, 0)); | |
| 159 | + // 准备缓存 | |
| 160 | + sensitiveWordService.validateText("hello world"); | |
| 161 | + // mock 词库更新,并断言刷新线程无租户编号 | |
| 162 | + when(imSensitiveWordMapper.selectMaxUpdateTime(0L)).thenAnswer(invocation -> { | |
| 163 | + assertNull(TenantContextHolder.getTenantId()); | |
| 164 | + return LocalDateTime.of(2026, 9, 2, 0, 0); | |
| 165 | + }); | |
| 166 | + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) | |
| 167 | + .thenReturn(List.of(ImSensitiveWordDO.builder().word("newbad").build())); | |
| 168 | + | |
| 169 | + // 调用 | |
| 170 | + LoadingCache<Long, ?> cache = (LoadingCache<Long, ?>) ReflectionTestUtils.getField( | |
| 171 | + sensitiveWordService, "sensitiveWordBsCaches"); | |
| 172 | + cache.refresh(0L); | |
| 173 | + | |
| 174 | + // 断言缓存异步刷新 | |
| 175 | + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> | |
| 176 | + assertServiceException(() -> sensitiveWordService.validateText("newbad"), MESSAGE_SENSITIVE_WORD_BLOCKED)); | |
| 177 | + assertDoesNotThrow(() -> sensitiveWordService.validateText("badword")); | |
| 178 | + } | |
| 179 | + | |
| 180 | + @Test | |
| 80 | 181 | public void testValidateText_hitEnglish() { |
| 81 | - ServiceException exception = assertThrows(ServiceException.class, | |
| 82 | - () -> sensitiveWordService.validateText("this contains badword here")); | |
| 83 | - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); | |
| 182 | + // 调用,并断言异常 | |
| 183 | + assertServiceException(() -> sensitiveWordService.validateText("this contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 84 | 184 | } |
| 85 | 185 | |
| 86 | 186 | @Test |
| 87 | 187 | public void testValidateText_hitChinese() { |
| 88 | - ServiceException exception = assertThrows(ServiceException.class, | |
| 89 | - () -> sensitiveWordService.validateText("这条消息里有违禁词哦")); | |
| 90 | - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); | |
| 188 | + // 调用,并断言异常 | |
| 189 | + assertServiceException(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 91 | 190 | } |
| 92 | 191 | |
| 93 | 192 | @Test |
| 94 | 193 | public void testValidateText_lazyLoadsCacheOnFirstCall() { |
| 95 | - // 调用:首次 validateText 应触发 cache load | |
| 194 | + // 调用 | |
| 96 | 195 | sensitiveWordService.validateText("hello world"); |
| 97 | 196 | |
| 98 | - // 断言:mapper 各调用 1 次(loadFresh 里先取 maxUpdateTime 再读词库) | |
| 197 | + // 断言 | |
| 99 | 198 | verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID); |
| 100 | 199 | verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 101 | 200 | } |
| 102 | 201 | |
| 103 | 202 | @Test |
| 104 | 203 | public void testValidateText_reusesCachedBsAcrossCalls() { |
| 105 | - // 调用:连续两次 validateText | |
| 204 | + // 调用 | |
| 106 | 205 | sensitiveWordService.validateText("hello world"); |
| 107 | 206 | sensitiveWordService.validateText("another text"); |
| 108 | 207 | |
| 109 | - // 断言:第二次复用 cache,mapper 仍只被调用 1 次 | |
| 208 | + // 断言复用缓存 | |
| 110 | 209 | verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID); |
| 111 | 210 | verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 112 | 211 | } |
| 113 | 212 | |
| 114 | 213 | @Test |
| 115 | 214 | public void testCreateSensitiveWord_invalidatesCacheAndReloadsOnNextValidate() { |
| 116 | - // 准备:首次 validateText 触发 cache load(旧词库) | |
| 215 | + // 准备缓存 | |
| 117 | 216 | sensitiveWordService.validateText("hello world"); |
| 118 | 217 | verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 119 | 218 | |
| 120 | - // 准备:mapper 返回新词库(额外多一个 newbad),并让 createSensitiveWord 走通 | |
| 219 | + // mock 新增后的词库 | |
| 121 | 220 | when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) |
| 122 | 221 | .thenReturn(ListUtil.of( |
| 123 | 222 | ImSensitiveWordDO.builder().id(1L).word("badword") |
| ... | ... | @@ -129,149 +228,145 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { |
| 129 | 228 | )); |
| 130 | 229 | when(imSensitiveWordMapper.selectByWord("newbad")).thenReturn(null); |
| 131 | 230 | |
| 132 | - // 调用:新增敏感词,触发 invalidate | |
| 231 | + // 准备参数 | |
| 133 | 232 | ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO(); |
| 134 | 233 | reqVO.setWord("newbad"); |
| 135 | 234 | reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 235 | + | |
| 236 | + // 调用 | |
| 136 | 237 | sensitiveWordService.createSensitiveWord(reqVO); |
| 137 | 238 | |
| 138 | - // 调用:再次 validateText,应触发重新 load | |
| 139 | - ServiceException exception = assertThrows(ServiceException.class, | |
| 140 | - () -> sensitiveWordService.validateText("contains newbad here")); | |
| 141 | - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); | |
| 142 | - // 旧词依然命中 | |
| 143 | - assertThrows(ServiceException.class, | |
| 144 | - () -> sensitiveWordService.validateText("contains badword here")); | |
| 239 | + // 断言缓存已刷新 | |
| 240 | + assertServiceException(() -> sensitiveWordService.validateText("contains newbad here"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 241 | + // 断言原有敏感词仍然生效 | |
| 242 | + assertServiceException(() -> sensitiveWordService.validateText("contains badword here"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 145 | 243 | |
| 146 | - // 断言:selectListByStatus 共被调用 2 次(首次 load + invalidate 后 reload) | |
| 244 | + // 断言词库重新加载 | |
| 147 | 245 | verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 148 | 246 | } |
| 149 | 247 | |
| 150 | 248 | @Test |
| 151 | 249 | public void testCreateSensitiveWord_duplicateWord_throws() { |
| 152 | - // 准备:mock 已存在同名敏感词 | |
| 250 | + // mock 同名敏感词 | |
| 153 | 251 | when(imSensitiveWordMapper.selectByWord("dup")).thenReturn( |
| 154 | 252 | ImSensitiveWordDO.builder().id(99L).word("dup") |
| 155 | 253 | .status(CommonStatusEnum.ENABLE.getStatus()).build()); |
| 156 | 254 | |
| 157 | - // 调用 + 断言:重复敏感词抛 SENSITIVE_WORD_DUPLICATED | |
| 255 | + // 准备参数 | |
| 158 | 256 | ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO(); |
| 159 | 257 | reqVO.setWord("dup"); |
| 160 | 258 | reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 161 | - ServiceException exception = assertThrows(ServiceException.class, | |
| 162 | - () -> sensitiveWordService.createSensitiveWord(reqVO)); | |
| 163 | - assertEquals(SENSITIVE_WORD_DUPLICATED.getCode(), exception.getCode()); | |
| 164 | 259 | |
| 165 | - // 断言:未走到 insert | |
| 260 | + // 调用,并断言异常 | |
| 261 | + assertServiceException(() -> sensitiveWordService.createSensitiveWord(reqVO), SENSITIVE_WORD_DUPLICATED, reqVO.getWord()); | |
| 262 | + | |
| 263 | + // 断言 | |
| 166 | 264 | verify(imSensitiveWordMapper, never()).insert(any(ImSensitiveWordDO.class)); |
| 167 | 265 | } |
| 168 | 266 | |
| 169 | 267 | @Test |
| 170 | 268 | public void testUpdateSensitiveWord_invalidatesCache() { |
| 171 | - // 准备:首次 validateText 触发 cache load | |
| 269 | + // 准备缓存 | |
| 172 | 270 | sensitiveWordService.validateText("hello world"); |
| 173 | 271 | verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 174 | 272 | |
| 175 | - // 准备:让 update 校验通过 | |
| 273 | + // mock 原敏感词 | |
| 176 | 274 | when(imSensitiveWordMapper.selectById(1L)).thenReturn( |
| 177 | 275 | ImSensitiveWordDO.builder().id(1L).word("badword") |
| 178 | 276 | .status(CommonStatusEnum.ENABLE.getStatus()).build()); |
| 179 | 277 | when(imSensitiveWordMapper.selectByWord("updatedbad")).thenReturn(null); |
| 180 | - // 准备:reload 时返回新词库 | |
| 278 | + // mock 修改后的词库 | |
| 181 | 279 | when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) |
| 182 | 280 | .thenReturn(ListUtil.of( |
| 183 | 281 | ImSensitiveWordDO.builder().id(1L).word("updatedbad") |
| 184 | 282 | .status(CommonStatusEnum.ENABLE.getStatus()).build() |
| 185 | 283 | )); |
| 186 | 284 | |
| 187 | - // 调用:更新敏感词,触发 invalidate | |
| 285 | + // 准备参数 | |
| 188 | 286 | ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO(); |
| 189 | 287 | reqVO.setId(1L); |
| 190 | 288 | reqVO.setWord("updatedbad"); |
| 191 | 289 | reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 290 | + | |
| 291 | + // 调用 | |
| 192 | 292 | sensitiveWordService.updateSensitiveWord(reqVO); |
| 193 | 293 | |
| 194 | - // 调用 + 断言:再次 validateText 应使用新词库 | |
| 195 | - ServiceException exception = assertThrows(ServiceException.class, | |
| 196 | - () -> sensitiveWordService.validateText("contains updatedbad here")); | |
| 197 | - assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); | |
| 294 | + // 断言缓存已刷新 | |
| 295 | + assertServiceException(() -> sensitiveWordService.validateText("contains updatedbad here"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 198 | 296 | |
| 199 | - // 断言:selectListByStatus 共被调用 2 次 | |
| 297 | + // 断言词库重新加载 | |
| 200 | 298 | verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 201 | 299 | } |
| 202 | 300 | |
| 203 | 301 | @Test |
| 204 | 302 | public void testDeleteSensitiveWord_invalidatesCache() { |
| 205 | - // 准备:首次 validateText 触发 cache load | |
| 303 | + // 准备缓存 | |
| 206 | 304 | sensitiveWordService.validateText("hello world"); |
| 207 | 305 | verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 208 | 306 | |
| 209 | - // 准备:让 delete 校验通过 | |
| 307 | + // mock 原敏感词 | |
| 210 | 308 | when(imSensitiveWordMapper.selectById(1L)).thenReturn( |
| 211 | 309 | ImSensitiveWordDO.builder().id(1L).word("badword") |
| 212 | 310 | .status(CommonStatusEnum.ENABLE.getStatus()).build()); |
| 213 | - // 准备:reload 时返回剩余词库(只剩中文那条) | |
| 311 | + // mock 删除后的词库 | |
| 214 | 312 | when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) |
| 215 | 313 | .thenReturn(ListUtil.of( |
| 216 | 314 | ImSensitiveWordDO.builder().id(2L).word("违禁词") |
| 217 | 315 | .status(CommonStatusEnum.ENABLE.getStatus()).build() |
| 218 | 316 | )); |
| 219 | 317 | |
| 220 | - // 调用:删除 badword,触发 invalidate | |
| 318 | + // 调用 | |
| 221 | 319 | sensitiveWordService.deleteSensitiveWord(1L); |
| 222 | 320 | |
| 223 | - // 调用 + 断言:badword 已被删,不再命中 | |
| 321 | + // 断言缓存已刷新 | |
| 224 | 322 | assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here")); |
| 225 | - // 中文词依然在 | |
| 226 | - assertThrows(ServiceException.class, | |
| 227 | - () -> sensitiveWordService.validateText("这条消息里有违禁词哦")); | |
| 323 | + // 断言未删除的敏感词仍然生效 | |
| 324 | + assertServiceException(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"), MESSAGE_SENSITIVE_WORD_BLOCKED); | |
| 228 | 325 | |
| 229 | - // 断言:selectListByStatus 共被调用 2 次 | |
| 326 | + // 断言词库重新加载 | |
| 230 | 327 | verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 231 | 328 | } |
| 232 | 329 | |
| 233 | 330 | @Test |
| 234 | 331 | public void testDeleteSensitiveWord_notExists_throws() { |
| 235 | - // 准备:selectById 返回 null | |
| 332 | + // mock 敏感词不存在 | |
| 236 | 333 | when(imSensitiveWordMapper.selectById(999L)).thenReturn(null); |
| 237 | 334 | |
| 238 | - // 调用 + 断言:抛 SENSITIVE_WORD_NOT_EXISTS | |
| 239 | - ServiceException exception = assertThrows(ServiceException.class, | |
| 240 | - () -> sensitiveWordService.deleteSensitiveWord(999L)); | |
| 241 | - assertEquals(SENSITIVE_WORD_NOT_EXISTS.getCode(), exception.getCode()); | |
| 335 | + // 调用,并断言异常 | |
| 336 | + assertServiceException(() -> sensitiveWordService.deleteSensitiveWord(999L), SENSITIVE_WORD_NOT_EXISTS); | |
| 242 | 337 | |
| 243 | - // 断言:未走到 deleteById | |
| 338 | + // 断言 | |
| 244 | 339 | verify(imSensitiveWordMapper, never()).deleteById(anyLong()); |
| 245 | 340 | } |
| 246 | 341 | |
| 247 | 342 | @Test |
| 248 | 343 | public void testDeleteSensitiveWordList_invalidatesCache() { |
| 249 | - // 准备:首次 validateText 触发 cache load | |
| 344 | + // 准备缓存 | |
| 250 | 345 | sensitiveWordService.validateText("hello world"); |
| 251 | 346 | verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 252 | 347 | |
| 253 | - // 准备:reload 时返回空词库 | |
| 348 | + // mock 删除后的词库 | |
| 254 | 349 | when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) |
| 255 | 350 | .thenReturn(ListUtil.of()); |
| 256 | 351 | |
| 257 | - // 调用:批量删除,触发 invalidate | |
| 352 | + // 调用 | |
| 258 | 353 | sensitiveWordService.deleteSensitiveWordList(ListUtil.of(1L, 2L)); |
| 259 | 354 | |
| 260 | - // 调用 + 断言:所有词都不再命中 | |
| 355 | + // 断言缓存已刷新 | |
| 261 | 356 | assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here")); |
| 262 | 357 | assertDoesNotThrow(() -> sensitiveWordService.validateText("这条消息里有违禁词哦")); |
| 263 | 358 | |
| 264 | - // 断言:deleteByIds 被调用 1 次;selectListByStatus 共 2 次 | |
| 359 | + // 断言 | |
| 265 | 360 | verify(imSensitiveWordMapper, times(1)).deleteByIds(ListUtil.of(1L, 2L)); |
| 266 | 361 | verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); |
| 267 | 362 | } |
| 268 | 363 | |
| 269 | 364 | @Test |
| 270 | 365 | public void testDeleteSensitiveWordList_emptyIds_skip() { |
| 271 | - // 调用:空列表直接返回 | |
| 366 | + // 调用 | |
| 272 | 367 | sensitiveWordService.deleteSensitiveWordList(ListUtil.of()); |
| 273 | 368 | |
| 274 | - // 断言:mapper 不被调用 | |
| 369 | + // 断言 | |
| 275 | 370 | verify(imSensitiveWordMapper, never()).deleteByIds(anyList()); |
| 276 | 371 | } |
| 277 | 372 | ... | ... |