Sfoglia il codice sorgente

通话回调敏词校验

yjwang 17 ore fa
parent
commit
f8f8c08ecb
22 ha cambiato i file con 1043 aggiunte e 18 eliminazioni
  1. 40 0
      docs/company_ai_sensitive_word.sql
  2. 2 2
      fs-cid-workflow/src/main/resources/application.yml
  3. 30 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java
  4. 104 0
      fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java
  5. 7 0
      fs-service/pom.xml
  6. 2 0
      fs-service/src/main/java/com/fs/aiSipCall/param/ApiCallRecordByUuidQueryParams.java
  7. 13 0
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  8. 9 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  9. 17 3
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  10. 0 12
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  11. 5 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java
  12. 13 0
      fs-service/src/main/java/com/fs/sensitive/DTO/AgentSensitiveWordDetectResultDTO.java
  13. 133 0
      fs-service/src/main/java/com/fs/sensitive/component/AgentSensitiveWordDetector.java
  14. 47 0
      fs-service/src/main/java/com/fs/sensitive/domain/CompanyAiSensitiveWord.java
  15. 255 0
      fs-service/src/main/java/com/fs/sensitive/manager/SensitiveWordAcManager.java
  16. 48 0
      fs-service/src/main/java/com/fs/sensitive/manager/SensitiveWordHit.java
  17. 57 0
      fs-service/src/main/java/com/fs/sensitive/mapper/CompanyAiSensitiveWordMapper.java
  18. 53 0
      fs-service/src/main/java/com/fs/sensitive/service/ICompanyAiSensitiveWordService.java
  19. 78 0
      fs-service/src/main/java/com/fs/sensitive/service/impl/CompanyAiSensitiveWordServiceImpl.java
  20. 22 0
      fs-service/src/main/resources/db/tenant-initTable.sql
  21. 14 1
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml
  22. 94 0
      fs-service/src/main/resources/mapper/sensitive/CompanyAiSensitiveWordMapper.xml

+ 40 - 0
docs/company_ai_sensitive_word.sql

@@ -0,0 +1,40 @@
+/*
+ Navicat Premium Data Transfer
+
+ Source Server         : hisScrm
+ Source Server Type    : MySQL
+ Source Server Version : 80025
+ Source Host           : 139.186.77.83:3306
+ Source Schema         : ylrz_his_scrm
+
+ Target Server Type    : MySQL
+ Target Server Version : 80025
+ File Encoding         : 65001
+
+ Date: 14/05/2026 16:48:25
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for company_ai_sensitive_word
+-- ----------------------------
+DROP TABLE IF EXISTS `company_ai_sensitive_word`;
+CREATE TABLE `company_ai_sensitive_word`  (
+                                              `word_id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+                                              `word` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '敏感词',
+                                              `enabled` tinyint NULL DEFAULT 1 COMMENT '是否启用 0/1',
+                                              `source` tinyint NULL DEFAULT 1 COMMENT '来源 1=手工 2=批量 3=内置',
+                                              `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
+                                              `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                              `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP,
+                                              `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                              `update_time` datetime NULL DEFAULT NULL,
+                                              `del_flag` tinyint NULL DEFAULT 0,
+                                              PRIMARY KEY (`word_id`) USING BTREE,
+                                              UNIQUE INDEX `uk_word`(`word` ASC, `del_flag` ASC) USING BTREE,
+                                              INDEX `idx_enabled`(`enabled` ASC) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '敏感词库' ROW_FORMAT = DYNAMIC;
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 2 - 2
fs-cid-workflow/src/main/resources/application.yml

@@ -14,7 +14,7 @@ spring:
 #    active: druid-sxjz
 #    active: druid-hdt
 #    active: druid-myhk-test
-cid-group-no: 3
-tenant-id: 11
+cid-group-no: 4
+tenant-id: 13
 # 配置了服务标记后,请在tenant_service_config 中配置服务应用租户ids信息
 tenant-service-marker: cidWorkflow00

+ 30 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java

@@ -9,6 +9,7 @@ import com.fs.aiSipCall.utils.DateUtils;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.model.LoginUser;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.exception.ServiceException;
@@ -21,6 +22,8 @@ import com.fs.framework.datasource.TenantDataSourceManager;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.Date;
@@ -174,6 +177,8 @@ public class AiSipCallOutboundCdrController extends BaseController
         if (req == null || org.apache.commons.lang3.StringUtils.isBlank(req.getUuid())) {
             throw new ServiceException("uuid不能为空");
         }
+        //获取租户id
+        req.setTenantId(getTenantId());
         EasyCallOutBoundVO callPhoneRes = easyCallMapper.getOutBoundInfoByUuid(req.getUuid());
         if (ObjectUtil.isEmpty(callPhoneRes)) {
             return AjaxResult.error("未同步到对应通话记录");
@@ -196,4 +201,29 @@ public class AiSipCallOutboundCdrController extends BaseController
         return AjaxResult.error("未查到对应通话记录或同步失败");
     }
 
+    public static Long getTenantId() {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        if (auth == null || auth.getPrincipal() == null) {
+            return null;
+        }
+        Object principal = auth.getPrincipal();
+
+        // 兼容 common 的 LoginUser
+        if (principal instanceof LoginUser) {
+            return ((LoginUser) principal).getTenantId();
+        }
+
+        // 兼容 fs-company 等使用 framework.security.LoginUser 的场景
+        try {
+            java.lang.reflect.Method m = principal.getClass().getMethod("getTenantId");
+            Object v = m.invoke(principal);
+            if (v instanceof Long) {
+                return (Long) v;
+            }
+        } catch (Exception ignored) {
+        }
+        return null;
+    }
+
+
 }

+ 104 - 0
fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java

@@ -0,0 +1,104 @@
+package com.fs.company.controller.sensitive;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 敏感词库Controller
+ *
+ * @author fs
+ */
+@RestController
+@RequestMapping("/sensitive/word")
+public class CompanyAiSensitiveWordController extends BaseController {
+
+    @Autowired
+    private ICompanyAiSensitiveWordService companyAiSensitiveWordService;
+
+    /**
+     * 查询敏感词列表
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        startPage();
+        List<CompanyAiSensitiveWord> list = companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出敏感词列表
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:export')")
+    @Log(title = "敏感词库", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        List<CompanyAiSensitiveWord> list = companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        ExcelUtil<CompanyAiSensitiveWord> util = new ExcelUtil<>(CompanyAiSensitiveWord.class);
+        return util.exportExcel(list, "敏感词数据");
+    }
+
+    /**
+     * 获取敏感词详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:query')")
+    @GetMapping(value = "/{wordId}")
+    public AjaxResult getInfo(@PathVariable("wordId") Long wordId) {
+        return AjaxResult.success(companyAiSensitiveWordService.selectCompanyAiSensitiveWordByWordId(wordId));
+    }
+
+    /**
+     * 新增敏感词
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:add')")
+    @Log(title = "敏感词库", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        int ret = companyAiSensitiveWordService.insertCompanyAiSensitiveWord(companyAiSensitiveWord);
+        if (ret == -1) {
+            return AjaxResult.error("该敏感词已存在");
+        }
+        return toAjax(ret);
+    }
+
+    /**
+     * 修改敏感词
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.updateCompanyAiSensitiveWord(companyAiSensitiveWord));
+    }
+
+    /**
+     * 启用/禁用
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping("/changeEnabled")
+    public AjaxResult changeEnabled(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.changeEnabled(companyAiSensitiveWord.getWordId(), companyAiSensitiveWord.getEnabled()));
+    }
+
+    /**
+     * 删除敏感词
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:remove')")
+    @Log(title = "敏感词库", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{wordIds}")
+    public AjaxResult remove(@PathVariable Long[] wordIds) {
+        return toAjax(companyAiSensitiveWordService.deleteCompanyAiSensitiveWordByWordIds(wordIds));
+    }
+}

+ 7 - 0
fs-service/pom.xml

@@ -308,6 +308,13 @@
             <artifactId>druid-spring-boot-starter</artifactId>
         </dependency>
 
+        <!-- 敏感词匹配:AhoCorasick 双数组 Trie(DAT-AC),Apache-2.0 协议 -->
+        <dependency>
+            <groupId>com.hankcs</groupId>
+            <artifactId>aho-corasick-double-array-trie</artifactId>
+            <version>1.2.3</version>
+        </dependency>
+
     </dependencies>
 
 </project>

+ 2 - 0
fs-service/src/main/java/com/fs/aiSipCall/param/ApiCallRecordByUuidQueryParams.java

@@ -31,5 +31,7 @@ public class ApiCallRecordByUuidQueryParams implements Serializable {
 
     private Long customerId;
 
+    private Long tenantId;
+
 
 }

+ 13 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java

@@ -25,6 +25,8 @@ import com.fs.company.mapper.*;
 import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.vo.CidConfigVO;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
+import com.fs.sensitive.DTO.AgentSensitiveWordDetectResultDTO;
+import com.fs.sensitive.component.AgentSensitiveWordDetector;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
@@ -75,6 +77,9 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
     @Autowired
     private CrmCustomerCallLogMapper crmCustomerCallLogMapper;
 
+    @Autowired
+    private AgentSensitiveWordDetector agentSensitiveWordDetector;
+
 
     private static final BigDecimal DEFAULT_CALL_CHARGE = new BigDecimal("0.12");
     private static final BigDecimal ONE_MINUTES_SECOND = new BigDecimal("60");
@@ -529,6 +534,14 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         companyVoiceRoboticCallLogCallphone.setCallTime(Long.valueOf(callPhoneRes.getTimeLen()));
         companyVoiceRoboticCallLogCallphone.setCallType(Integer.valueOf(callType));
 
+        //检测敏感词语
+        AgentSensitiveWordDetectResultDTO resultDTO = agentSensitiveWordDetector.detectAndHighlight(req.getUuid(), req.getTenantId(), callPhoneRes.getChatContent());
+        if(resultDTO != null && resultDTO.getCheckBoolean()){
+            companyVoiceRoboticCallLogCallphone.setIsWarning(1);
+            companyVoiceRoboticCallLogCallphone.setContentList(resultDTO.getProcessText());
+            companyVoiceRoboticCallLogCallphone.setViolationNum(resultDTO.getViolationCount());
+        }
+
         String json = configService.selectConfigByKey("cId.config");
         CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
         BigDecimal callCharge = cidConfigVO.getCallCharge();

+ 9 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java

@@ -105,6 +105,15 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @Excel(name = "外呼类型")
     private Integer callType;
 
+    /** 是否警告(0否 1是)用于敏感词 */
+    @Excel(name = "是否警告(0否 1是)用于敏感词")
+    private Integer isWarning;
+
+    /**
+     * 违规次数
+     * **/
+    private Integer violationNum;
+
     @TableField(exist = false)
     private String companyName;
 

+ 17 - 3
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -5,10 +5,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.fs.aicall.domain.TaskInfo;
-import com.fs.aicall.domain.apiresult.Notify;
 import com.fs.aicall.domain.apiresult.PushIIntentionResult;
-import com.fs.aicall.domain.param.getDialogMapDomain;
 import com.fs.aicall.service.AiCallService;
 import com.fs.common.config.RedisTenantContext;
 import com.fs.common.core.domain.entity.SysDictData;
@@ -34,6 +31,9 @@ import com.fs.core.config.TenantConfigContext;
 import com.fs.crm.service.ICrmCustomerPropertyService;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
+import com.fs.sensitive.DTO.AgentSensitiveWordDetectResultDTO;
+import com.fs.sensitive.component.AgentSensitiveWordDetector;
+import com.fs.sensitive.manager.SensitiveWordAcManager;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.impl.SysDictTypeServiceImpl;
 import com.fs.wxcid.utils.TenantHelper;
@@ -96,6 +96,10 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     ICompanyVoiceRoboticService companyVoiceRoboticService;
     @Autowired
     CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
+
+    @Autowired
+    private AgentSensitiveWordDetector agentSensitiveWordDetector;
+
     /**
      * 查询调用日志_ai打电话
      *
@@ -321,6 +325,7 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 //                getDialogMapDomain getDialogMap = getDialogMapDomain.builder()
 //                        .uuid(uuid)
 //                        .build();
+
                     CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLog = companyVoiceRoboticCallLogCallphoneMapper.selectNoResultLogByCallees(callees);
 
                     companyVoiceRoboticCallLog.setStatus(2);
@@ -372,6 +377,15 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                     BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLog.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
                     BigDecimal multiply = divide.multiply(callCharge);
                     companyVoiceRoboticCallLog.setCost(multiply);
+
+                    //检测敏感词语
+                    AgentSensitiveWordDetectResultDTO resultDTO = agentSensitiveWordDetector.detectAndHighlight(result.getUuid(), TenantHelper.getTenantId(), result.getDialogue());
+                    if(resultDTO != null && resultDTO.getCheckBoolean()){
+                        companyVoiceRoboticCallLog.setIsWarning(1);
+                        companyVoiceRoboticCallLog.setContentList(resultDTO.getProcessText());
+                        companyVoiceRoboticCallLog.setViolationNum(resultDTO.getViolationCount());
+                    }
+
                     baseMapper.updateCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLog);
                     // 更新用户标签
                     crmCustomerPropertyService.addPropertyByCallLog(companyVoiceRoboticCallLog);

+ 0 - 12
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -1,6 +1,5 @@
 package com.fs.company.service.impl;
 
-import cn.hutool.core.util.ObjectUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -8,8 +7,6 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.aicall.domain.apiresult.Notify;
 import com.fs.aicall.domain.apiresult.PushIIntentionResult;
-import com.fs.aicall.domain.param.CalleeDomain;
-import com.fs.aicall.domain.param.CalltaskcreateaiCustomizeDomain;
 import com.fs.aicall.domain.result.CalltaskcreateaiCustomizeResult;
 import com.fs.aicall.service.AiCallService;
 import com.fs.common.annotation.DataScope;
@@ -45,10 +42,6 @@ import com.fs.enums.TaskTypeEnum;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.service.impl.QwExternalContactServiceImpl;
-import com.fs.qw.vo.QwSopRuleTimeVO;
-import com.fs.sop.domain.QwSopTemp;
-import com.fs.system.domain.SysConfig;
-import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.mapper.SysDictDataMapper;
 import com.fs.system.service.impl.SysDictTypeServiceImpl;
 import com.fs.wxcid.utils.TenantHelper;
@@ -57,21 +50,16 @@ import com.github.pagehelper.PageInfo;
 import lombok.RequiredArgsConstructor;
 import lombok.Synchronized;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 
 import java.lang.reflect.Method;
-import java.text.SimpleDateFormat;
 import java.time.temporal.ChronoField;
 import java.util.*;
-import java.util.function.Function;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 
-import static com.fs.company.service.impl.call.node.AbstractWorkflowNode.companyVoiceRoboticCallLogCallphoneMapper;
 import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
 import static java.time.LocalTime.now;
 

+ 5 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java

@@ -79,4 +79,9 @@ public class CompanyVoiceRoboticCallLogCallPhoneVO {
     @Excel(name = "外呼类型")
     private Integer callType;
 
+    /** 是否警告(0否 1是)用于敏感词 */
+    private Integer isWarning;
+
+    /** 通话详细列表(已对敏感词标红的JSON字符串) */
+    private String contentList;
 }

+ 13 - 0
fs-service/src/main/java/com/fs/sensitive/DTO/AgentSensitiveWordDetectResultDTO.java

@@ -0,0 +1,13 @@
+package com.fs.sensitive.DTO;
+
+import lombok.Data;
+
+/**
+ * 检测结果实体类
+ * **/
+@Data
+public class AgentSensitiveWordDetectResultDTO {
+    private Boolean checkBoolean;//是否违规
+    private String processText;//处理后的文本
+    private int violationCount;//违规数量
+}

+ 133 - 0
fs-service/src/main/java/com/fs/sensitive/component/AgentSensitiveWordDetector.java

@@ -0,0 +1,133 @@
+package com.fs.sensitive.component;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.sensitive.DTO.AgentSensitiveWordDetectResultDTO;
+import com.fs.sensitive.manager.SensitiveWordAcManager;
+import com.fs.sensitive.manager.SensitiveWordHit;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Agent对话内容敏感词检测组件
+ * 只检测和标红 role="agent" 的对话内容
+ *
+ * @author fs
+ * @date 2026-01-15
+ */
+@Slf4j
+@Component
+public class AgentSensitiveWordDetector {
+    /** 敏感词 AC 自动机管理器(多租户缓存 + 匹配/标红/替换) */
+    private final SensitiveWordAcManager sensitiveWordAcManager;
+
+    /**
+     * 构造函数
+     * @param sensitiveWordAcManager 敏感词 AC 自动机管理器
+     */
+    AgentSensitiveWordDetector(SensitiveWordAcManager sensitiveWordAcManager){
+        this.sensitiveWordAcManager = sensitiveWordAcManager;
+    }
+
+
+    public AgentSensitiveWordDetectResultDTO detectAndHighlight(String uuid,Long tenantId, String dialogueJson){
+        AgentSensitiveWordDetectResultDTO result = new AgentSensitiveWordDetectResultDTO();
+        result.setCheckBoolean(false);//默认为 false
+        result.setViolationCount(0);//默认违规数量为0
+        // ==================== 【新增】对话内容敏感词检测 ====================
+        // 仅检测 role="agent" 的对话内容 + 标红 + 日志,不修改原有业务逻辑。
+        // dialogueHasSensitiveWord = true 表示当前对话命中了敏感词;
+        // dialogueWithSensitiveHighlight 为标红后的文本(<span class="sensitive-word" style="color:red;">敏感词</span>),
+        // 后续可直接写入数据库供前端渲染。
+        Long __sw_tenantId = tenantId;
+        String __sw_dialogue = dialogueJson;
+        boolean dialogueHasSensitiveWord = false;
+        String dialogueWithSensitiveHighlight = __sw_dialogue;
+        List<SensitiveWordHit> dialogueSensitiveHits = Collections.emptyList();
+        if (__sw_tenantId != null && com.fs.common.utils.StringUtils.isNotBlank(__sw_dialogue)) {
+            try {
+                // 解析对话内容为 JSON 数组
+                com.alibaba.fastjson.JSONArray dialogueArray = com.alibaba.fastjson.JSON.parseArray(__sw_dialogue);
+                if (dialogueArray != null && !dialogueArray.isEmpty()) {
+                    // 提取所有 role="agent" 的 content 内容
+                    StringBuilder agentContentBuilder = new StringBuilder();
+                    for (int i = 0; i < dialogueArray.size(); i++) {
+                        com.alibaba.fastjson.JSONObject item = dialogueArray.getJSONObject(i);
+                        if (item != null && "agent".equals(item.getString("role"))) {
+                            String content = item.getString("content");
+                            if (com.fs.common.utils.StringUtils.isNotBlank(content)) {
+                                if (agentContentBuilder.length() > 0) {
+                                    agentContentBuilder.append("\n");
+                                }
+                                agentContentBuilder.append(content);
+                            }
+                        }
+                    }
+
+                    String agentContent = agentContentBuilder.toString();
+
+                    // 只对 agent 的内容进行敏感词检测
+                    if (com.fs.common.utils.StringUtils.isNotBlank(agentContent)) {
+                        dialogueHasSensitiveWord = sensitiveWordAcManager.hasSensitiveWord(__sw_tenantId, agentContent);
+                        if (dialogueHasSensitiveWord) {
+                            // 获取命中的敏感词详情
+                            dialogueSensitiveHits = sensitiveWordAcManager.matchAll(__sw_tenantId, agentContent);
+
+                            // 计算违规数量(命中的敏感词次数)
+                            int violationCount = dialogueSensitiveHits != null ? dialogueSensitiveHits.size() : 0;
+
+                            // 对完整对话内容进行标红处理(只标红 agent 角色中的敏感词)
+                            dialogueWithSensitiveHighlight = highlightAgentSensitiveWords(
+                                    __sw_tenantId,
+                                    __sw_dialogue,
+                                    "<span class=\"sensitive-word\" style=\"color:red;\">",
+                                    "</span>");
+
+                            log.warn("【敏感词命中】uuid={}, tenantId={}, 违规数量={}, hits={}, highlighted={}",
+                                    uuid, __sw_tenantId, violationCount, dialogueSensitiveHits, dialogueWithSensitiveHighlight);
+                            //写入数据
+                            result.setProcessText(dialogueWithSensitiveHighlight);
+                            result.setCheckBoolean(dialogueHasSensitiveWord);
+                            result.setViolationCount(violationCount);
+                        } else {
+                            log.debug("【敏感词检测】未命中, uuid={}, tenantId={}", uuid, __sw_tenantId);
+                        }
+                    } else {
+                        log.debug("【敏感词检测】无agent对话内容, uuid={}, tenantId={}", uuid, __sw_tenantId);
+                    }
+                }
+            } catch (Exception __sw_e) {
+                log.error("【敏感词检测异常】uuid={}, tenantId={}", uuid, __sw_tenantId, __sw_e);
+            }
+        }
+        // ==================== 敏感词检测结束 ====================
+
+        return result;
+    }
+
+
+    private String highlightAgentSensitiveWords(Long tenantId, String fullDialogueJson,
+                                                String highlightPrefix, String highlightSuffix) {
+        JSONArray dialogueArray = JSON.parseArray(fullDialogueJson);
+
+        // 遍历对话数组,只对 role="agent" 的内容进行标红
+        for (int i = 0; i < dialogueArray.size(); i++) {
+            JSONObject item = dialogueArray.getJSONObject(i);
+            if (item != null && "agent".equals(item.getString("role"))) {  // ← 只处理 agent
+                String content = item.getString("content");
+                if (StringUtils.isNotBlank(content)) {
+                    // 对 agent 的内容进行敏感词标红
+                    String highlightedContent = sensitiveWordAcManager.highlight(
+                            tenantId, content, highlightPrefix, highlightSuffix);
+                    item.put("content", highlightedContent);
+                }
+            }
+        }
+        return dialogueArray.toJSONString();
+    }
+}

+ 47 - 0
fs-service/src/main/java/com/fs/sensitive/domain/CompanyAiSensitiveWord.java

@@ -0,0 +1,47 @@
+package com.fs.sensitive.domain;
+
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 敏感词库对象 company_ai_sensitive_word
+ *
+ * @author fs
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CompanyAiSensitiveWord extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 敏感词主键 */
+    private Long wordId;
+
+    /** 敏感词内容 */
+    @Excel(name = "敏感词")
+    private String word;
+
+    /** 是否启用 1-启用 0-禁用 */
+    @Excel(name = "状态", readConverterExp = "1=启用,0=禁用")
+    private Integer enabled;
+
+    /** 来源 1=手工 2=批量 3=内置 */
+    @Excel(name = "来源", readConverterExp = "1=手工,2=批量,3=内置")
+    private Integer source;
+
+    /** 删除标记 0-未删除 1-已删除 */
+    private Integer delFlag;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 255 - 0
fs-service/src/main/java/com/fs/sensitive/manager/SensitiveWordAcManager.java

@@ -0,0 +1,255 @@
+package com.fs.sensitive.manager;
+
+import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
+import com.hankcs.algorithm.AhoCorasickDoubleArrayTrie;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 敏感词 AC 自动机管理器(基于 com.hankcs:aho-corasick-double-array-trie)。
+ *
+ * <p>设计要点:
+ * <ul>
+ *   <li>按 {@code tenantId} 维度缓存 {@link AhoCorasickDoubleArrayTrie},每个租户库一棵树。</li>
+ *   <li>调用前请确保 ThreadLocal 数据源已切到对应租户库(动态数据源切面会自动生效)。</li>
+ *   <li>{@link #evict(Long)} 用于后台 CRUD 时显式清空缓存,下次读取触发懒加载重建。</li>
+ *   <li>{@link AhoCorasickDoubleArrayTrie} 一旦 build 完成不可增量修改,新增/删除敏感词必须重建整棵树。</li>
+ * </ul>
+ *
+ * <p>典型用法:
+ * <pre>
+ *     if (sensitiveWordAcManager.hasSensitiveWord(tenantId, text)) {
+ *         String highlighted = sensitiveWordAcManager.highlight(
+ *                 tenantId, text, "&lt;span class=\"sensitive\"&gt;", "&lt;/span&gt;");
+ *     }
+ * </pre>
+ *
+ * @author fs
+ */
+@Slf4j
+@Component
+public class SensitiveWordAcManager {
+
+    /** 空树占位符,避免不停地针对空词库重复 build */
+    private static final AhoCorasickDoubleArrayTrie<String> EMPTY_TRIE = buildTrie(Collections.emptyList());
+
+    /** tenantId -> 该租户库的敏感词 AC 自动机 */
+    private final ConcurrentHashMap<Long, AhoCorasickDoubleArrayTrie<String>> cache = new ConcurrentHashMap<>();
+
+    @Autowired
+    private ICompanyAiSensitiveWordService companyAiSensitiveWordService;
+
+    // ---------------------------------------------------------
+    // 构建 / 缓存
+    // ---------------------------------------------------------
+
+    /**
+     * 获取指定租户的 AC 自动机,不存在时懒加载构建。
+     *
+     * @param tenantId 租户 ID(不能为 null)
+     * @return 该租户的 AC 自动机
+     */
+    public AhoCorasickDoubleArrayTrie<String> get(Long tenantId) {
+        if (tenantId == null) {
+            return EMPTY_TRIE;
+        }
+        AhoCorasickDoubleArrayTrie<String> trie = cache.get(tenantId);
+        if (trie != null) {
+            return trie;
+        }
+        synchronized (cache) {
+            trie = cache.get(tenantId);
+            if (trie != null) {
+                return trie;
+            }
+            trie = loadFromDb(tenantId);
+            cache.put(tenantId, trie);
+            return trie;
+        }
+    }
+
+    /**
+     * 从数据库加载启用中的敏感词并构建 AC 自动机。
+     * 调用此方法前 ThreadLocal 数据源必须已切到对应租户库。
+     */
+    private AhoCorasickDoubleArrayTrie<String> loadFromDb(Long tenantId) {
+        try {
+            List<String> words = companyAiSensitiveWordService.selectAllEnabledWords();
+            if (words == null || words.isEmpty()) {
+                log.info("【敏感词缓存】tenantId={} 启用的敏感词为空,使用空树", tenantId);
+                return EMPTY_TRIE;
+            }
+            AhoCorasickDoubleArrayTrie<String> trie = buildTrie(words);
+            log.info("【敏感词缓存】tenantId={} 构建完成, 词条数={}", tenantId, words.size());
+            return trie;
+        } catch (Exception e) {
+            log.error("【敏感词缓存】tenantId={} 加载失败,本次返回空树", tenantId, e);
+            return EMPTY_TRIE;
+        }
+    }
+
+    /** 把词表构建成一棵不可变的双数组 Trie,value 直接复用敏感词本身。 */
+    private static AhoCorasickDoubleArrayTrie<String> buildTrie(List<String> words) {
+        TreeMap<String, String> map = new TreeMap<>();
+        if (words != null) {
+            for (String w : words) {
+                if (StringUtils.isNotBlank(w)) {
+                    map.put(w, w);
+                }
+            }
+        }
+        AhoCorasickDoubleArrayTrie<String> trie = new AhoCorasickDoubleArrayTrie<>();
+        trie.build(map);
+        return trie;
+    }
+
+    /**
+     * 清除指定租户的缓存(后台增删改敏感词后调用,下次读取会重建)。
+     */
+    public void evict(Long tenantId) {
+        if (tenantId != null) {
+            cache.remove(tenantId);
+            log.info("【敏感词缓存】tenantId={} 缓存已清除", tenantId);
+        }
+    }
+
+    /** 清除全部租户缓存。 */
+    public void evictAll() {
+        cache.clear();
+        log.info("【敏感词缓存】全部租户缓存已清除");
+    }
+
+    // ---------------------------------------------------------
+    // 检测 / 标红 / 替换
+    // ---------------------------------------------------------
+
+    /**
+     * 是否包含敏感词。出现任意一个即返回 true。
+     */
+    public boolean hasSensitiveWord(Long tenantId, String text) {
+        if (StringUtils.isBlank(text)) {
+            return false;
+        }
+        AhoCorasickDoubleArrayTrie<String> trie = get(tenantId);
+        if (trie == EMPTY_TRIE) {
+            return false;
+        }
+        // parseText 找到第一个就抛异常以快速短路
+        final boolean[] found = new boolean[]{false};
+        AhoCorasickDoubleArrayTrie.IHit<String> hit = new AhoCorasickDoubleArrayTrie.IHit<String>() {
+            @Override
+            public void hit(int begin, int end, String value) {
+                found[0] = true;
+                throw new ShortCircuitException();
+            }
+        };
+        try {
+            trie.parseText(text, hit);
+        } catch (ShortCircuitException ignored) {
+            // 短路退出,无须额外处理
+        }
+        return found[0];
+    }
+
+    /**
+     * 列出所有命中(包含位置区间,可用于前端标红/统计)。
+     */
+    public List<SensitiveWordHit> matchAll(Long tenantId, String text) {
+        if (StringUtils.isBlank(text)) {
+            return Collections.emptyList();
+        }
+        AhoCorasickDoubleArrayTrie<String> trie = get(tenantId);
+        if (trie == EMPTY_TRIE) {
+            return Collections.emptyList();
+        }
+        final List<SensitiveWordHit> hits = new ArrayList<>();
+        AhoCorasickDoubleArrayTrie.IHit<String> collector = new AhoCorasickDoubleArrayTrie.IHit<String>() {
+            @Override
+            public void hit(int begin, int end, String value) {
+                hits.add(new SensitiveWordHit(begin, end, value));
+            }
+        };
+        trie.parseText(text, collector);
+        return hits;
+    }
+
+    /**
+     * 返回标记后的文本,命中部分被 {@code prefixTag} / {@code suffixTag} 包裹。
+     * 例如 {@code highlight(tenantId, text, "&lt;span class=\"sensitive\"&gt;", "&lt;/span&gt;")}。
+     *
+     * <p>处理重叠命中:保留较早出现且较长的命中,丢弃被覆盖的命中,避免标签嵌套混乱。
+     */
+    public String highlight(Long tenantId, String text, String prefixTag, String suffixTag) {
+        if (StringUtils.isBlank(text)) {
+            return text;
+        }
+        List<SensitiveWordHit> hits = matchAll(tenantId, text);
+        if (hits.isEmpty()) {
+            return text;
+        }
+        // 按 begin 升序、长度降序排序后做区间合并
+        hits.sort((a, b) -> {
+            if (a.getBegin() != b.getBegin()) {
+                return Integer.compare(a.getBegin(), b.getBegin());
+            }
+            return Integer.compare(b.getEnd() - b.getBegin(), a.getEnd() - a.getBegin());
+        });
+
+        StringBuilder sb = new StringBuilder(text.length() + hits.size() * 16);
+        int cursor = 0;
+        for (SensitiveWordHit hit : hits) {
+            if (hit.getBegin() < cursor) {
+                // 与前一段重叠,跳过
+                continue;
+            }
+            if (hit.getBegin() > cursor) {
+                sb.append(text, cursor, hit.getBegin());
+            }
+            sb.append(prefixTag)
+              .append(text, hit.getBegin(), hit.getEnd())
+              .append(suffixTag);
+            cursor = hit.getEnd();
+        }
+        if (cursor < text.length()) {
+            sb.append(text, cursor, text.length());
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 把命中部分用 {@code mask} 字符按命中长度等长替换(如 '*')。
+     */
+    public String replace(Long tenantId, String text, char mask) {
+        if (StringUtils.isBlank(text)) {
+            return text;
+        }
+        List<SensitiveWordHit> hits = matchAll(tenantId, text);
+        if (hits.isEmpty()) {
+            return text;
+        }
+        char[] chars = text.toCharArray();
+        for (SensitiveWordHit hit : hits) {
+            for (int i = hit.getBegin(); i < hit.getEnd() && i < chars.length; i++) {
+                chars[i] = mask;
+            }
+        }
+        return new String(chars);
+    }
+
+    /** 仅用于 parseText 短路退出的内部异常 */
+    private static final class ShortCircuitException extends RuntimeException {
+        private static final long serialVersionUID = 1L;
+
+        ShortCircuitException() {
+            super(null, null, false, false);
+        }
+    }
+}

+ 48 - 0
fs-service/src/main/java/com/fs/sensitive/manager/SensitiveWordHit.java

@@ -0,0 +1,48 @@
+package com.fs.sensitive.manager;
+
+/**
+ * 敏感词命中信息(用于后续标红/替换/统计等场景)。
+ *
+ * <p>所有字段使用文本中的偏移量描述命中范围:
+ * <ul>
+ *   <li>{@code begin} 命中起始下标(包含)</li>
+ *   <li>{@code end}   命中结束下标(不包含,可直接配合 {@link String#substring(int, int)} 使用)</li>
+ *   <li>{@code word}  命中的敏感词原文</li>
+ * </ul>
+ *
+ * @author fs
+ */
+public class SensitiveWordHit {
+
+    /** 命中起始下标(包含) */
+    private final int begin;
+
+    /** 命中结束下标(不包含) */
+    private final int end;
+
+    /** 命中的敏感词文本 */
+    private final String word;
+
+    public SensitiveWordHit(int begin, int end, String word) {
+        this.begin = begin;
+        this.end = end;
+        this.word = word;
+    }
+
+    public int getBegin() {
+        return begin;
+    }
+
+    public int getEnd() {
+        return end;
+    }
+
+    public String getWord() {
+        return word;
+    }
+
+    @Override
+    public String toString() {
+        return "{begin=" + begin + ", end=" + end + ", word='" + word + "'}";
+    }
+}

+ 57 - 0
fs-service/src/main/java/com/fs/sensitive/mapper/CompanyAiSensitiveWordMapper.java

@@ -0,0 +1,57 @@
+package com.fs.sensitive.mapper;
+
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 敏感词库Mapper接口
+ *
+ * @author fs
+ */
+public interface CompanyAiSensitiveWordMapper {
+
+    /**
+     * 查询敏感词
+     */
+    CompanyAiSensitiveWord selectCompanyAiSensitiveWordByWordId(Long wordId);
+
+    /**
+     * 查询敏感词列表
+     */
+    List<CompanyAiSensitiveWord> selectCompanyAiSensitiveWordList(CompanyAiSensitiveWord companyAiSensitiveWord);
+
+    /**
+     * 新增敏感词
+     */
+    int insertCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord);
+
+    /**
+     * 修改敏感词
+     */
+    int updateCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord);
+
+    /**
+     * 删除敏感词
+     */
+    int deleteCompanyAiSensitiveWordByWordId(Long wordId);
+
+    /**
+     * 批量删除敏感词
+     */
+    int deleteCompanyAiSensitiveWordByWordIds(Long[] wordIds);
+
+    /**
+     * 根据敏感词内容查询数量(用于重复校验,过滤已删除)
+     */
+    @Select("select count(1) from company_ai_sensitive_word where word = #{word} and del_flag = 0")
+    int selectCountByWord(@Param("word") String word);
+
+    /**
+     * 查询所有启用的敏感词
+     */
+    @Select("select word from company_ai_sensitive_word where enabled = 1 and del_flag = 0")
+    List<String> selectAllEnabledWords();
+}

+ 53 - 0
fs-service/src/main/java/com/fs/sensitive/service/ICompanyAiSensitiveWordService.java

@@ -0,0 +1,53 @@
+package com.fs.sensitive.service;
+
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+
+import java.util.List;
+
+/**
+ * 敏感词库Service接口
+ *
+ * @author fs
+ */
+public interface ICompanyAiSensitiveWordService {
+
+    /**
+     * 查询敏感词
+     */
+    CompanyAiSensitiveWord selectCompanyAiSensitiveWordByWordId(Long wordId);
+
+    /**
+     * 查询敏感词列表
+     */
+    List<CompanyAiSensitiveWord> selectCompanyAiSensitiveWordList(CompanyAiSensitiveWord companyAiSensitiveWord);
+
+    /**
+     * 新增敏感词(返回 -1 表示重复)
+     */
+    int insertCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord);
+
+    /**
+     * 修改敏感词
+     */
+    int updateCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord);
+
+    /**
+     * 批量删除敏感词
+     */
+    int deleteCompanyAiSensitiveWordByWordIds(Long[] wordIds);
+
+    /**
+     * 删除敏感词
+     */
+    int deleteCompanyAiSensitiveWordByWordId(Long wordId);
+
+    /**
+     * 查询所有启用的敏感词内容
+     */
+    List<String> selectAllEnabledWords();
+
+    /**
+     * 更改启用状态
+     */
+    int changeEnabled(Long wordId, Integer enabled);
+}

+ 78 - 0
fs-service/src/main/java/com/fs/sensitive/service/impl/CompanyAiSensitiveWordServiceImpl.java

@@ -0,0 +1,78 @@
+package com.fs.sensitive.service.impl;
+
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+import com.fs.sensitive.mapper.CompanyAiSensitiveWordMapper;
+import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 敏感词库Service业务层处理
+ *
+ * @author fs
+ */
+@Service
+public class CompanyAiSensitiveWordServiceImpl implements ICompanyAiSensitiveWordService {
+
+    @Resource
+    private CompanyAiSensitiveWordMapper baseMapper;
+
+    @Override
+    public CompanyAiSensitiveWord selectCompanyAiSensitiveWordByWordId(Long wordId) {
+        return baseMapper.selectCompanyAiSensitiveWordByWordId(wordId);
+    }
+
+    @Override
+    public List<CompanyAiSensitiveWord> selectCompanyAiSensitiveWordList(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return baseMapper.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+    }
+
+    @Override
+    public int insertCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        // 敏感词重复校验
+        int count = baseMapper.selectCountByWord(companyAiSensitiveWord.getWord());
+        if (count > 0) {
+            return -1;
+        }
+        if (companyAiSensitiveWord.getEnabled() == null) {
+            companyAiSensitiveWord.setEnabled(1);
+        }
+        if (companyAiSensitiveWord.getSource() == null) {
+            companyAiSensitiveWord.setSource(1);
+        }
+        if (companyAiSensitiveWord.getDelFlag() == null) {
+            companyAiSensitiveWord.setDelFlag(0);
+        }
+        return baseMapper.insertCompanyAiSensitiveWord(companyAiSensitiveWord);
+    }
+
+    @Override
+    public int updateCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return baseMapper.updateCompanyAiSensitiveWord(companyAiSensitiveWord);
+    }
+
+    @Override
+    public int deleteCompanyAiSensitiveWordByWordIds(Long[] wordIds) {
+        return baseMapper.deleteCompanyAiSensitiveWordByWordIds(wordIds);
+    }
+
+    @Override
+    public int deleteCompanyAiSensitiveWordByWordId(Long wordId) {
+        return baseMapper.deleteCompanyAiSensitiveWordByWordId(wordId);
+    }
+
+    @Override
+    public List<String> selectAllEnabledWords() {
+        return baseMapper.selectAllEnabledWords();
+    }
+
+    @Override
+    public int changeEnabled(Long wordId, Integer enabled) {
+        CompanyAiSensitiveWord update = new CompanyAiSensitiveWord();
+        update.setWordId(wordId);
+        update.setEnabled(enabled);
+        return baseMapper.updateCompanyAiSensitiveWord(update);
+    }
+}

+ 22 - 0
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -2727,6 +2727,8 @@ CREATE TABLE `company_voice_robotic_call_log_callphone`
     `update_by`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
     `update_time`      datetime NULL DEFAULT NULL,
     `call_type`        int NULL DEFAULT NULL COMMENT '外呼类型',
+    `is_warning` tinyint NULL DEFAULT 0 COMMENT '是否警告(0否1是)用于敏感词',
+    `violation_num` int NULL DEFAULT 0 COMMENT '违规数量',
     PRIMARY KEY (`log_id`) USING BTREE,
     INDEX              `robotic_id_and_caller_id_idx`(`robotic_id`, `caller_id`) USING BTREE,
     INDEX              `company_and_company_user_idx`(`company_id`, `company_user_id`) USING BTREE,
@@ -18065,5 +18067,25 @@ CREATE TABLE `tencent_word_detail`  (
     INDEX `idx_sheet_id`(`sheet_id` ASC) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '腾讯文档详情表' ROW_FORMAT = Dynamic;
 
+-- ----------------------------
+-- Table structure for company_ai_sensitive_word
+-- ----------------------------
+DROP TABLE IF EXISTS `company_ai_sensitive_word`;
+CREATE TABLE `company_ai_sensitive_word`  (
+                                              `word_id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+                                              `word` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '敏感词',
+                                              `enabled` tinyint NULL DEFAULT 1 COMMENT '是否启用 0/1',
+                                              `source` tinyint NULL DEFAULT 1 COMMENT '来源 1=手工 2=批量 3=内置',
+                                              `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
+                                              `create_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                              `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP,
+                                              `update_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
+                                              `update_time` datetime NULL DEFAULT NULL,
+                                              `del_flag` tinyint NULL DEFAULT 0,
+                                              PRIMARY KEY (`word_id`) USING BTREE,
+                                              UNIQUE INDEX `uk_word`(`word` ASC, `del_flag` ASC) USING BTREE,
+                                              INDEX `idx_enabled`(`enabled` ASC) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '敏感词库' ROW_FORMAT = DYNAMIC;
+
 SET
 FOREIGN_KEY_CHECKS = 1;

+ 14 - 1
fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml

@@ -25,10 +25,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="companyUserId"    column="company_user_id"    />
         <result property="callTime"    column="call_time"    />
         <result property="cost"    column="cost"    />
+        <result property="callType"    column="call_type"    />
+        <result property="isWarning"    column="is_warning"    />
     </resultMap>
 
     <sql id="selectCompanyVoiceRoboticCallLogCallphoneVo">
-        select log_id, robotic_id, caller_id, run_time, run_param, result, status, create_time, record_path, content_list, caller_num, callee_num, uuid, call_create_time, call_answer_time, intention, company_id, company_user_id, call_time, cost from company_voice_robotic_call_log_callphone
+        select log_id, robotic_id, caller_id, run_time, run_param, result, status, create_time, record_path, content_list, caller_num, callee_num, uuid, call_create_time, call_answer_time, intention, company_id, company_user_id, call_time, cost, call_type, is_warning from company_voice_robotic_call_log_callphone
     </sql>
 
     <select id="selectCompanyVoiceRoboticCallLogCallphoneList" parameterType="CompanyVoiceRoboticCallLogCallphone" resultMap="CompanyVoiceRoboticCallLogCallphoneResult">
@@ -52,6 +54,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="companyUserId != null "> and company_user_id = #{companyUserId}</if>
             <if test="callTime != null "> and call_time = #{callTime}</if>
             <if test="cost != null "> and cost = #{cost}</if>
+            <if test="callType != null "> and call_type = #{callType}</if>
+            <if test="isWarning != null "> and is_warning = #{isWarning}</if>
         </where>
     </select>
     
@@ -83,6 +87,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="companyUserId != null">company_user_id,</if>
             <if test="callTime != null">call_time,</if>
             <if test="cost != null">cost,</if>
+            <if test="callType != null">call_type,</if>
+            <if test="isWarning != null">is_warning,</if>
+            <if test="violationNum != null">violation_num,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="callbackUuid != null">#{callbackUuid},</if>
@@ -105,6 +112,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="companyUserId != null">#{companyUserId},</if>
             <if test="callTime != null">#{callTime},</if>
             <if test="cost != null">#{cost},</if>
+            <if test="callType != null">#{callType},</if>
+            <if test="isWarning != null">#{isWarning},</if>
+            <if test="violationNum != null">#{violationNum},</if>
          </trim>
     </insert>
 
@@ -130,6 +140,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="companyUserId != null">company_user_id = #{companyUserId},</if>
             <if test="callTime != null">call_time = #{callTime},</if>
             <if test="cost != null">cost = #{cost},</if>
+            <if test="callType != null">call_type = #{callType},</if>
+            <if test="isWarning != null">is_warning = #{isWarning},</if>
+            <if test="violationNum != null">violation_num = #{violationNum},</if>
         </trim>
         where log_id = #{logId}
     </update>

+ 94 - 0
fs-service/src/main/resources/mapper/sensitive/CompanyAiSensitiveWordMapper.xml

@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.sensitive.mapper.CompanyAiSensitiveWordMapper">
+
+    <resultMap type="com.fs.sensitive.domain.CompanyAiSensitiveWord" id="CompanyAiSensitiveWordResult">
+        <result property="wordId"     column="word_id"/>
+        <result property="word"       column="word"/>
+        <result property="enabled"    column="enabled"/>
+        <result property="source"     column="source"/>
+        <result property="remark"     column="remark"/>
+        <result property="createBy"   column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateBy"   column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="delFlag"    column="del_flag"/>
+    </resultMap>
+
+    <sql id="selectCompanyAiSensitiveWordVo">
+        select word_id, word, enabled, source, remark,
+               create_by, create_time, update_by, update_time, del_flag
+        from company_ai_sensitive_word
+    </sql>
+
+    <select id="selectCompanyAiSensitiveWordList" parameterType="com.fs.sensitive.domain.CompanyAiSensitiveWord" resultMap="CompanyAiSensitiveWordResult">
+        <include refid="selectCompanyAiSensitiveWordVo"/>
+        <where>
+            del_flag = 0
+            <if test="word != null and word != ''"> and word like concat('%', #{word}, '%')</if>
+            <if test="enabled != null">             and enabled = #{enabled}</if>
+            <if test="source != null">              and source = #{source}</if>
+            <if test="params.beginTime != null and params.beginTime != ''">
+                and create_time &gt;= #{params.beginTime}
+            </if>
+            <if test="params.endTime != null and params.endTime != ''">
+                and create_time &lt;= #{params.endTime}
+            </if>
+        </where>
+        order by word_id desc
+    </select>
+
+    <select id="selectCompanyAiSensitiveWordByWordId" parameterType="Long" resultMap="CompanyAiSensitiveWordResult">
+        <include refid="selectCompanyAiSensitiveWordVo"/>
+        where word_id = #{wordId} and del_flag = 0
+    </select>
+
+    <insert id="insertCompanyAiSensitiveWord" parameterType="com.fs.sensitive.domain.CompanyAiSensitiveWord" useGeneratedKeys="true" keyProperty="wordId">
+        insert into company_ai_sensitive_word
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="word != null and word != ''">word,</if>
+            <if test="enabled != null">enabled,</if>
+            <if test="source != null">source,</if>
+            <if test="remark != null">remark,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="delFlag != null">del_flag,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="word != null and word != ''">#{word},</if>
+            <if test="enabled != null">#{enabled},</if>
+            <if test="source != null">#{source},</if>
+            <if test="remark != null">#{remark},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="delFlag != null">#{delFlag},</if>
+            now(),
+        </trim>
+    </insert>
+
+    <update id="updateCompanyAiSensitiveWord" parameterType="com.fs.sensitive.domain.CompanyAiSensitiveWord">
+        update company_ai_sensitive_word
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="word != null and word != ''">word = #{word},</if>
+            <if test="enabled != null">enabled = #{enabled},</if>
+            <if test="source != null">source = #{source},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="delFlag != null">del_flag = #{delFlag},</if>
+            update_time = now(),
+        </trim>
+        where word_id = #{wordId}
+    </update>
+
+    <delete id="deleteCompanyAiSensitiveWordByWordId" parameterType="Long">
+        delete from company_ai_sensitive_word where word_id = #{wordId}
+    </delete>
+
+    <delete id="deleteCompanyAiSensitiveWordByWordIds" parameterType="String">
+        delete from company_ai_sensitive_word where word_id in
+        <foreach item="wordId" collection="array" open="(" separator="," close=")">
+            #{wordId}
+        </foreach>
+    </delete>
+</mapper>