Commit 459986bfca82fe006c7d111f43bcfaa6c5891d4e

Authored by YunaiV
1 parent d1937e43

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

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