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 实现类 *

* 词库匹配交给 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 实例的本地缓存 *

* 每分钟触发一次异步 reload,先读 max(update_time),没变就复用旧实例(避免 trie 重建),变了才重新读词库 + 重建。 * 单实例 CRUD 后另外通过 {@link #invalidateSensitiveWordBsCaches()} 立即让本机失效,多实例靠定时刷新最长 1 分钟内收敛。 */ @SuppressWarnings({"Convert2Diamond", "NullableProblems"}) private final LoadingCache sensitiveWordBsCaches = CacheUtils.buildAsyncReloadingCache( Duration.ofMinutes(1L), // 1 分钟过期 new CacheLoader() { @Override public SensitiveWordBsCache load(Long tenantId) { return loadFresh(tenantId); } @Override public ListenableFuture 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 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 重建 *

* 有租户上下文:仅失效该租户。无租户上下文(如系统级 / 跨租户清理):兜底失效所有租户。 */ 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 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 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); } } }