Browse Source

大模型配置和我的微信管理

xw 1 ngày trước cách đây
mục cha
commit
f73e9b033d

+ 3 - 1
docs/company_ai_sensitive_word.sql

@@ -23,6 +23,7 @@ SET FOREIGN_KEY_CHECKS = 0;
 DROP TABLE IF EXISTS `company_ai_sensitive_word`;
 CREATE TABLE `company_ai_sensitive_word`  (
                                               `word_id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+                                              `company_id` bigint NULL DEFAULT NULL COMMENT '公司ID',
                                               `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=内置',
@@ -33,7 +34,8 @@ CREATE TABLE `company_ai_sensitive_word`  (
                                               `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,
+                                              UNIQUE INDEX `uk_word_company`(`word` ASC, `company_id` ASC, `del_flag` ASC) USING BTREE,
+                                              INDEX `idx_company_id`(`company_id` ASC) USING BTREE,
                                               INDEX `idx_enabled`(`enabled` ASC) USING BTREE
 ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '敏感词库' ROW_FORMAT = DYNAMIC;
 

+ 370 - 0
fs-admin/src/main/java/com/fs/aicall/controller/CcLlmAgentAccountController.java

@@ -0,0 +1,370 @@
+package com.fs.aicall.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aicall.domain.CompanyBindAiModel;
+import com.fs.aicall.domain.CcCallTask;
+import com.fs.aicall.domain.CcLlmAgentAccount;
+import com.fs.aicall.service.ICcCallTaskService;
+import com.fs.aicall.service.ICcLlmAgentAccountService;
+import com.fs.aicall.service.ICcParamsService;
+import com.fs.aicall.service.ICompanyBindAiModelService;
+import com.fs.aicall.utils.CommonUtils;
+import com.fs.aicall.utils.StringUtils;
+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.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.core.text.Convert;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.cache.ICompanyCacheService;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.ServletUtils;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.framework.web.service.TokenService;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 机器人参数配置Controller(总后台)
+ * <p>
+ * 模型数据存于共享 EASYCALL 库,需通过当前租户的公司绑定关系过滤;
+ * 默认展示本租户下全部公司已绑定模型,可按 companyId 进一步筛选。
+ * </p>
+ */
+@RestController
+@RequestMapping("/aicall/account")
+public class CcLlmAgentAccountController extends BaseController {
+
+    @Autowired
+    private ICcLlmAgentAccountService ccLlmAgentAccountService;
+    @Autowired
+    private ICcParamsService ccParamsService;
+    @Autowired
+    private ICcCallTaskService ccCallTaskService;
+    @Autowired
+    private ICompanyBindAiModelService companyBindAiModelService;
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+    @Autowired
+    private ICompanyCacheService companyCacheService;
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+    @Autowired
+    private TokenService tokenService;
+
+    private static final List<String> HIDE_KEYS = Arrays.asList(
+            "apiKey", "oauthPrivateKey", "oauthPublicKeyId", "patToken");
+
+    /**
+     * 查询机器人参数配置列表(当前租户下全部公司已绑定模型;可选 companyId 筛选)
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:list')")
+    @PostMapping("/list")
+    public TableDataInfo list(@RequestBody CcLlmAgentAccount ccLlmAgentAccount,
+                                @RequestParam(value = "companyId", required = false) Long companyId) {
+        List<Long> modelIds = resolveTenantModelIds(companyId);
+        if (modelIds.isEmpty()) {
+            return getDataTable(new ArrayList<>());
+        }
+        ccLlmAgentAccount.setModelIds(modelIds);
+
+        startPage();
+        List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(ccLlmAgentAccount);
+        TableDataInfo tableDataInfo = getDataTable(list);
+        List<CcLlmAgentAccount> records = (List<CcLlmAgentAccount>) tableDataInfo.getRows();
+        fillCompanyBindInfo(records);
+        maskAccountJson(records);
+        return tableDataInfo;
+    }
+
+    /**
+     * 获取机器人参数配置详情
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Integer id) {
+        CcLlmAgentAccount account = ccLlmAgentAccountService.selectCcLlmAgentAccountById(id);
+        if (StringUtils.isBlank(account.getInterruptIgnoreKeywords())) {
+            account.setInterruptIgnoreKeywords(
+                    ccParamsService.getParamValueByCode("default_interrupt_ignore_keywords", ""));
+        }
+        maskAccountJson(account);
+        fillCompanyBindInfo(Collections.singletonList(account));
+        return AjaxResult.success(account);
+    }
+
+    /**
+     * 导出机器人参数配置列表
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:export')")
+    @Log(title = "机器人参数配置", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public AjaxResult export(CcLlmAgentAccount ccLlmAgentAccount,
+                             @RequestParam(value = "companyId", required = false) Long companyId) {
+        List<Long> modelIds = resolveTenantModelIds(companyId);
+        if (modelIds.isEmpty()) {
+            ExcelUtil<CcLlmAgentAccount> emptyUtil = new ExcelUtil<>(CcLlmAgentAccount.class);
+            return emptyUtil.exportExcel(new ArrayList<>(), "机器人参数配置数据");
+        }
+        ccLlmAgentAccount.setModelIds(modelIds);
+        List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(ccLlmAgentAccount);
+        ExcelUtil<CcLlmAgentAccount> util = new ExcelUtil<>(CcLlmAgentAccount.class);
+        return util.exportExcel(list, "机器人参数配置数据");
+    }
+
+    /**
+     * 新增保存机器人参数配置
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:add')")
+    @Log(title = "配置模型新增", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult addSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount,
+                              @RequestParam(value = "companyId", required = false) Long companyId) {
+        prepareAccountEntity(ccLlmAgentAccount);
+        fillDeepSeekApiKey(ccLlmAgentAccount);
+        if (ccLlmAgentAccount.getKbCatId() == null) {
+            ccLlmAgentAccount.setKbCatId(-1);
+        }
+
+        int result = ccLlmAgentAccountService.insertCcLlmAgentAccount(ccLlmAgentAccount);
+        if (result > 0 && companyId != null) {
+            ensureTenantDataSource();
+            companyBindAiModelService.bindCompanyToModel(ccLlmAgentAccount.getId().longValue(), companyId);
+        }
+        return toAjax(result);
+    }
+
+    /**
+     * 修改保存机器人参数配置
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
+    @Log(title = "配置模型修改", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult editSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount) {
+        if (ccLlmAgentAccount.getId() != null && ccLlmAgentAccount.getId() > 0) {
+            String errMsg = checkEdit(ccLlmAgentAccount.getId());
+            if (StringUtils.isNotEmpty(errMsg)) {
+                return AjaxResult.error(errMsg);
+            }
+        }
+
+        prepareAccountEntity(ccLlmAgentAccount);
+
+        Integer originId = ccLlmAgentAccount.getId();
+        if (originId != null && originId < 0) {
+            originId = originId * -1;
+        }
+        if (originId != null) {
+            CcLlmAgentAccount oldAccount = ccLlmAgentAccountService.selectCcLlmAgentAccountById(originId);
+            JSONObject oldAccountJson = JSONObject.parseObject(oldAccount.getAccountJson());
+            JSONObject newAccountJson = JSONObject.parseObject(ccLlmAgentAccount.getAccountJson());
+            for (String key : newAccountJson.keySet()) {
+                if (HIDE_KEYS.contains(key) && newAccountJson.getString(key).contains("****")) {
+                    newAccountJson.put(key, oldAccountJson.getString(key));
+                }
+            }
+            ccLlmAgentAccount.setAccountJson(JSONObject.toJSONString(newAccountJson));
+        }
+        if (ccLlmAgentAccount.getKbCatId() == null) {
+            ccLlmAgentAccount.setKbCatId(-1);
+        }
+
+        if (ccLlmAgentAccount.getId() != null && ccLlmAgentAccount.getId() > 0) {
+            return toAjax(ccLlmAgentAccountService.updateCcLlmAgentAccount(ccLlmAgentAccount));
+        }
+        ccLlmAgentAccount.setId(null);
+        return toAjax(ccLlmAgentAccountService.insertCcLlmAgentAccount(ccLlmAgentAccount));
+    }
+
+    /**
+     * 删除机器人参数配置(同时清理全部公司绑定关系)
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:remove')")
+    @Log(title = "机器人参数配置", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String ids) {
+        ensureTenantDataSource();
+        for (String id : Convert.toStrArray(ids)) {
+            companyBindAiModelService.deleteCompanyBindAiModelByModelId(Long.parseLong(id));
+        }
+        return toAjax(ccLlmAgentAccountService.deleteCcLlmAgentAccountByIds(ids));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:account:list')")
+    @GetMapping("/all")
+    public AjaxResult all(@RequestParam(value = "companyId", required = false) Long companyId) {
+        CcLlmAgentAccount query = new CcLlmAgentAccount();
+        List<Long> modelIds = resolveTenantModelIds(companyId);
+        if (modelIds.isEmpty()) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+        query.setModelIds(modelIds);
+        List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(query);
+        fillCompanyBindInfo(list);
+        maskAccountJson(list);
+        return AjaxResult.success(list);
+    }
+
+    @GetMapping("/getCidConfig")
+    public R getCidConfig() {
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("cId.config");
+        return R.ok().put("data", sysConfig.getConfigValue());
+    }
+
+    /**
+     * 绑定模型与公司
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
+    @Log(title = "配置模型绑定公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/bindCompany")
+    public AjaxResult bindCompany(@RequestParam Long modelId, @RequestParam Long companyId) {
+        ensureTenantDataSource();
+        return toAjax(companyBindAiModelService.bindCompanyToModel(modelId, companyId));
+    }
+
+    /**
+     * 解除模型与公司的绑定
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
+    @Log(title = "配置模型解绑公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/unbindCompany")
+    public AjaxResult unbindCompany(@RequestParam Long modelId, @RequestParam Long companyId) {
+        ensureTenantDataSource();
+        companyBindAiModelService.deleteBindAiModelByCompanyIdAndModelIds(companyId, String.valueOf(modelId));
+        return AjaxResult.success();
+    }
+
+    private void prepareAccountEntity(CcLlmAgentAccount ccLlmAgentAccount) {
+        if ("Coze".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())
+                || "LocalNlpChat".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            ccLlmAgentAccount.setAccountEntity("CozeAccount");
+        } else if ("JiutianWorkflow".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())
+                || "JiutianAgent".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            ccLlmAgentAccount.setAccountEntity("JiutianAccount");
+        } else {
+            ccLlmAgentAccount.setAccountEntity("LlmAccount");
+        }
+    }
+
+    private void fillDeepSeekApiKey(CcLlmAgentAccount ccLlmAgentAccount) {
+        if (!"DeepSeekChat".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            return;
+        }
+        JSONObject jsonObject = JSONObject.parseObject(ccLlmAgentAccount.getAccountJson());
+        if (jsonObject == null || !jsonObject.containsKey("apiKey")
+                || !jsonObject.getString("apiKey").contains("**")) {
+            return;
+        }
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("cId.config");
+        if (sysConfig != null && StringUtils.isNotBlank(sysConfig.getConfigValue())) {
+            JSONObject configValue = JSONObject.parseObject(sysConfig.getConfigValue());
+            jsonObject.put("apiKey", configValue.getString("apiKey"));
+            ccLlmAgentAccount.setAccountJson(JSONObject.toJSONString(jsonObject));
+        }
+    }
+
+    private void maskAccountJson(List<CcLlmAgentAccount> records) {
+        for (CcLlmAgentAccount data : records) {
+            maskAccountJson(data);
+        }
+    }
+
+    private void maskAccountJson(CcLlmAgentAccount data) {
+        JSONObject accountJson = JSONObject.parseObject(data.getAccountJson());
+        for (String key : accountJson.keySet()) {
+            if (HIDE_KEYS.contains(key)) {
+                accountJson.put(key, CommonUtils.maskStringUtil(accountJson.getString(key)));
+            }
+        }
+        data.setAccountJson(JSONObject.toJSONString(accountJson));
+    }
+
+    private String checkEdit(Integer id) {
+        List<CcCallTask> ccCallTaskList = ccCallTaskService.selectCcCallTaskList(new CcCallTask().setLlmAccountId(id));
+        List<String> ids = new ArrayList<>();
+        for (CcCallTask ccCallTask : ccCallTaskList) {
+            if (ccCallTask.getTaskType() == 1
+                    && ccCallTask.getIfcall() == 1
+                    && ccCallTask.getAutoStop() == 0) {
+                ids.add(ccCallTask.getBatchName());
+            }
+        }
+        if (!ids.isEmpty()) {
+            return String.format("%s%s%s", "请先暂停任务:", StringUtils.join(ids, ","), ",再修改该配置,修改完成后再启动任务");
+        }
+        return "";
+    }
+
+    /**
+     * 解析当前租户可见的模型ID:
+     * - 指定 companyId 时,仅返回该公司绑定的模型
+     * - 未指定时,返回当前租户下所有公司已绑定的模型(company_bind_ai_model 在租户库)
+     */
+    private List<Long> resolveTenantModelIds(Long companyId) {
+        ensureTenantDataSource();
+        if (companyId != null) {
+            return companyBindAiModelService.selectModelIdsByCompanyId(companyId);
+        }
+        return companyBindAiModelService.selectCompanyBindAiModelList(new CompanyBindAiModel())
+                .stream()
+                .map(CompanyBindAiModel::getModelId)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 填充模型绑定的公司ID、公司名称(多对多,逗号分隔展示)
+     */
+    private void fillCompanyBindInfo(List<CcLlmAgentAccount> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        ensureTenantDataSource();
+        List<CompanyBindAiModel> allBinds =
+                companyBindAiModelService.selectCompanyBindAiModelList(new CompanyBindAiModel());
+        Map<Long, List<Long>> modelCompanyMap = allBinds.stream()
+                .collect(Collectors.groupingBy(
+                        CompanyBindAiModel::getModelId,
+                        Collectors.mapping(CompanyBindAiModel::getCompanyId, Collectors.toList())));
+
+        for (CcLlmAgentAccount account : records) {
+            List<Long> companyIds = modelCompanyMap.getOrDefault(
+                    account.getId().longValue(), Collections.emptyList());
+            account.setCompanyIds(companyIds);
+            if (companyIds.isEmpty()) {
+                account.setCompanyId("");
+                account.setCompanyName("");
+                continue;
+            }
+            List<String> companyNames = new ArrayList<>();
+            for (Long cid : companyIds) {
+                String name = companyCacheService.selectCompanyNameById(cid);
+                companyNames.add(StringUtils.isNotBlank(name) ? name : String.valueOf(cid));
+            }
+            account.setCompanyId(companyIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+            account.setCompanyName(String.join(",", companyNames));
+        }
+    }
+
+    /**
+     * company_bind_ai_model 在租户库;查询 EASYCALL 后 @DataSource 切面会 clear 数据源,需重新切回租户库。
+     */
+    private void ensureTenantDataSource() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (loginUser != null && loginUser.getTenantId() != null) {
+            tenantDataSourceManager.ensureSwitchByTenantId(loginUser.getTenantId());
+        }
+    }
+}

+ 77 - 0
fs-admin/src/main/java/com/fs/aicall/controller/CcLlmAgentProviderController.java

@@ -0,0 +1,77 @@
+package com.fs.aicall.controller;
+
+import com.fs.aicall.domain.CcLlmAgentProvider;
+import com.fs.aicall.service.ICcLlmAgentProviderService;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 大模型实现类列表 Controller(总后台)
+ */
+@RestController
+@RequestMapping("/aicall/provider")
+public class CcLlmAgentProviderController extends BaseController {
+
+    @Autowired
+    private ICcLlmAgentProviderService ccLlmAgentProviderService;
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CcLlmAgentProvider ccLlmAgentProvider) {
+        startPage();
+        List<CcLlmAgentProvider> list = ccLlmAgentProviderService.selectCcLlmAgentProviderList(ccLlmAgentProvider);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Integer id) {
+        return AjaxResult.success(ccLlmAgentProviderService.selectCcLlmAgentProviderById(id));
+    }
+
+    @GetMapping("/all")
+    public AjaxResult all() {
+        List<CcLlmAgentProvider> list =
+                ccLlmAgentProviderService.selectCcLlmAgentProviderList(new CcLlmAgentProvider());
+        return AjaxResult.success(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:export')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CcLlmAgentProvider ccLlmAgentProvider) {
+        List<CcLlmAgentProvider> list = ccLlmAgentProviderService.selectCcLlmAgentProviderList(ccLlmAgentProvider);
+        ExcelUtil<CcLlmAgentProvider> util = new ExcelUtil<>(CcLlmAgentProvider.class);
+        return util.exportExcel(list, "大模型实现类列表数据");
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:add')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CcLlmAgentProvider ccLlmAgentProvider) {
+        return toAjax(ccLlmAgentProviderService.insertCcLlmAgentProvider(ccLlmAgentProvider));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:edit')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CcLlmAgentProvider ccLlmAgentProvider) {
+        return toAjax(ccLlmAgentProviderService.updateCcLlmAgentProvider(ccLlmAgentProvider));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:remove')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String ids) {
+        return toAjax(ccLlmAgentProviderService.deleteCcLlmAgentProviderByIds(ids));
+    }
+}

+ 90 - 0
fs-admin/src/main/java/com/fs/aicall/controller/CcLlmKbCatController.java

@@ -0,0 +1,90 @@
+package com.fs.aicall.controller;
+
+import com.fs.aicall.domain.CcLlmKbCat;
+import com.fs.aicall.service.ICcLlmKbCatService;
+import com.fs.aicall.service.ICcLlmKbService;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 知识库 Controller(总后台)
+ */
+@RestController
+@RequestMapping("/aicall/kbcat")
+public class CcLlmKbCatController extends BaseController {
+
+    @Autowired
+    private ICcLlmKbCatService ccLlmKbCatService;
+    @Autowired
+    private ICcLlmKbService ccLlmKbService;
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CcLlmKbCat ccLlmKbCat) {
+        startPage();
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(ccLlmKbCat);
+        for (CcLlmKbCat data : list) {
+            data.setContentCount(ccLlmKbService.selectCountByCatId(data.getId()));
+        }
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(ccLlmKbCatService.selectCcLlmKbCatById(id));
+    }
+
+    @GetMapping("/all")
+    public AjaxResult all() {
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(new CcLlmKbCat());
+        return AjaxResult.success(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:export')")
+    @Log(title = "知识库", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CcLlmKbCat ccLlmKbCat) {
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(ccLlmKbCat);
+        ExcelUtil<CcLlmKbCat> util = new ExcelUtil<>(CcLlmKbCat.class);
+        return util.exportExcel(list, "知识库数据");
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:add')")
+    @Log(title = "知识库", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CcLlmKbCat ccLlmKbCat) {
+        CcLlmKbCat check = ccLlmKbCatService.selectCcLlmKbCatByCat(null, ccLlmKbCat.getCat());
+        if (check != null) {
+            return AjaxResult.error("知识库分类不能重复,请修改");
+        }
+        return toAjax(ccLlmKbCatService.insertCcLlmKbCat(ccLlmKbCat));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:edit')")
+    @Log(title = "知识库", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CcLlmKbCat ccLlmKbCat) {
+        CcLlmKbCat check = ccLlmKbCatService.selectCcLlmKbCatByCat(ccLlmKbCat.getId(), ccLlmKbCat.getCat());
+        if (check != null) {
+            return AjaxResult.error("知识库分类不能重复,请修改");
+        }
+        return toAjax(ccLlmKbCatService.updateCcLlmKbCat(ccLlmKbCat));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:remove')")
+    @Log(title = "知识库", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String ids) {
+        return toAjax(ccLlmKbCatService.deleteCcLlmKbCatByIds(ids));
+    }
+}

+ 91 - 0
fs-admin/src/main/java/com/fs/company/controller/CompanyVoiceCloneController.java

@@ -0,0 +1,91 @@
+package com.fs.company.controller;
+
+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.company.domain.CompanyVoiceCloneRef;
+import com.fs.company.service.ICompanyVoiceCloneService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 豆包声音克隆 Controller(总后台)
+ * <p>
+ * 总后台可查看全部公司音色数据,上传训练时需指定 companyId。
+ * </p>
+ */
+@RestController
+@RequestMapping("/company/voiceClone")
+public class CompanyVoiceCloneController extends BaseController {
+
+    @Autowired
+    private ICompanyVoiceCloneService companyVoiceCloneService;
+
+    /**
+     * 分页查询全部音色关联(可按 companyId、companyUserId 等筛选)
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:list')")
+    @GetMapping("/ref/list")
+    public TableDataInfo refList(CompanyVoiceCloneRef ref) {
+        startPage();
+        List<CompanyVoiceCloneRef> list = companyVoiceCloneService.selectRefList(ref);
+        return getDataTable(list);
+    }
+
+    /**
+     * 删除音色关联
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:remove')")
+    @Log(title = "声音克隆-删除音色", businessType = BusinessType.DELETE)
+    @DeleteMapping("/ref/{id}")
+    public AjaxResult removeRef(@PathVariable Long id) {
+        return toAjax(companyVoiceCloneService.deleteRefById(id));
+    }
+
+    /**
+     * 启用/禁用音色关联
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:update')")
+    @Log(title = "声音克隆-修改音色状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/ref/changeStatus")
+    public AjaxResult changeRefStatus(@RequestBody CompanyVoiceCloneRef ref) {
+        return toAjax(companyVoiceCloneService.changeStatus(ref));
+    }
+
+    /**
+     * 上传音频文件并训练声音克隆音色
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:add')")
+    @Log(title = "声音克隆-上传训练", businessType = BusinessType.IMPORT)
+    @PostMapping("/uploadAndTrain")
+    public AjaxResult uploadAndTrain(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam("voice_name") String voiceName,
+            @RequestParam("speaker_id") String speakerId,
+            @RequestParam("companyId") Long companyId,
+            @RequestParam(value = "companyUserId", required = false) Long companyUserId,
+            @RequestParam(value = "language", defaultValue = "0") Integer language,
+            @RequestParam(value = "model_type", defaultValue = "2") Integer modelType) {
+        return companyVoiceCloneService.uploadAndTrain(
+                voiceName, speakerId, language, modelType, file, companyId, companyUserId);
+    }
+
+    /**
+     * TTS 语音合成测试
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:list')")
+    @Log(title = "声音克隆-TTS测试", businessType = BusinessType.OTHER)
+    @PostMapping("/doubaoTtsTest")
+    public AjaxResult doubaoTtsTest(
+            @RequestParam("speakerId") String speakerId,
+            @RequestParam(value = "language", defaultValue = "0") Integer language,
+            @RequestParam("text") String text) {
+        return companyVoiceCloneService.doubaoTtsTest(speakerId, language, text);
+    }
+}

+ 120 - 0
fs-admin/src/main/java/com/fs/sensitive/controller/CompanyAiSensitiveWordController.java

@@ -0,0 +1,120 @@
+package com.fs.sensitive.controller;
+
+import cn.hutool.json.JSONUtil;
+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.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.service.ICompanyService;
+import com.fs.course.config.CourseConfig;
+import com.fs.framework.web.service.TokenService;
+import com.fs.his.vo.OptionsVO;
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
+import com.fs.system.service.ISysConfigService;
+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(总后台)
+ * <p>
+ * 总后台可管理当前租户下各公司敏感词,新增时需指定 companyId。
+ * </p>
+ */
+@RestController
+@RequestMapping("/sensitive/word")
+public class CompanyAiSensitiveWordController extends BaseController {
+
+    @Autowired
+    private ICompanyAiSensitiveWordService companyAiSensitiveWordService;
+    @Autowired
+    private ICompanyService companyService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private ISysConfigService configService;
+
+    /**
+     * 公司下拉列表(新增/筛选使用)
+     */
+    @GetMapping("/companyList")
+    public AjaxResult companyList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long deptId = null;
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        if (!loginUser.isAdmin() && config.getDept() != null && config.getDept()) {
+            deptId = loginUser.getDeptId();
+        }
+        List<OptionsVO> list = companyService.selectAllCompanyList(deptId);
+        return AjaxResult.success(list);
+    }
+
+    @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("/{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) {
+        if (companyAiSensitiveWord.getCompanyId() == null) {
+            return AjaxResult.error("请选择公司");
+        }
+        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));
+    }
+}

+ 15 - 2
fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java

@@ -5,7 +5,10 @@ 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.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import com.fs.sensitive.domain.CompanyAiSensitiveWord;
 import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -25,13 +28,16 @@ public class CompanyAiSensitiveWordController extends BaseController {
 
     @Autowired
     private ICompanyAiSensitiveWordService companyAiSensitiveWordService;
+    @Autowired
+    private TokenService tokenService;
 
     /**
-     * 查询敏感词列表
+     * 查询敏感词列表(当前公司)
      */
     @PreAuthorize("@ss.hasPermi('sensitive:word:list')")
     @GetMapping("/list")
     public TableDataInfo list(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        companyAiSensitiveWord.setCompanyId(getCurrentCompanyId());
         startPage();
         List<CompanyAiSensitiveWord> list = companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
         return getDataTable(list);
@@ -44,6 +50,7 @@ public class CompanyAiSensitiveWordController extends BaseController {
     @Log(title = "敏感词库", businessType = BusinessType.EXPORT)
     @GetMapping("/export")
     public AjaxResult export(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        companyAiSensitiveWord.setCompanyId(getCurrentCompanyId());
         List<CompanyAiSensitiveWord> list = companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
         ExcelUtil<CompanyAiSensitiveWord> util = new ExcelUtil<>(CompanyAiSensitiveWord.class);
         return util.exportExcel(list, "敏感词数据");
@@ -65,9 +72,10 @@ public class CompanyAiSensitiveWordController extends BaseController {
     @Log(title = "敏感词库", businessType = BusinessType.INSERT)
     @PostMapping
     public AjaxResult add(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        companyAiSensitiveWord.setCompanyId(getCurrentCompanyId());
         int ret = companyAiSensitiveWordService.insertCompanyAiSensitiveWord(companyAiSensitiveWord);
         if (ret == -1) {
-            return AjaxResult.error("该敏感词已存在");
+            return AjaxResult.error("该公司下该敏感词已存在");
         }
         return toAjax(ret);
     }
@@ -101,4 +109,9 @@ public class CompanyAiSensitiveWordController extends BaseController {
     public AjaxResult remove(@PathVariable Long[] wordIds) {
         return toAjax(companyAiSensitiveWordService.deleteCompanyAiSensitiveWordByWordIds(wordIds));
     }
+
+    private Long getCurrentCompanyId() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return loginUser.getUser().getCompanyId();
+    }
 }

+ 9 - 0
fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java

@@ -65,4 +65,13 @@ public class CcLlmAgentAccount implements Serializable {
     /** 模型ID列表,用于IN查询 */
     private List<Long> modelIds;
 
+    /** 绑定公司ID列表(展示字段) */
+    private List<Long> companyIds;
+
+    /** 绑定公司ID,逗号分隔(展示字段) */
+    private String companyId;
+
+    /** 绑定公司名称,逗号分隔(展示字段) */
+    private String companyName;
+
 }

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

@@ -21,6 +21,10 @@ public class CompanyAiSensitiveWord extends BaseEntity {
     /** 敏感词主键 */
     private Long wordId;
 
+    /** 公司ID */
+    @Excel(name = "公司ID")
+    private Long companyId;
+
     /** 敏感词内容 */
     @Excel(name = "敏感词")
     private String word;

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

@@ -46,8 +46,8 @@ public interface CompanyAiSensitiveWordMapper {
     /**
      * 根据敏感词内容查询数量(用于重复校验,过滤已删除)
      */
-    @Select("select count(1) from company_ai_sensitive_word where word = #{word} and del_flag = 0")
-    int selectCountByWord(@Param("word") String word);
+    @Select("select count(1) from company_ai_sensitive_word where word = #{word} and company_id = #{companyId} and del_flag = 0")
+    int selectCountByWord(@Param("word") String word, @Param("companyId") Long companyId);
 
     /**
      * 查询所有启用的敏感词

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

@@ -31,8 +31,8 @@ public class CompanyAiSensitiveWordServiceImpl implements ICompanyAiSensitiveWor
 
     @Override
     public int insertCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord) {
-        // 敏感词重复校验
-        int count = baseMapper.selectCountByWord(companyAiSensitiveWord.getWord());
+        // 同一公司下敏感词重复校验
+        int count = baseMapper.selectCountByWord(companyAiSensitiveWord.getWord(), companyAiSensitiveWord.getCompanyId());
         if (count > 0) {
             return -1;
         }

+ 6 - 1
fs-service/src/main/resources/mapper/sensitive/CompanyAiSensitiveWordMapper.xml

@@ -6,6 +6,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <resultMap type="com.fs.sensitive.domain.CompanyAiSensitiveWord" id="CompanyAiSensitiveWordResult">
         <result property="wordId"     column="word_id"/>
+        <result property="companyId"  column="company_id"/>
         <result property="word"       column="word"/>
         <result property="enabled"    column="enabled"/>
         <result property="source"     column="source"/>
@@ -18,7 +19,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectCompanyAiSensitiveWordVo">
-        select word_id, word, enabled, source, remark,
+        select word_id, company_id, word, enabled, source, remark,
                create_by, create_time, update_by, update_time, del_flag
         from company_ai_sensitive_word
     </sql>
@@ -27,6 +28,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <include refid="selectCompanyAiSensitiveWordVo"/>
         <where>
             del_flag = 0
+            <if test="companyId != null"> and company_id = #{companyId}</if>
             <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>
@@ -48,6 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <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="companyId != null">company_id,</if>
             <if test="word != null and word != ''">word,</if>
             <if test="enabled != null">enabled,</if>
             <if test="source != null">source,</if>
@@ -57,6 +60,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             create_time,
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="companyId != null">#{companyId},</if>
             <if test="word != null and word != ''">#{word},</if>
             <if test="enabled != null">#{enabled},</if>
             <if test="source != null">#{source},</if>
@@ -70,6 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <update id="updateCompanyAiSensitiveWord" parameterType="com.fs.sensitive.domain.CompanyAiSensitiveWord">
         update company_ai_sensitive_word
         <trim prefix="SET" suffixOverrides=",">
+            <if test="companyId != null">company_id = #{companyId},</if>
             <if test="word != null and word != ''">word = #{word},</if>
             <if test="enabled != null">enabled = #{enabled},</if>
             <if test="source != null">source = #{source},</if>