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 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  
... ...