2026-06-30 24681c81c09022f584a57006f2534b5f74723414
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
package cn.iocoder.yudao.module.im.service.sensitiveword;
 
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.cache.CacheUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordPageReqVO;
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.github.houbb.sensitive.word.bs.SensitiveWordBs;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import jakarta.annotation.Resource;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
 
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
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;
 
/**
 * IM 敏感词 Service 实现类
 * <p>
 * 词库匹配交给 houbb sensitive-word 库(trie 树 + 全/半角 / 大小写 / 繁简体 / 数字风格规范化)
 *
 * @author 芋道源码
 */
@Service
@Validated
@Slf4j
public class ImSensitiveWordServiceImpl implements ImSensitiveWordService {
 
    @Resource
    private ImSensitiveWordMapper sensitiveWordMapper;
 
    /**
     * 缓存条目
     */
    @Data
    @AllArgsConstructor
    private static class SensitiveWordBsCache {
 
        /**
         * 敏感词检测器
         */
        private SensitiveWordBs bs;
        /**
         * 构建本实例时数据库里的 max(update_time),作为下次刷新比对的基线
         */
        private LocalDateTime maxUpdateTime;
 
    }
 
    /**
     * 租户 → SensitiveWordBs 实例的本地缓存
     * <p>
     * 每分钟触发一次异步 reload,先读 max(update_time),没变就复用旧实例(避免 trie 重建),变了才重新读词库 + 重建。
     * 单实例 CRUD 后另外通过 {@link #invalidateSensitiveWordBsCaches()} 立即让本机失效,多实例靠定时刷新最长 1 分钟内收敛。
     */
    @SuppressWarnings({"Convert2Diamond", "NullableProblems"})
    private final LoadingCache<Long, SensitiveWordBsCache> sensitiveWordBsCaches = CacheUtils.buildAsyncReloadingCache(
            Duration.ofMinutes(1L), // 1 分钟过期
            new CacheLoader<Long, SensitiveWordBsCache>() {
 
                @Override
                public SensitiveWordBsCache load(Long tenantId) {
                    return loadFresh(tenantId);
                }
 
                @Override
                public ListenableFuture<SensitiveWordBsCache> reload(Long tenantId, SensitiveWordBsCache oldValue) {
                    // 异步刷新线程独立于业务线程,没有租户上下文;必须显式 TenantUtils.execute 设置,否则租户拦截器会按当前线程的空上下文拼 SQL
                    return Futures.immediateFuture(TenantUtils.execute(tenantId, () -> {
                        LocalDateTime currentMax = sensitiveWordMapper.selectMaxUpdateTime(tenantId);
                        // 没变 → 复用旧实例,避免无谓地重建 trie
                        if (Objects.equals(oldValue.getMaxUpdateTime(), currentMax)) {
                            return oldValue;
                        }
                        // 变了 → 重新读词库并重建 trie
                        return loadFresh(tenantId);
                    }));
                }
 
            });
 
    private SensitiveWordBsCache loadFresh(Long tenantId) {
        return TenantUtils.execute(tenantId, () -> {
            // 先取基线时间再读词库:反过来在两次查询之间出现的新插入会被漏感知
            LocalDateTime maxUpdateTime = sensitiveWordMapper.selectMaxUpdateTime(tenantId);
            List<ImSensitiveWordDO> words = sensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
            // 构建敏感词检测器
            SensitiveWordBs bs = SensitiveWordBs.newInstance()
                    .wordDeny(() -> convertList(words, ImSensitiveWordDO::getWord))
                    .ignoreCase(true)
                    .ignoreWidth(true)         // 忽略全/半角
                    .ignoreNumStyle(true)      // 忽略数字风格(中文/阿拉伯)
                    .ignoreChineseStyle(true)  // 忽略繁简体
                    .enableWordCheck(true)
                    .init();
            return new SensitiveWordBsCache(bs, maxUpdateTime);
        });
    }
 
    /**
     * 强制让敏感词缓存失效,下次访问按最新 DB 重建
     * <p>
     * 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。
     */
    private void invalidateSensitiveWordBsCaches() {
        Long tenantId = TenantContextHolder.getTenantId();
        if (tenantId != null) {
            sensitiveWordBsCaches.invalidate(tenantId);
            return;
        }
        sensitiveWordBsCaches.invalidateAll();
    }
 
    @Override
    public void validateText(String text) {
        if (StrUtil.isBlank(text)) {
            return;
        }
        SensitiveWordBs bs = sensitiveWordBsCaches.getUnchecked(TenantContextHolder.getRequiredTenantId()).getBs();
        if (bs.contains(text)) {
            throw exception(MESSAGE_SENSITIVE_WORD_BLOCKED);
        }
    }
 
    // ==================== 管理后台 ====================
 
    @Override
    public PageResult<ImSensitiveWordDO> getSensitiveWordPage(ImSensitiveWordPageReqVO reqVO) {
        return sensitiveWordMapper.selectPage(reqVO);
    }
 
    @Override
    public ImSensitiveWordDO getSensitiveWord(Long id) {
        return sensitiveWordMapper.selectById(id);
    }
 
    @Override
    public Long createSensitiveWord(ImSensitiveWordSaveReqVO reqVO) {
        // 1. 校验唯一
        validateWordUnique(null, reqVO.getWord());
 
        // 2.1 入库
        ImSensitiveWordDO word = BeanUtils.toBean(reqVO, ImSensitiveWordDO.class);
        sensitiveWordMapper.insert(word);
        // 2.2 强制失效本机缓存(多实例靠定时刷新收敛)
        invalidateSensitiveWordBsCaches();
        return word.getId();
    }
 
    @Override
    public void updateSensitiveWord(ImSensitiveWordSaveReqVO reqVO) {
        // 1.1 校验存在
        validateSensitiveWordExists(reqVO.getId());
        // 1.2 校验唯一(排除自身)
        validateWordUnique(reqVO.getId(), reqVO.getWord());
 
        // 2.1 更新
        ImSensitiveWordDO updateObj = BeanUtils.toBean(reqVO, ImSensitiveWordDO.class);
        sensitiveWordMapper.updateById(updateObj);
        // 2.2 强制失效本机缓存
        invalidateSensitiveWordBsCaches();
    }
 
    @Override
    public void deleteSensitiveWord(Long id) {
        // 1. 校验存在
        validateSensitiveWordExists(id);
 
        // 2.1 删除
        sensitiveWordMapper.deleteById(id);
        // 2.2 强制失效本机缓存
        invalidateSensitiveWordBsCaches();
    }
 
 
    @Override
    public void deleteSensitiveWordList(List<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return;
        }
        // 1. 删除
        sensitiveWordMapper.deleteByIds(ids);
        // 2. 强制失效本机缓存
        invalidateSensitiveWordBsCaches();
    }
 
    private void validateSensitiveWordExists(Long id) {
        if (sensitiveWordMapper.selectById(id) == null) {
            throw exception(SENSITIVE_WORD_NOT_EXISTS);
        }
    }
 
    /**
     * 校验敏感词唯一(修改时排除自身)
     */
    private void validateWordUnique(Long id, String word) {
        ImSensitiveWordDO exist = sensitiveWordMapper.selectByWord(word);
        if (exist == null) {
            return;
        }
        if (id == null || ObjUtil.notEqual(exist.getId(), id)) {
            throw exception(SENSITIVE_WORD_DUPLICATED, word);
        }
    }
 
}