提交
This commit is contained in:
18
sql/mysql/ydoyun_ai_assistant_dify_kb.sql
Normal file
18
sql/mysql/ydoyun_ai_assistant_dify_kb.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 每日汇报:租户 + 页面(module_code) 对应一个 Dify 知识库(dataset),保存 datasetId 与聚合文档 ID
|
||||
CREATE TABLE IF NOT EXISTS `ydoyun_ai_assistant_dify_kb` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`module_code` varchar(128) NOT NULL DEFAULT '' COMMENT '模块编码(页面维度)',
|
||||
`module_name` varchar(128) NOT NULL DEFAULT '' COMMENT '模块名称',
|
||||
`dataset_id` varchar(64) NOT NULL DEFAULT '' COMMENT 'Dify 知识库/数据集 ID',
|
||||
`dataset_name` varchar(256) NOT NULL DEFAULT '' COMMENT 'Dify 侧知识库名称',
|
||||
`aggregate_document_id` varchar(64) DEFAULT NULL COMMENT '聚合文档 ID(首次创建后写入;后续走更新接口)',
|
||||
`creator` varchar(64) DEFAULT '' NULL COMMENT '创建者',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
|
||||
`updater` varchar(64) DEFAULT '' NULL COMMENT '更新者',
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) DEFAULT b'0' NOT NULL COMMENT '是否删除',
|
||||
`tenant_id` bigint DEFAULT 0 NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_module` (`tenant_id`, `module_code`),
|
||||
KEY `idx_module_code` (`module_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日汇报-Dify知识库绑定(每租户每页面一条)';
|
||||
19
sql/mysql/ydoyun_ai_assistant_dify_kb_sync_log.sql
Normal file
19
sql/mysql/ydoyun_ai_assistant_dify_kb_sync_log.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 每日汇报:同步 Dify 知识库记录
|
||||
CREATE TABLE IF NOT EXISTS `ydoyun_ai_assistant_dify_kb_sync_log` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`module_code` varchar(128) NOT NULL DEFAULT '' COMMENT '模块编码',
|
||||
`module_name` varchar(128) NOT NULL DEFAULT '' COMMENT '模块名称',
|
||||
`report_id` bigint DEFAULT NULL COMMENT '触发同步的汇报主表 ID',
|
||||
`dataset_id` varchar(64) NOT NULL DEFAULT '' COMMENT 'Dify 数据集 ID',
|
||||
`sync_status` varchar(32) NOT NULL DEFAULT '' COMMENT 'success / fail',
|
||||
`sync_message` varchar(1024) NOT NULL DEFAULT '' COMMENT '说明或错误信息',
|
||||
`creator` varchar(64) DEFAULT '' NULL COMMENT '操作人',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
|
||||
`updater` varchar(64) DEFAULT '' NULL COMMENT '更新者',
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) DEFAULT b'0' NOT NULL COMMENT '是否删除',
|
||||
`tenant_id` bigint DEFAULT 0 NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tenant_module_time` (`tenant_id`, `module_code`, `create_time`),
|
||||
KEY `idx_report_id` (`report_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日汇报-Dify知识库同步记录';
|
||||
36
sql/mysql/ydoyun_dify_kb_manage_menu.sql
Normal file
36
sql/mysql/ydoyun_dify_kb_manage_menu.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- =============================================================================
|
||||
-- Dify 知识库管理:菜单 + 按钮权限(system_menu)
|
||||
-- =============================================================================
|
||||
-- 说明:
|
||||
-- 1. type:1=目录 2=菜单 3=按钮
|
||||
-- 2. 权限标识与后端 @PreAuthorize、前端 v-hasPermi 一致
|
||||
-- 3. 默认父菜单为「报表管理」id=1281;若你库中不存在或需挂到其他目录,请改 parent_id
|
||||
-- 4. 执行前请确认 id 5110、5111、5112 未被占用;若冲突请整体替换为新 id,并同步改按钮的 parent_id
|
||||
-- =============================================================================
|
||||
select * from system_menu where name like '%码头%'
|
||||
-- 主菜单(路由)
|
||||
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
|
||||
VALUES ('报表知识库', '', 2, 55, 5042, 'dify-kb-manage', 'ep:folder', 'ydoyun/difykb/index', 'YdoyunDifyKbManage', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
|
||||
|
||||
-- 按钮权限:查询(列表、文档分页)
|
||||
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
|
||||
VALUES ('查询', 'ydoyun:dify-kb:query', 3, 1, 5130, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
|
||||
|
||||
-- 按钮权限:删除(删除知识库、删除文档)
|
||||
INSERT INTO `system_menu` (`name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
|
||||
VALUES ( '删除', 'ydoyun:dify-kb:delete', 3, 2, 5130, '', '', '', '', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 可选:将菜单授权给角色(system_role_menu)
|
||||
-- =============================================================================
|
||||
-- 在「系统管理 → 角色」里勾选菜单也可,不必执行下面语句。
|
||||
-- 若需 SQL 授权,请按你库中实际 role_id、tenant_id、自增 id 修改后执行。
|
||||
--
|
||||
-- INSERT INTO `system_role_menu` (`id`, `role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`)
|
||||
-- VALUES (xxxx, 2, 5110, '1', NOW(), '1', NOW(), b'0', 1);
|
||||
-- INSERT INTO `system_role_menu` (`id`, `role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`)
|
||||
-- VALUES (xxxx, 2, 5111, '1', NOW(), '1', NOW(), b'0', 1);
|
||||
-- INSERT INTO `system_role_menu` (`id`, `role_id`, `menu_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `tenant_id`)
|
||||
-- VALUES (xxxx, 2, 5112, '1', NOW(), '1', NOW(), b'0', 1);
|
||||
-- =============================================================================
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "每日汇报 - 当前模块 Dify 知识库元数据")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyKbInfoRespVO {
|
||||
|
||||
@Schema(description = "Dify 数据集 ID")
|
||||
private String datasetId;
|
||||
@Schema(description = "聚合同步文档 ID")
|
||||
private String documentId;
|
||||
@Schema(description = "知识库名称")
|
||||
private String datasetName;
|
||||
@Schema(description = "浏览链接(文档详情或文档列表)")
|
||||
private String knowledgeBaseUrl;
|
||||
@Schema(description = "最近同步时间(字符串,便于前端展示)")
|
||||
private String lastSyncTime;
|
||||
@Schema(description = "是否已创建知识库")
|
||||
private Boolean created;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
|
||||
@Schema(description = "每日汇报 - Dify 同步记录分页")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
public class DifyKbSyncLogPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "模块编码(可选,空则查当前租户全部模块记录)")
|
||||
private String moduleCode;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
|
||||
|
||||
@Schema(description = "每日汇报 - Dify 同步记录")
|
||||
@Data
|
||||
public class DifyKbSyncLogRespVO {
|
||||
|
||||
private Long id;
|
||||
private String moduleCode;
|
||||
private String moduleName;
|
||||
private Long reportId;
|
||||
private String datasetId;
|
||||
private String syncStatus;
|
||||
private String syncMessage;
|
||||
private String creator;
|
||||
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
@Schema(description = "每日汇报 - 同步 Dify 知识库请求")
|
||||
@Data
|
||||
public class DifyKbSyncReqVO {
|
||||
|
||||
@Schema(description = "已保存的汇报主表 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "reportId 不能为空")
|
||||
private Long reportId;
|
||||
|
||||
@Schema(description = "模块编码(须与汇报一致)")
|
||||
private String moduleCode;
|
||||
|
||||
@Schema(description = "模块名称")
|
||||
private String moduleName;
|
||||
|
||||
@Schema(description = "当前页面菜单编号(与后台菜单 id 一致,用于 Dify 知识库命名)")
|
||||
private Long menuId;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "每日汇报 - 同步 Dify 知识库响应")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyKbSyncRespVO {
|
||||
|
||||
@Schema(description = "Dify 数据集/知识库 ID")
|
||||
private String datasetId;
|
||||
@Schema(description = "聚合文档 ID(与控制台路径中的 documentId 一致)")
|
||||
private String documentId;
|
||||
@Schema(description = "知识库名称")
|
||||
private String datasetName;
|
||||
@Schema(description = "可在浏览器打开的链接(优先指向文档详情页,与 Dify 控制台路由一致)")
|
||||
private String knowledgeBaseUrl;
|
||||
@Schema(description = "本系统同步记录 ID")
|
||||
private Long syncRecordId;
|
||||
@Schema(description = "提示信息")
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.difykb;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentContentRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentPageReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbManageItemRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbManageListReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbModuleOptionRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.service.difykbmanage.DifyKbManageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
@Tag(name = "管理后台 - Dify 知识库管理")
|
||||
@RestController
|
||||
@RequestMapping("/ydoyun/dify-kb-manage")
|
||||
@Validated
|
||||
public class DifyKbManageController {
|
||||
|
||||
@Resource
|
||||
private DifyKbManageService difyKbManageService;
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "列出当前租户已绑定的 Dify 知识库(支持按模块编码/名称筛选)")
|
||||
@PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:query')")
|
||||
public CommonResult<List<DifyKbManageItemRespVO>> listBindings(DifyKbManageListReqVO reqVO) {
|
||||
return success(difyKbManageService.listBindings(reqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/module-options")
|
||||
@Operation(summary = "当前租户下模块筛选项(去重)")
|
||||
@PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:query')")
|
||||
public CommonResult<List<DifyKbModuleOptionRespVO>> listModuleOptions() {
|
||||
return success(difyKbManageService.listModuleFilterOptions());
|
||||
}
|
||||
|
||||
@GetMapping("/document/page")
|
||||
@Operation(summary = "分页查询某知识库下的文档(dataset 须为本租户已绑定)")
|
||||
@PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:difyquery')")
|
||||
public CommonResult<PageResult<DifyKbDocumentRespVO>> getDocumentPage(@Valid DifyKbDocumentPageReqVO reqVO) {
|
||||
return success(difyKbManageService.getDocumentPage(reqVO));
|
||||
}
|
||||
|
||||
@GetMapping("/document/content")
|
||||
@Operation(summary = "查看文档正文(由 Dify 分段接口拼接,仅允许本租户已绑定的 dataset)")
|
||||
@PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:difyquery')")
|
||||
public CommonResult<DifyKbDocumentContentRespVO> getDocumentContent(
|
||||
@RequestParam("datasetId") String datasetId,
|
||||
@RequestParam("documentId") String documentId) {
|
||||
return success(difyKbManageService.getDocumentContent(datasetId, documentId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/dataset/delete")
|
||||
@Operation(summary = "删除 Dify 知识库并移除本地绑定")
|
||||
@Parameter(name = "datasetId", description = "Dify dataset_id", required = true)
|
||||
@PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:delete')")
|
||||
public CommonResult<Boolean> deleteDataset(@RequestParam("datasetId") String datasetId) {
|
||||
difyKbManageService.deleteDataset(datasetId);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/document/delete")
|
||||
@Operation(summary = "删除 Dify 知识库中的文档")
|
||||
@PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:delete')")
|
||||
public CommonResult<Boolean> deleteDocument(
|
||||
@RequestParam("datasetId") String datasetId,
|
||||
@RequestParam("documentId") String documentId) {
|
||||
difyKbManageService.deleteDocument(datasetId, documentId);
|
||||
return success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "Dify 文档正文(由分段拼接)")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyKbDocumentContentRespVO {
|
||||
|
||||
@Schema(description = "正文内容")
|
||||
private String content;
|
||||
@Schema(description = "是否因长度上限被截断")
|
||||
private Boolean truncated;
|
||||
@Schema(description = "参与拼接的分段条数(含子块)")
|
||||
private Integer segmentPieces;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@Schema(description = "管理后台 - Dify 知识库文档分页")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DifyKbDocumentPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "Dify 知识库 dataset_id", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "datasetId 不能为空")
|
||||
private String datasetId;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "管理后台 - Dify 知识库文档")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyKbDocumentRespVO {
|
||||
|
||||
@Schema(description = "文档 ID")
|
||||
private String id;
|
||||
@Schema(description = "文档名称")
|
||||
private String name;
|
||||
@Schema(description = "创建时间(秒级时间戳)")
|
||||
private Long createdAt;
|
||||
@Schema(description = "索引状态")
|
||||
private String indexingStatus;
|
||||
@Schema(description = "展示状态")
|
||||
private String displayStatus;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "管理后台 - Dify 知识库绑定项")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyKbManageItemRespVO {
|
||||
|
||||
@Schema(description = "本地绑定主键")
|
||||
private Long id;
|
||||
@Schema(description = "模块编码")
|
||||
private String moduleCode;
|
||||
@Schema(description = "模块名称")
|
||||
private String moduleName;
|
||||
@Schema(description = "Dify 知识库 ID")
|
||||
private String datasetId;
|
||||
@Schema(description = "知识库名称(本地记录)")
|
||||
private String datasetName;
|
||||
@Schema(description = "聚合文档 ID")
|
||||
private String aggregateDocumentId;
|
||||
@Schema(description = "Dify 侧是否仍可访问该知识库")
|
||||
private Boolean existsOnDify;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 知识库绑定列表筛选(仅当前租户;按本地绑定表 module 字段过滤)。
|
||||
*/
|
||||
@Schema(description = "管理后台 - Dify 知识库绑定列表查询条件")
|
||||
@Data
|
||||
public class DifyKbManageListReqVO {
|
||||
|
||||
@Schema(description = "模块编码(精确匹配)")
|
||||
private String moduleCode;
|
||||
|
||||
@Schema(description = "模块名称(模糊匹配)")
|
||||
private String moduleName;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Schema(description = "模块筛选项(用于下拉)")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyKbModuleOptionRespVO {
|
||||
|
||||
@Schema(description = "模块编码")
|
||||
private String moduleCode;
|
||||
@Schema(description = "模块名称")
|
||||
private String moduleName;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantdifykb;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 每日汇报:租户 + 页面(module_code) 与 Dify 知识库绑定
|
||||
*/
|
||||
@TableName("ydoyun_ai_assistant_dify_kb")
|
||||
@KeySequence("ydoyun_ai_assistant_dify_kb_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiAssistantDifyKbDO extends TenantBaseDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
/** 模块编码 */
|
||||
private String moduleCode;
|
||||
/** 模块名称 */
|
||||
private String moduleName;
|
||||
/** Dify 数据集 ID */
|
||||
private String datasetId;
|
||||
/** Dify 数据集名称 */
|
||||
private String datasetName;
|
||||
/** 聚合文档 ID(全量历史写入同一文档) */
|
||||
private String aggregateDocumentId;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantdifykb;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 每日汇报:同步 Dify 知识库记录
|
||||
*/
|
||||
@TableName("ydoyun_ai_assistant_dify_kb_sync_log")
|
||||
@KeySequence("ydoyun_ai_assistant_dify_kb_sync_log_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AiAssistantDifyKbSyncLogDO extends TenantBaseDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private String moduleCode;
|
||||
private String moduleName;
|
||||
/** 触发同步的汇报主表 ID */
|
||||
private Long reportId;
|
||||
private String datasetId;
|
||||
/** success / fail */
|
||||
private String syncStatus;
|
||||
private String syncMessage;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantdifykb;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantdifykb.AiAssistantDifyKbDO;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 每日汇报 Dify 知识库绑定 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface AiAssistantDifyKbMapper extends BaseMapperX<AiAssistantDifyKbDO> {
|
||||
|
||||
default AiAssistantDifyKbDO selectByModuleCode(String moduleCode) {
|
||||
return selectOne(new LambdaQueryWrapperX<AiAssistantDifyKbDO>()
|
||||
.eq(AiAssistantDifyKbDO::getModuleCode, moduleCode)
|
||||
.orderByDesc(AiAssistantDifyKbDO::getId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
default AiAssistantDifyKbDO selectByDatasetId(String datasetId) {
|
||||
return selectOne(new LambdaQueryWrapperX<AiAssistantDifyKbDO>()
|
||||
.eq(AiAssistantDifyKbDO::getDatasetId, datasetId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 datasetId + 租户查询(管理端须显式带 tenant_id,避免与上下文漂移不一致)。
|
||||
*/
|
||||
default AiAssistantDifyKbDO selectByDatasetIdAndTenantId(String datasetId, Long tenantId) {
|
||||
if (StringUtils.isBlank(datasetId) || tenantId == null) {
|
||||
return null;
|
||||
}
|
||||
return selectOne(new LambdaQueryWrapperX<AiAssistantDifyKbDO>()
|
||||
.eq(AiAssistantDifyKbDO::getDatasetId, datasetId.trim())
|
||||
.eq(AiAssistantDifyKbDO::getTenantId, tenantId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantdifykb;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncLogPageReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantdifykb.AiAssistantDifyKbSyncLogDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* Dify 知识库同步记录 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface AiAssistantDifyKbSyncLogMapper extends BaseMapperX<AiAssistantDifyKbSyncLogDO> {
|
||||
|
||||
default PageResult<AiAssistantDifyKbSyncLogDO> selectPage(DifyKbSyncLogPageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<AiAssistantDifyKbSyncLogDO>()
|
||||
.eqIfPresent(AiAssistantDifyKbSyncLogDO::getModuleCode, reqVO.getModuleCode())
|
||||
.orderByDesc(AiAssistantDifyKbSyncLogDO::getId));
|
||||
}
|
||||
|
||||
default AiAssistantDifyKbSyncLogDO selectLatestByModuleCode(String moduleCode) {
|
||||
return selectOne(new LambdaQueryWrapperX<AiAssistantDifyKbSyncLogDO>()
|
||||
.eq(AiAssistantDifyKbSyncLogDO::getModuleCode, moduleCode)
|
||||
.orderByDesc(AiAssistantDifyKbSyncLogDO::getId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.dify;
|
||||
|
||||
import cn.iocoder.yudao.module.ydoyun.dify.dto.DifyDocumentListDTO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dify.dto.DifyDocumentSegmentTextDTO;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Dify 知识库(Dataset)HTTP API,与 Chat 应用使用不同的 API Key。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DifyKnowledgeApiClient {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${ydoyun.dify.knowledge.base-url:http://127.0.0.1:8888/v1}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${ydoyun.dify.knowledge.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* 新建知识库可见范围:only_me 时通常仅 API 密钥对应身份在控制台可见,其他已登录成员可能 403;
|
||||
* all_team_members 表示同工作空间成员可在网页端查看。
|
||||
*/
|
||||
@Value("${ydoyun.dify.knowledge.dataset-permission:all_team_members}")
|
||||
private String datasetPermission;
|
||||
|
||||
/**
|
||||
* 是否在日志中输出每次调用的 URL、请求体(大文本会省略)、响应摘要,便于排查「控制台找不到知识库」等问题。
|
||||
*/
|
||||
@Value("${ydoyun.dify.knowledge.log-http-detail:true}")
|
||||
private boolean logHttpDetail;
|
||||
|
||||
public DifyKnowledgeApiClient() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(10_000);
|
||||
factory.setReadTimeout(120_000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
public String createDataset(String name, String description) {
|
||||
String url = trimBase() + "/datasets";
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", name);
|
||||
body.put("permission", resolveDatasetPermission());
|
||||
body.put("indexing_technique", "high_quality");
|
||||
if (StringUtils.isNotBlank(description)) {
|
||||
body.put("description", description);
|
||||
}
|
||||
String json = postJson(url, body);
|
||||
try {
|
||||
JsonNode root = OBJECT_MAPPER.readTree(json.getBytes(StandardCharsets.UTF_8));
|
||||
String id = textOrNull(root, "id");
|
||||
if (StringUtils.isBlank(id)) {
|
||||
throw new IllegalStateException("Dify 创建数据集响应无 id: " + json);
|
||||
}
|
||||
log.info("[DifyKnowledge] 创建数据集成功 id={} name={}", id, name);
|
||||
return id;
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("解析 Dify 创建数据集响应失败: " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建知识库;若 Dify 返回名称重复(409),则按名称在列表中查找并复用已有数据集的 id(常见于本地未绑定 id 但 Dify 侧已存在同名库)。
|
||||
*/
|
||||
public String createDatasetOrReuseByName(String name, String description) {
|
||||
try {
|
||||
return createDataset(name, description);
|
||||
} catch (IllegalStateException e) {
|
||||
if (!isDatasetNameConflict(e)) {
|
||||
throw e;
|
||||
}
|
||||
log.warn("[DifyKnowledge] 创建知识库返回名称重复(409),尝试按名称检索并复用 name={}", name);
|
||||
String id = findDatasetIdByExactName(name);
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
log.info("[DifyKnowledge] 已复用同名知识库 id={} name={}", id, name);
|
||||
return id;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Dify 提示知识库名称已存在,但列表接口未检索到同名知识库。请登录 Dify 控制台处理同名冲突,或稍后重试。"
|
||||
+ " 原始错误: " + e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 GET /datasets 列表中按「名称完全一致」查找知识库 id(keyword 检索 + 少量分页兜底)。
|
||||
*/
|
||||
public String findDatasetIdByExactName(String exactName) {
|
||||
if (StringUtils.isBlank(exactName)) {
|
||||
return null;
|
||||
}
|
||||
String byKeyword = findDatasetIdInListPages(exactName, true);
|
||||
if (StringUtils.isNotBlank(byKeyword)) {
|
||||
return byKeyword;
|
||||
}
|
||||
return findDatasetIdInListPages(exactName, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param useKeyword true 时带 keyword=exactName;false 时不带 keyword,仅分页扫描(兜底)。
|
||||
*/
|
||||
private String findDatasetIdInListPages(String exactName, boolean useKeyword) {
|
||||
int page = 1;
|
||||
final int limit = 50;
|
||||
final int maxPages = 30;
|
||||
while (page <= maxPages) {
|
||||
// 使用 URLEncoder:Spring 6 UriComponentsBuilder 对 keyword 中含中文等字符可能抛出 QUERY_PARAM 非法字符异常
|
||||
StringBuilder urlSb = new StringBuilder(trimBase()).append("/datasets?page=").append(page)
|
||||
.append("&limit=").append(limit);
|
||||
if (useKeyword) {
|
||||
try {
|
||||
urlSb.append("&keyword=").append(URLEncoder.encode(exactName, StandardCharsets.UTF_8.name()));
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
String url = urlSb.toString();
|
||||
String json = getJson(url);
|
||||
try {
|
||||
JsonNode root = OBJECT_MAPPER.readTree(json.getBytes(StandardCharsets.UTF_8));
|
||||
JsonNode data = root.get("data");
|
||||
if (data != null && data.isArray()) {
|
||||
for (JsonNode item : data) {
|
||||
String n = textOrNull(item, "name");
|
||||
if (exactName.equals(n)) {
|
||||
return textOrNull(item, "id");
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean hasMore = root.path("has_more").asBoolean(false);
|
||||
if (!hasMore) {
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[DifyKnowledge] 解析列表响应失败: {}", e.getMessage());
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void deleteNoContent(String url) {
|
||||
String token = normalizeApiKey(apiKey);
|
||||
assertDatasetApiKeyConfigured(token);
|
||||
try {
|
||||
if (logHttpDetail) {
|
||||
log.info("[DifyKnowledge] 请求 DELETE {}", url);
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(token);
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.DELETE, entity, String.class);
|
||||
if (!resp.getStatusCode().is2xxSuccessful()) {
|
||||
throw new IllegalStateException("Dify HTTP " + resp.getStatusCode());
|
||||
}
|
||||
if (logHttpDetail) {
|
||||
log.info("[DifyKnowledge] 响应 DELETE {} status={}", url, resp.getStatusCode());
|
||||
}
|
||||
} catch (HttpStatusCodeException e) {
|
||||
String b = e.getResponseBodyAsString();
|
||||
log.error("[DifyKnowledge] DELETE 失败 status={}, url={}, body={}", e.getStatusCode(), url, b);
|
||||
throw new IllegalStateException(
|
||||
"Dify DELETE 失败 " + e.getStatusCode() + ": " + StringUtils.defaultString(b), e);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[DifyKnowledge] DELETE 异常 url={}", url, e);
|
||||
throw new IllegalStateException("Dify DELETE 异常: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private DifyDocumentListDTO parseDocumentList(String json, int page, int limit) {
|
||||
DifyDocumentListDTO dto = new DifyDocumentListDTO();
|
||||
dto.setPage(page);
|
||||
dto.setLimit(limit);
|
||||
try {
|
||||
JsonNode root = OBJECT_MAPPER.readTree(json.getBytes(StandardCharsets.UTF_8));
|
||||
dto.setTotal(root.path("total").asLong(0));
|
||||
dto.setHasMore(root.path("has_more").asBoolean(false));
|
||||
JsonNode data = root.get("data");
|
||||
if (data != null && data.isArray()) {
|
||||
for (JsonNode item : data) {
|
||||
DifyDocumentListDTO.Item it = new DifyDocumentListDTO.Item();
|
||||
it.setId(textOrNull(item, "id"));
|
||||
it.setName(textOrNull(item, "name"));
|
||||
it.setIndexingStatus(textOrNull(item, "indexing_status"));
|
||||
it.setDisplayStatus(textOrNull(item, "display_status"));
|
||||
JsonNode ca = item.get("created_at");
|
||||
if (ca != null && ca.isNumber()) {
|
||||
it.setCreatedAt(ca.asLong());
|
||||
}
|
||||
dto.getData().add(it);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("解析 Dify 文档列表失败: " + truncateForLog(json, 800), e);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String getJson(String url) {
|
||||
String token = normalizeApiKey(apiKey);
|
||||
assertDatasetApiKeyConfigured(token);
|
||||
try {
|
||||
if (logHttpDetail) {
|
||||
log.info("[DifyKnowledge] 请求 GET {}", url);
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(token);
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
||||
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
|
||||
throw new IllegalStateException("Dify HTTP " + resp.getStatusCode());
|
||||
}
|
||||
if (logHttpDetail) {
|
||||
log.info("[DifyKnowledge] 响应 GET {} body={}", url, truncateForLog(resp.getBody(), 2500));
|
||||
}
|
||||
return resp.getBody();
|
||||
} catch (HttpStatusCodeException e) {
|
||||
String b = e.getResponseBodyAsString();
|
||||
log.error("[DifyKnowledge] GET 失败 status={}, url={}, body={}", e.getStatusCode(), url, b);
|
||||
throw new IllegalStateException(
|
||||
"Dify GET 失败 " + e.getStatusCode() + ": " + StringUtils.defaultString(b), e);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[DifyKnowledge] GET 异常 url={}", url, e);
|
||||
throw new IllegalStateException("Dify GET 异常: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDatasetNameConflict(IllegalStateException e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.contains("dataset_name_duplicate")) {
|
||||
return true;
|
||||
}
|
||||
Throwable c = e.getCause();
|
||||
while (c != null) {
|
||||
if (c instanceof HttpStatusCodeException) {
|
||||
HttpStatusCodeException h = (HttpStatusCodeException) c;
|
||||
if (h.getStatusCode().value() == 409) {
|
||||
String b = h.getResponseBodyAsString();
|
||||
return b != null && b.contains("dataset_name_duplicate");
|
||||
}
|
||||
}
|
||||
c = c.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断知识库在 Dify 侧是否仍存在(GET /datasets/{id})。
|
||||
* 404 视为不存在(可能已在控制台删除);其它非 2xx 仍抛异常。
|
||||
*/
|
||||
public boolean datasetExists(String datasetId) {
|
||||
if (StringUtils.isBlank(datasetId)) {
|
||||
return false;
|
||||
}
|
||||
String url = trimBase() + "/datasets/" + datasetId.trim();
|
||||
String token = normalizeApiKey(apiKey);
|
||||
assertDatasetApiKeyConfigured(token);
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(token);
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
||||
boolean ok = resp.getStatusCode().is2xxSuccessful() && resp.getBody() != null;
|
||||
if (ok) {
|
||||
if (logHttpDetail) {
|
||||
log.info("[DifyKnowledge] 知识库存在 datasetId={} detail={}", datasetId,
|
||||
truncateForLog(resp.getBody(), 1200));
|
||||
} else {
|
||||
log.info("[DifyKnowledge] 知识库存在 datasetId={}", datasetId);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
} catch (HttpStatusCodeException e) {
|
||||
if (e.getStatusCode().value() == 404) {
|
||||
log.info("[DifyKnowledge] 知识库不存在或已删除(404) datasetId={}", datasetId);
|
||||
return false;
|
||||
}
|
||||
String b = e.getResponseBodyAsString();
|
||||
log.error("[DifyKnowledge] 判断知识库是否存在失败 status={}, url={}, body={}", e.getStatusCode(), url, b);
|
||||
throw new IllegalStateException(
|
||||
"Dify GET dataset 失败 " + e.getStatusCode() + ": " + StringUtils.defaultString(b), e);
|
||||
} catch (Exception e) {
|
||||
log.error("[DifyKnowledge] 判断知识库是否存在异常 url={}", url, e);
|
||||
throw new IllegalStateException("Dify GET dataset 异常: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已有数据集中按文本创建文档(异步索引,返回 document.id)
|
||||
*/
|
||||
public String createDocumentByText(String datasetId, String documentName, String text) {
|
||||
Map<String, Object> body = buildTextDocumentBody(documentName, text);
|
||||
String hyphen = trimBase() + "/datasets/" + datasetId + "/document/create-by-text";
|
||||
String underscore = trimBase() + "/datasets/" + datasetId + "/document/create_by_text";
|
||||
String json = postJsonFirstWorking(hyphen, underscore, body);
|
||||
String docId = parseDocumentId(json);
|
||||
log.info("[DifyKnowledge] 创建文档成功 datasetId={} documentId={}", datasetId, docId);
|
||||
return docId;
|
||||
}
|
||||
|
||||
public void updateDocumentByText(String datasetId, String documentId, String documentName, String text) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", documentName);
|
||||
body.put("text", text);
|
||||
String hyphen = trimBase() + "/datasets/" + datasetId + "/documents/" + documentId + "/update-by-text";
|
||||
String underscore = trimBase() + "/datasets/" + datasetId + "/documents/" + documentId + "/update_by_text";
|
||||
String json = postJsonFirstWorking(hyphen, underscore, body);
|
||||
log.info("[DifyKnowledge] 更新文档成功 datasetId={} documentId={}", datasetId, documentId);
|
||||
log.debug("[DifyKnowledge] update doc response: {}", json);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页列出知识库内文档(GET /datasets/{dataset_id}/documents)。
|
||||
*/
|
||||
public DifyDocumentListDTO listDocuments(String datasetId, int page, int limit) {
|
||||
if (StringUtils.isBlank(datasetId)) {
|
||||
throw new IllegalStateException("datasetId 为空");
|
||||
}
|
||||
String url = trimBase() + "/datasets/" + datasetId.trim() + "/documents?page=" + page + "&limit=" + limit;
|
||||
String json = getJson(url);
|
||||
return parseDocumentList(json, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页拉取文档分段(GET /datasets/{dataset_id}/documents/{document_id}/segments),拼接正文用于预览。
|
||||
*
|
||||
* @param maxChars 正文最大字符数,超出则截断并标记 truncated
|
||||
*/
|
||||
public DifyDocumentSegmentTextDTO aggregateDocumentTextBySegments(String datasetId, String documentId, int maxChars) {
|
||||
if (StringUtils.isBlank(datasetId) || StringUtils.isBlank(documentId)) {
|
||||
throw new IllegalStateException("datasetId 或 documentId 为空");
|
||||
}
|
||||
if (maxChars < 1) {
|
||||
maxChars = 1;
|
||||
}
|
||||
String base = trimBase() + "/datasets/" + datasetId.trim() + "/documents/" + documentId.trim() + "/segments";
|
||||
StringBuilder out = new StringBuilder();
|
||||
int[] pieceCount = new int[1];
|
||||
boolean[] truncated = new boolean[1];
|
||||
int page = 1;
|
||||
final int limit = 100;
|
||||
final int maxPages = 200;
|
||||
while (page <= maxPages && !truncated[0]) {
|
||||
String url = base + "?page=" + page + "&limit=" + limit;
|
||||
String json = getJson(url);
|
||||
try {
|
||||
JsonNode root = OBJECT_MAPPER.readTree(json.getBytes(StandardCharsets.UTF_8));
|
||||
JsonNode data = root.get("data");
|
||||
if (data != null && data.isArray()) {
|
||||
for (JsonNode seg : data) {
|
||||
appendSegmentTree(out, seg, maxChars, truncated, pieceCount);
|
||||
if (truncated[0]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (truncated[0]) {
|
||||
break;
|
||||
}
|
||||
boolean hasMore = root.path("has_more").asBoolean(false);
|
||||
if (!hasMore) {
|
||||
break;
|
||||
}
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("解析 Dify 分段列表失败: " + truncateForLog(json, 600), e);
|
||||
}
|
||||
page++;
|
||||
}
|
||||
return DifyDocumentSegmentTextDTO.builder()
|
||||
.text(out.toString())
|
||||
.truncated(truncated[0])
|
||||
.segmentPieces(pieceCount[0])
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分段节点及其 child_chunks 中的文本拼入 buffer。
|
||||
*/
|
||||
private void appendSegmentTree(StringBuilder out, JsonNode node, int maxChars, boolean[] truncated, int[] pieceCount) {
|
||||
if (node == null || node.isNull()) {
|
||||
return;
|
||||
}
|
||||
pieceCount[0]++;
|
||||
String c = textOrNull(node, "content");
|
||||
if (StringUtils.isNotBlank(c)) {
|
||||
appendTextWithLimit(out, c, maxChars, truncated);
|
||||
}
|
||||
JsonNode children = node.get("child_chunks");
|
||||
if (children != null && children.isArray()) {
|
||||
for (JsonNode ch : children) {
|
||||
appendSegmentTree(out, ch, maxChars, truncated, pieceCount);
|
||||
if (truncated[0]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendTextWithLimit(StringBuilder out, String c, int maxChars, boolean[] truncated) {
|
||||
if (truncated[0]) {
|
||||
return;
|
||||
}
|
||||
if (out.length() > 0) {
|
||||
out.append("\n\n");
|
||||
}
|
||||
int room = maxChars - out.length();
|
||||
if (room <= 0) {
|
||||
truncated[0] = true;
|
||||
return;
|
||||
}
|
||||
if (c.length() <= room) {
|
||||
out.append(c);
|
||||
} else {
|
||||
out.append(c, 0, room);
|
||||
truncated[0] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除整个知识库(DELETE /datasets/{dataset_id})。
|
||||
*/
|
||||
public void deleteDataset(String datasetId) {
|
||||
if (StringUtils.isBlank(datasetId)) {
|
||||
throw new IllegalStateException("datasetId 为空");
|
||||
}
|
||||
deleteNoContent(trimBase() + "/datasets/" + datasetId.trim());
|
||||
log.info("[DifyKnowledge] 已删除知识库 datasetId={}", datasetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除知识库内单个文档(DELETE /datasets/{dataset_id}/documents/{document_id})。
|
||||
*/
|
||||
public void deleteDocument(String datasetId, String documentId) {
|
||||
if (StringUtils.isBlank(datasetId) || StringUtils.isBlank(documentId)) {
|
||||
throw new IllegalStateException("datasetId 或 documentId 为空");
|
||||
}
|
||||
String url = trimBase() + "/datasets/" + datasetId.trim() + "/documents/" + documentId.trim();
|
||||
deleteNoContent(url);
|
||||
log.info("[DifyKnowledge] 已删除文档 datasetId={} documentId={}", datasetId, documentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询知识库详情(与创建、写文档使用同一 Service API 根地址与 dataset 密钥)。
|
||||
* 用于同步后校验:若此处能查到而网页控制台看不到,多为未登录同一工作空间或 console 地址与部署不一致。
|
||||
*/
|
||||
public String getDatasetDetail(String datasetId) {
|
||||
if (StringUtils.isBlank(datasetId)) {
|
||||
throw new IllegalStateException("datasetId 为空");
|
||||
}
|
||||
String url = trimBase() + "/datasets/" + datasetId.trim();
|
||||
String token = normalizeApiKey(apiKey);
|
||||
assertDatasetApiKeyConfigured(token);
|
||||
log.info("[DifyKnowledge] 请求 GET {} (反查知识库详情)", url);
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(token);
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
||||
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
|
||||
throw new IllegalStateException("Dify HTTP " + resp.getStatusCode());
|
||||
}
|
||||
log.info("[DifyKnowledge] 响应 GET {} body={}", url, truncateForLog(resp.getBody(), 2500));
|
||||
return resp.getBody();
|
||||
} catch (HttpStatusCodeException e) {
|
||||
String b = e.getResponseBodyAsString();
|
||||
log.error("[DifyKnowledge] GET dataset 失败 status={}, url={}, body={}", e.getStatusCode(), url, b);
|
||||
throw new IllegalStateException(
|
||||
"Dify GET dataset 失败 " + e.getStatusCode() + ": " + StringUtils.defaultString(b), e);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[DifyKnowledge] GET dataset 异常 url={}", url, e);
|
||||
throw new IllegalStateException("Dify GET dataset 异常: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildTextDocumentBody(String documentName, String text) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", documentName);
|
||||
body.put("text", text);
|
||||
body.put("indexing_technique", "high_quality");
|
||||
Map<String, Object> processRule = new HashMap<>();
|
||||
processRule.put("mode", "automatic");
|
||||
body.put("process_rule", processRule);
|
||||
return body;
|
||||
}
|
||||
|
||||
private String postJsonFirstWorking(String urlPrimary, String urlFallback, Map<String, Object> body) {
|
||||
try {
|
||||
return postJson(urlPrimary, body);
|
||||
} catch (IllegalStateException ex) {
|
||||
if (!urlPrimary.equals(urlFallback) && isNotFound(ex)) {
|
||||
log.warn("[DifyKnowledge] 主路径 404,尝试备用: {} -> {}", urlPrimary, urlFallback);
|
||||
return postJson(urlFallback, body);
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isNotFound(IllegalStateException ex) {
|
||||
if (ex.getMessage() != null && ex.getMessage().contains("404")) {
|
||||
return true;
|
||||
}
|
||||
Throwable c = ex.getCause();
|
||||
return c instanceof HttpStatusCodeException
|
||||
&& ((HttpStatusCodeException) c).getStatusCode().value() == 404;
|
||||
}
|
||||
|
||||
private String postJson(String url, Map<String, Object> body) {
|
||||
String token = normalizeApiKey(apiKey);
|
||||
assertDatasetApiKeyConfigured(token);
|
||||
try {
|
||||
if (logHttpDetail) {
|
||||
log.info("[DifyKnowledge] 请求 POST {} body={}", url, bodyForLog(body));
|
||||
}
|
||||
String jsonBody = OBJECT_MAPPER.writeValueAsString(body);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setBearerAuth(token);
|
||||
HttpEntity<String> entity = new HttpEntity<>(jsonBody, headers);
|
||||
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
|
||||
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
|
||||
throw new IllegalStateException("Dify HTTP " + resp.getStatusCode() + ": " + resp.getBody());
|
||||
}
|
||||
if (logHttpDetail) {
|
||||
log.info("[DifyKnowledge] 响应 POST {} body={}", url, truncateForLog(resp.getBody(), 2500));
|
||||
}
|
||||
return resp.getBody();
|
||||
} catch (HttpStatusCodeException e) {
|
||||
String b = e.getResponseBodyAsString();
|
||||
if (e.getStatusCode().value() == 409 && b != null && b.contains("dataset_name_duplicate")) {
|
||||
log.warn("[DifyKnowledge] 请求冲突(409) 知识库名称已存在 url={}, body={}", url, b);
|
||||
} else {
|
||||
log.error("[DifyKnowledge] 请求失败 status={}, url={}, body={}", e.getStatusCode(), url, b);
|
||||
}
|
||||
String extra = "";
|
||||
if (e.getStatusCode().value() == 401) {
|
||||
extra = " 说明:须使用「知识库 API」文档中的 Service 根地址 + /v1(与控制台文档 curl 一致,常见为 Web 端口如 :8888,而非对话 API 的 :5001);"
|
||||
+ "Authorization 须为「知识库 → API」中的 dataset- 密钥,勿用 app- 密钥。";
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Dify 请求失败 " + e.getStatusCode() + ": " + StringUtils.defaultString(b) + extra, e);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[DifyKnowledge] 请求异常 url={}", url, e);
|
||||
throw new IllegalStateException("Dify 请求异常: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉首尾空白;若用户整段粘贴了 “Bearer xxx”,只保留 token。
|
||||
*/
|
||||
private static String normalizeApiKey(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String s = raw.trim();
|
||||
if (s.length() > 7 && s.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
s = s.substring(7).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static void assertDatasetApiKeyConfigured(String token) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
throw new IllegalStateException("未配置 ydoyun.dify.knowledge.api-key(请在配置中填写 Dify 知识库 API 密钥)");
|
||||
}
|
||||
if ("dataset-your-knowledge-api-key".equals(token)) {
|
||||
throw new IllegalStateException(
|
||||
"ydoyun.dify.knowledge.api-key 仍为占位符,请在 application.yaml / application-local.yaml 中改为 Dify 知识库「API」页生成的真实密钥");
|
||||
}
|
||||
if (token.regionMatches(true, 0, "app-", 0, 4)) {
|
||||
throw new IllegalStateException(
|
||||
"当前 api-key 为对话应用密钥(app- 开头),无法调用知识库接口。请改用「知识库 → API」中的 dataset- 密钥。");
|
||||
}
|
||||
}
|
||||
|
||||
private static String parseDocumentId(String json) {
|
||||
try {
|
||||
JsonNode root = OBJECT_MAPPER.readTree(json.getBytes(StandardCharsets.UTF_8));
|
||||
JsonNode doc = root.get("document");
|
||||
if (doc != null && !doc.isNull()) {
|
||||
String id = textOrNull(doc, "id");
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
String id = textOrNull(root, "id");
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
return id;
|
||||
}
|
||||
throw new IllegalStateException("Dify 创建文档响应无 document.id: " + json);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("解析 Dify 创建文档响应失败: " + json, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String bodyForLog(Map<String, Object> body) {
|
||||
if (body == null || body.isEmpty()) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
Map<String, Object> copy = new LinkedHashMap<>(body);
|
||||
Object text = copy.get("text");
|
||||
if (text instanceof String && ((String) text).length() > 400) {
|
||||
copy.put("text", "[omitted " + ((String) text).length() + " chars]");
|
||||
}
|
||||
return OBJECT_MAPPER.writeValueAsString(copy);
|
||||
} catch (Exception e) {
|
||||
return "(无法序列化请求体: " + e.getMessage() + ")";
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncateForLog(String s, int maxLen) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
if (s.length() <= maxLen) {
|
||||
return s;
|
||||
}
|
||||
return s.substring(0, maxLen) + "...(truncated " + (s.length() - maxLen) + " chars)";
|
||||
}
|
||||
|
||||
private static String textOrNull(JsonNode node, String field) {
|
||||
if (node == null || !node.has(field) || node.get(field).isNull()) {
|
||||
return null;
|
||||
}
|
||||
return node.get(field).asText(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service API 根路径,如 http://host:8888/v1。去掉末尾斜杠,并修正文档示例里 host 后的双斜杠(8888//v1)。
|
||||
*/
|
||||
private String trimBase() {
|
||||
String s = StringUtils.trimToEmpty(baseUrl).replaceAll("/+$", "");
|
||||
return s.replaceFirst("(https?://[^/]+)//+", "$1/");
|
||||
}
|
||||
|
||||
private String resolveDatasetPermission() {
|
||||
String p = StringUtils.trimToEmpty(datasetPermission);
|
||||
if ("only_me".equals(p) || "all_team_members".equals(p) || "partial_members".equals(p)) {
|
||||
return p;
|
||||
}
|
||||
if (StringUtils.isNotBlank(p)) {
|
||||
log.warn("[DifyKnowledge] 未知 dataset-permission: {},回退为 all_team_members", p);
|
||||
}
|
||||
return "all_team_members";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.dify.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Dify GET /datasets/{id}/documents 分页结果(字段随 Dify 版本可能略有差异,以解析为准)。
|
||||
*/
|
||||
@Data
|
||||
public class DifyDocumentListDTO {
|
||||
|
||||
private List<Item> data = new ArrayList<>();
|
||||
private long total;
|
||||
private int page;
|
||||
private int limit;
|
||||
private boolean hasMore;
|
||||
|
||||
@Data
|
||||
public static class Item {
|
||||
private String id;
|
||||
private String name;
|
||||
/** 秒级时间戳 */
|
||||
private Long createdAt;
|
||||
/** 索引/展示状态 */
|
||||
private String indexingStatus;
|
||||
private String displayStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.dify.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 由 Dify 文档分段(segments)拼接的正文预览。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DifyDocumentSegmentTextDTO {
|
||||
|
||||
private String text;
|
||||
/** 是否因长度上限被截断 */
|
||||
private boolean truncated;
|
||||
/** 已遍历的分段条数(含子块) */
|
||||
private int segmentPieces;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.service.aiassistantdifykb;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbInfoRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncLogPageReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncLogRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncRespVO;
|
||||
|
||||
/**
|
||||
* 每日汇报:租户 × 模块 对应 Dify 知识库聚合同步
|
||||
*/
|
||||
public interface AiAssistantDifyKbService {
|
||||
|
||||
DifyKbSyncRespVO syncToDify(DifyKbSyncReqVO reqVO);
|
||||
|
||||
DifyKbInfoRespVO getKbInfo(String moduleCode);
|
||||
|
||||
PageResult<DifyKbSyncLogRespVO> getSyncLogPage(DifyKbSyncLogPageReqVO reqVO);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.service.aiassistantdifykb;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbInfoRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncLogPageReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncLogRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.aiassistantreport.vo.DifyKbSyncRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantdifykb.AiAssistantDifyKbDO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantdifykb.AiAssistantDifyKbSyncLogDO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantreport.AiAssistantReportDO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantreport.AiAssistantReportDetailDO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantdifykb.AiAssistantDifyKbMapper;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantdifykb.AiAssistantDifyKbSyncLogMapper;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantreport.AiAssistantReportDetailMapper;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantreport.AiAssistantReportMapper;
|
||||
import cn.iocoder.yudao.module.ydoyun.dify.DifyKnowledgeApiClient;
|
||||
import cn.iocoder.yudao.module.system.dal.dataobject.tenant.TenantDO;
|
||||
import cn.iocoder.yudao.module.system.service.tenant.TenantService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
|
||||
|
||||
/**
|
||||
* 将当前租户、某 moduleCode 下全部店长汇报聚合为一份文本,写入 Dify 数据集(单文档全量覆盖)。
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class AiAssistantDifyKbServiceImpl implements AiAssistantDifyKbService {
|
||||
|
||||
private static final int MAX_TEXT_CHARS = 800_000;
|
||||
private static final String SYNC_OK = "success";
|
||||
private static final String SYNC_FAIL = "fail";
|
||||
|
||||
@Resource
|
||||
private AiAssistantReportMapper aiAssistantReportMapper;
|
||||
@Resource
|
||||
private AiAssistantReportDetailMapper aiAssistantReportDetailMapper;
|
||||
@Resource
|
||||
private AiAssistantDifyKbMapper aiAssistantDifyKbMapper;
|
||||
@Resource
|
||||
private AiAssistantDifyKbSyncLogMapper aiAssistantDifyKbSyncLogMapper;
|
||||
@Resource
|
||||
private DifyKnowledgeApiClient difyKnowledgeApiClient;
|
||||
@Resource
|
||||
private TenantService tenantService;
|
||||
@Resource
|
||||
private DifyKbSyncRedisLock difyKbSyncRedisLock;
|
||||
|
||||
@Value("${ydoyun.dify.knowledge.console-base-url:}")
|
||||
private String consoleBaseUrl;
|
||||
|
||||
@Value("${ydoyun.dify.knowledge.base-url:http://127.0.0.1:8888/v1}")
|
||||
private String difyKnowledgeBaseUrl;
|
||||
|
||||
/**
|
||||
* 同步完成后是否用 GET /datasets/{id} 再查一次(与网页控制台无关,仅验证 Service API 与密钥是否指向同一套数据)。
|
||||
*/
|
||||
@Value("${ydoyun.dify.knowledge.verify-after-sync:true}")
|
||||
private boolean verifyDatasetAfterSync;
|
||||
|
||||
@Override
|
||||
public DifyKbSyncRespVO syncToDify(DifyKbSyncReqVO reqVO) {
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (loginUserId == null) {
|
||||
throw exception(BAD_REQUEST);
|
||||
}
|
||||
AiAssistantReportDO report = aiAssistantReportMapper.selectById(reqVO.getReportId());
|
||||
if (report == null) {
|
||||
throw exception(BAD_REQUEST);
|
||||
}
|
||||
if (!loginUserId.equals(report.getReporterId())) {
|
||||
throw exception(FORBIDDEN);
|
||||
}
|
||||
String moduleCode = StringUtils.isNotBlank(reqVO.getModuleCode()) ? reqVO.getModuleCode() : report.getModuleCode();
|
||||
if (StringUtils.isBlank(moduleCode)) {
|
||||
throw invalidParamException("moduleCode 不能为空");
|
||||
}
|
||||
if (StringUtils.isNotBlank(reqVO.getModuleCode()) && !moduleCode.equals(report.getModuleCode())) {
|
||||
throw invalidParamException("moduleCode 与汇报记录不一致");
|
||||
}
|
||||
|
||||
String moduleName = StringUtils.isNotBlank(reqVO.getModuleName())
|
||||
? reqVO.getModuleName()
|
||||
: StringUtils.defaultString(report.getModuleName());
|
||||
|
||||
List<AiAssistantReportDO> all = aiAssistantReportMapper.selectListByModuleCodeAllReporters(moduleCode);
|
||||
if (all.isEmpty()) {
|
||||
throw invalidParamException("该模块下没有可同步的汇报数据");
|
||||
}
|
||||
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
String tenantName = resolveTenantName();
|
||||
Long menuId = reqVO.getMenuId();
|
||||
String canonicalDatasetName = truncateDatasetName(
|
||||
buildTenantModuleKbName(tenantName, tenantId, moduleName, menuId));
|
||||
log.info("[DifyKb] 同步开始 tenantId={} tenantName={} moduleCode={} moduleName={} menuId={} canonicalKbName={} serviceApiBase={} consoleBaseUrlConfigured={}",
|
||||
tenantId,
|
||||
tenantName,
|
||||
moduleCode,
|
||||
moduleName,
|
||||
menuId,
|
||||
canonicalDatasetName,
|
||||
trimTrailingSlash(difyKnowledgeBaseUrl),
|
||||
StringUtils.isNotBlank(consoleBaseUrl));
|
||||
return difyKbSyncRedisLock.runWithLock(tenantId, moduleCode,
|
||||
() -> doSyncToDify(report, moduleCode, moduleName, tenantName, canonicalDatasetName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在「租户+moduleCode」分布式锁内执行:重新拉取汇报、聚合文本、读写 Dify,避免并发重复创建/覆盖。
|
||||
*/
|
||||
private DifyKbSyncRespVO doSyncToDify(AiAssistantReportDO report,
|
||||
String moduleCode, String moduleName,
|
||||
String tenantName, String canonicalDatasetName) {
|
||||
List<AiAssistantReportDO> allLocked = aiAssistantReportMapper.selectListByModuleCodeAllReporters(moduleCode);
|
||||
if (allLocked.isEmpty()) {
|
||||
throw invalidParamException("该模块下没有可同步的汇报数据");
|
||||
}
|
||||
String aggregateText = buildAggregateText(allLocked);
|
||||
if (aggregateText.length() > MAX_TEXT_CHARS) {
|
||||
log.warn("[DifyKb] 聚合文本过长 {},截断至 {}", aggregateText.length(), MAX_TEXT_CHARS);
|
||||
aggregateText = aggregateText.substring(0, MAX_TEXT_CHARS);
|
||||
}
|
||||
|
||||
AiAssistantDifyKbDO kb = aiAssistantDifyKbMapper.selectByModuleCode(moduleCode);
|
||||
/** 同步前在 Dify 侧已确认知识库存在(仅更新文档,非本次新建) */
|
||||
boolean datasetKnownExistingOnDify = false;
|
||||
if (kb != null && StringUtils.isNotBlank(kb.getDatasetId())) {
|
||||
if (difyKnowledgeApiClient.datasetExists(kb.getDatasetId())) {
|
||||
datasetKnownExistingOnDify = true;
|
||||
} else {
|
||||
log.warn("[DifyKb] 本地绑定的知识库在 Dify 侧已不存在或已删除,将新建知识库并重建文档 moduleCode={} oldDatasetId={}",
|
||||
moduleCode, kb.getDatasetId());
|
||||
kb.setDatasetId(null);
|
||||
kb.setAggregateDocumentId(null);
|
||||
}
|
||||
}
|
||||
|
||||
String datasetId;
|
||||
String datasetName;
|
||||
try {
|
||||
if (kb == null || StringUtils.isBlank(kb.getDatasetId())) {
|
||||
datasetName = canonicalDatasetName;
|
||||
String desc = "租户「" + StringUtils.defaultString(tenantName) + "」页面「"
|
||||
+ StringUtils.defaultString(moduleName) + "」全部店长历史汇报(系统自动同步)";
|
||||
datasetId = difyKnowledgeApiClient.createDatasetOrReuseByName(datasetName, desc);
|
||||
if (kb == null) {
|
||||
kb = AiAssistantDifyKbDO.builder()
|
||||
.moduleCode(moduleCode)
|
||||
.moduleName(moduleName)
|
||||
.datasetId(datasetId)
|
||||
.datasetName(datasetName)
|
||||
.aggregateDocumentId(null)
|
||||
.build();
|
||||
aiAssistantDifyKbMapper.insert(kb);
|
||||
} else {
|
||||
kb.setDatasetId(datasetId);
|
||||
kb.setDatasetName(datasetName);
|
||||
kb.setModuleName(moduleName);
|
||||
aiAssistantDifyKbMapper.updateById(kb);
|
||||
}
|
||||
} else {
|
||||
datasetId = kb.getDatasetId();
|
||||
datasetName = canonicalDatasetName;
|
||||
kb.setDatasetName(canonicalDatasetName);
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(kb.getAggregateDocumentId())) {
|
||||
String docId = difyKnowledgeApiClient.createDocumentByText(datasetId, canonicalDatasetName, aggregateText);
|
||||
kb.setAggregateDocumentId(docId);
|
||||
kb.setModuleName(moduleName);
|
||||
aiAssistantDifyKbMapper.updateById(kb);
|
||||
} else {
|
||||
try {
|
||||
difyKnowledgeApiClient.updateDocumentByText(datasetId, kb.getAggregateDocumentId(), canonicalDatasetName, aggregateText);
|
||||
} catch (Exception docEx) {
|
||||
if (isDifyNotFound(docEx)) {
|
||||
log.warn("[DifyKb] 聚合文档在 Dify 侧已不存在,将按新建文档写入 datasetId={} oldDocumentId={}",
|
||||
datasetId, kb.getAggregateDocumentId());
|
||||
String docId = difyKnowledgeApiClient.createDocumentByText(datasetId, canonicalDatasetName, aggregateText);
|
||||
kb.setAggregateDocumentId(docId);
|
||||
} else {
|
||||
throw docEx;
|
||||
}
|
||||
}
|
||||
kb.setModuleName(moduleName);
|
||||
aiAssistantDifyKbMapper.updateById(kb);
|
||||
}
|
||||
|
||||
if (verifyDatasetAfterSync && StringUtils.isNotBlank(datasetId) && !datasetKnownExistingOnDify) {
|
||||
try {
|
||||
difyKnowledgeApiClient.getDatasetDetail(datasetId);
|
||||
log.info("[DifyKb] Service API 校验通过:GET /datasets/{} 可返回知识库详情(与浏览器控制台是否可见无关,控制台需同一工作空间账号)", datasetId);
|
||||
} catch (Exception verifyEx) {
|
||||
log.warn("[DifyKb] Service API 校验失败 datasetId={} msg={}。"
|
||||
+ " 若此处失败,请核对 ydoyun.dify.knowledge.base-url 与 Dify「知识库 API」文档中的 Service 根地址是否一致、api-key 是否为 dataset- 密钥;"
|
||||
+ " 若此处成功但网页里看不到,请确认已登录与密钥相同的工作空间,并检查 console-base-url。",
|
||||
datasetId, verifyEx.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Long logId = appendSyncLog(moduleCode, moduleName, report.getId(), datasetId, SYNC_OK, "同步成功");
|
||||
String docId = kb.getAggregateDocumentId();
|
||||
String url = buildConsoleDocumentUrl(datasetId, docId);
|
||||
return DifyKbSyncRespVO.builder()
|
||||
.datasetId(datasetId)
|
||||
.documentId(docId)
|
||||
.datasetName(datasetName)
|
||||
.knowledgeBaseUrl(url)
|
||||
.syncRecordId(logId)
|
||||
.message("已将该模块下全部店长历史汇报写入 Dify 知识库")
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.error("[DifyKb] 同步失败 moduleCode={}", moduleCode, e);
|
||||
String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
||||
appendSyncLog(moduleCode, moduleName, report.getId(),
|
||||
kb != null && kb.getDatasetId() != null ? kb.getDatasetId() : "",
|
||||
SYNC_FAIL, truncateMsg(msg));
|
||||
throw exception0(BAD_REQUEST.getCode(), "Dify 知识库同步失败:" + msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库展示名与聚合文档名:租户名称-租户ID-菜单名称-菜单ID(前端传当前路由菜单名与 menuId),
|
||||
* 避免不同租户或不同菜单在 Dify 侧冲突;未传 menuId 时退化为前三段以保持兼容。
|
||||
*/
|
||||
private static String buildTenantModuleKbName(String tenantName, Long tenantId, String moduleName, Long menuId) {
|
||||
String t = StringUtils.trimToEmpty(tenantName);
|
||||
String idPart = tenantId != null ? String.valueOf(tenantId) : "";
|
||||
String m = StringUtils.trimToEmpty(moduleName);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (StringUtils.isNotBlank(t)) {
|
||||
sb.append(t);
|
||||
}
|
||||
if (StringUtils.isNotBlank(idPart)) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('-');
|
||||
}
|
||||
sb.append(idPart);
|
||||
}
|
||||
if (StringUtils.isNotBlank(m)) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('-');
|
||||
}
|
||||
sb.append(m);
|
||||
}
|
||||
if (menuId != null) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('-');
|
||||
}
|
||||
sb.append(menuId);
|
||||
}
|
||||
if (sb.length() == 0) {
|
||||
return "daily-report-kb";
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String resolveTenantName() {
|
||||
Long tid = TenantContextHolder.getTenantId();
|
||||
if (tid == null) {
|
||||
return "";
|
||||
}
|
||||
TenantDO tenant = tenantService.getTenant(tid);
|
||||
return tenant != null ? StringUtils.defaultString(tenant.getName()) : "";
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String url) {
|
||||
if (url == null) {
|
||||
return "";
|
||||
}
|
||||
return url.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private static boolean isDifyNotFound(Throwable e) {
|
||||
Throwable c = e;
|
||||
while (c != null) {
|
||||
if (c instanceof HttpStatusCodeException) {
|
||||
return ((HttpStatusCodeException) c).getStatusCode().value() == 404;
|
||||
}
|
||||
String msg = c.getMessage();
|
||||
if (msg != null && msg.contains("404")) {
|
||||
return true;
|
||||
}
|
||||
c = c.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String truncateDatasetName(String name) {
|
||||
if (name == null) {
|
||||
return "daily-report-kb";
|
||||
}
|
||||
String n = name.trim();
|
||||
// Dify 知识库名含「租户-租户id-菜单名-菜单id」,适当放宽截断长度
|
||||
return n.length() > 120 ? n.substring(0, 120) : n;
|
||||
}
|
||||
|
||||
private static String truncateMsg(String m) {
|
||||
if (m == null) {
|
||||
return "";
|
||||
}
|
||||
return m.length() > 1000 ? m.substring(0, 1000) : m;
|
||||
}
|
||||
|
||||
private Long appendSyncLog(String moduleCode, String moduleName, Long reportId, String datasetId,
|
||||
String status, String message) {
|
||||
AiAssistantDifyKbSyncLogDO row = AiAssistantDifyKbSyncLogDO.builder()
|
||||
.moduleCode(moduleCode)
|
||||
.moduleName(StringUtils.defaultString(moduleName))
|
||||
.reportId(reportId)
|
||||
.datasetId(StringUtils.defaultString(datasetId))
|
||||
.syncStatus(status)
|
||||
.syncMessage(StringUtils.defaultString(message))
|
||||
.build();
|
||||
aiAssistantDifyKbSyncLogMapper.insert(row);
|
||||
return row.getId();
|
||||
}
|
||||
|
||||
private String buildAggregateText(List<AiAssistantReportDO> reports) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (AiAssistantReportDO r : reports) {
|
||||
sb.append("\n\n========== 汇报 ID: ").append(r.getId())
|
||||
.append(" | 日期: ").append(r.getReportTime() != null ? r.getReportTime() : "")
|
||||
.append(" | 报告人: ").append(StringUtils.defaultString(r.getReporter()))
|
||||
.append(" ==========\n");
|
||||
if (StringUtils.isNotBlank(r.getReportContent())) {
|
||||
sb.append("\n【正文】\n").append(r.getReportContent().trim()).append("\n");
|
||||
}
|
||||
List<AiAssistantReportDetailDO> details = aiAssistantReportDetailMapper.selectListByReportId(r.getId());
|
||||
if (details != null && !details.isEmpty()) {
|
||||
sb.append("\n【AI经营诊断】\n");
|
||||
for (AiAssistantReportDetailDO d : details) {
|
||||
sb.append("- 诊断项: ").append(StringUtils.defaultString(d.getDiagnosisItem()))
|
||||
.append(" | 指标异常: ").append(StringUtils.defaultString(d.getValue1()))
|
||||
.append(" | 归因: ").append(StringUtils.defaultString(d.getValue2()))
|
||||
.append(" | 对策: ").append(StringUtils.defaultString(d.getValue3()))
|
||||
.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dify 控制台(Next.js)路由:文档详情为
|
||||
* {@code /datasets/{datasetId}/documents/{documentId}},与官方「知识库 API」中的 dataset_id、document_id 对应。
|
||||
* 浏览器打开时需用户登录控制台且对数据集有权限(/console/api),与 Service API 的 dataset- 密钥无关。
|
||||
*/
|
||||
private String buildConsoleDocumentUrl(String datasetId, String documentId) {
|
||||
if (StringUtils.isBlank(consoleBaseUrl) || StringUtils.isBlank(datasetId)) {
|
||||
return "";
|
||||
}
|
||||
String base = consoleBaseUrl.replaceAll("/$", "");
|
||||
if (StringUtils.isNotBlank(documentId)) {
|
||||
return base + "/datasets/" + datasetId + "/documents/" + documentId;
|
||||
}
|
||||
return base + "/datasets/" + datasetId + "/documents";
|
||||
}
|
||||
|
||||
@Override
|
||||
public DifyKbInfoRespVO getKbInfo(String moduleCode) {
|
||||
if (StringUtils.isBlank(moduleCode)) {
|
||||
return DifyKbInfoRespVO.builder().created(false).build();
|
||||
}
|
||||
AiAssistantDifyKbDO kb = aiAssistantDifyKbMapper.selectByModuleCode(moduleCode);
|
||||
String lastSync = null;
|
||||
AiAssistantDifyKbSyncLogDO latest = aiAssistantDifyKbSyncLogMapper.selectLatestByModuleCode(moduleCode);
|
||||
if (latest != null && latest.getCreateTime() != null) {
|
||||
lastSync = latest.getCreateTime().format(DateTimeFormatter.ofPattern(FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND));
|
||||
}
|
||||
if (kb == null || StringUtils.isBlank(kb.getDatasetId())) {
|
||||
return DifyKbInfoRespVO.builder()
|
||||
.created(false)
|
||||
.lastSyncTime(lastSync)
|
||||
.build();
|
||||
}
|
||||
String docId = kb.getAggregateDocumentId();
|
||||
return DifyKbInfoRespVO.builder()
|
||||
.datasetId(kb.getDatasetId())
|
||||
.documentId(docId)
|
||||
.datasetName(kb.getDatasetName())
|
||||
.knowledgeBaseUrl(buildConsoleDocumentUrl(kb.getDatasetId(), docId))
|
||||
.lastSyncTime(lastSync)
|
||||
.created(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<DifyKbSyncLogRespVO> getSyncLogPage(DifyKbSyncLogPageReqVO reqVO) {
|
||||
PageResult<AiAssistantDifyKbSyncLogDO> page = aiAssistantDifyKbSyncLogMapper.selectPage(reqVO);
|
||||
return BeanUtils.toBean(page, DifyKbSyncLogRespVO.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.service.aiassistantdifykb;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.LOCKED;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0;
|
||||
|
||||
/**
|
||||
* 同一租户 + 同一页面(moduleCode) 的 Dify 知识库同步互斥,避免多用户同时点击导致重复创建、409 名称冲突、文档覆盖乱序。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class DifyKbSyncRedisLock {
|
||||
|
||||
private static final String KEY_PREFIX = "ydoyun:dify:kb:sync:";
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Value("${ydoyun.dify.knowledge.sync-lock-lease-seconds:300}")
|
||||
private long leaseSeconds;
|
||||
|
||||
/**
|
||||
* @param tenantId 租户编号(与 {@link cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder} 一致)
|
||||
*/
|
||||
public <T> T runWithLock(Long tenantId, String moduleCode, Supplier<T> supplier) {
|
||||
if (StringUtils.isBlank(moduleCode)) {
|
||||
return supplier.get();
|
||||
}
|
||||
String key = buildKey(tenantId, moduleCode);
|
||||
String token = UUID.randomUUID().toString();
|
||||
Boolean ok = stringRedisTemplate.opsForValue().setIfAbsent(key, token, Duration.ofSeconds(Math.max(leaseSeconds, 30)));
|
||||
if (!Boolean.TRUE.equals(ok)) {
|
||||
throw exception0(LOCKED.getCode(), "同一租户下该模块正在同步至 Dify,请稍后再试");
|
||||
}
|
||||
try {
|
||||
return supplier.get();
|
||||
} finally {
|
||||
release(key, token);
|
||||
}
|
||||
}
|
||||
|
||||
private static String buildKey(Long tenantId, String moduleCode) {
|
||||
String tid = tenantId != null ? String.valueOf(tenantId) : "0";
|
||||
return KEY_PREFIX + tid + ":" + moduleCode;
|
||||
}
|
||||
|
||||
private void release(String key, String token) {
|
||||
try {
|
||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||
script.setScriptText(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end");
|
||||
script.setResultType(Long.class);
|
||||
stringRedisTemplate.execute(script, Collections.singletonList(key), token);
|
||||
} catch (Exception e) {
|
||||
log.warn("[DifyKb] 释放同步锁失败 key={}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.service.difykbmanage;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentContentRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentPageReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbManageItemRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbManageListReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbModuleOptionRespVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 当前租户下 Dify 知识库绑定查询与 Dify 侧文档删除(仅允许操作本租户已绑定的 dataset)。
|
||||
*/
|
||||
public interface DifyKbManageService {
|
||||
|
||||
List<DifyKbManageItemRespVO> listBindings(DifyKbManageListReqVO reqVO);
|
||||
|
||||
List<DifyKbModuleOptionRespVO> listModuleFilterOptions();
|
||||
|
||||
PageResult<DifyKbDocumentRespVO> getDocumentPage(DifyKbDocumentPageReqVO reqVO);
|
||||
|
||||
DifyKbDocumentContentRespVO getDocumentContent(String datasetId, String documentId);
|
||||
|
||||
void deleteDataset(String datasetId);
|
||||
|
||||
void deleteDocument(String datasetId, String documentId);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package cn.iocoder.yudao.module.ydoyun.service.difykbmanage;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentContentRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentPageReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbDocumentRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbManageItemRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbManageListReqVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.controller.admin.difykb.vo.DifyKbModuleOptionRespVO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.dataobject.aiassistantdifykb.AiAssistantDifyKbDO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dal.mysql.aiassistantdifykb.AiAssistantDifyKbMapper;
|
||||
import cn.iocoder.yudao.module.ydoyun.dify.DifyKnowledgeApiClient;
|
||||
import cn.iocoder.yudao.module.ydoyun.dify.dto.DifyDocumentListDTO;
|
||||
import cn.iocoder.yudao.module.ydoyun.dify.dto.DifyDocumentSegmentTextDTO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class DifyKbManageServiceImpl implements DifyKbManageService {
|
||||
|
||||
private static final int MAX_DOCUMENT_PREVIEW_CHARS = 500_000;
|
||||
|
||||
private final AiAssistantDifyKbMapper aiAssistantDifyKbMapper;
|
||||
private final DifyKnowledgeApiClient difyKnowledgeApiClient;
|
||||
|
||||
@Override
|
||||
public List<DifyKbManageItemRespVO> listBindings(DifyKbManageListReqVO reqVO) {
|
||||
ensureTenantContextAligned();
|
||||
DifyKbManageListReqVO q = reqVO != null ? reqVO : new DifyKbManageListReqVO();
|
||||
List<AiAssistantDifyKbDO> rows = aiAssistantDifyKbMapper.selectList(
|
||||
buildTenantScopedWrapper(q.getModuleCode(), q.getModuleName()));
|
||||
List<DifyKbManageItemRespVO> list = new ArrayList<>();
|
||||
for (AiAssistantDifyKbDO row : rows) {
|
||||
boolean exists = false;
|
||||
if (StringUtils.isNotBlank(row.getDatasetId())) {
|
||||
try {
|
||||
exists = difyKnowledgeApiClient.datasetExists(row.getDatasetId());
|
||||
} catch (Exception e) {
|
||||
log.warn("[DifyKbManage] 检查知识库存在性失败 datasetId={} msg={}", row.getDatasetId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
list.add(DifyKbManageItemRespVO.builder()
|
||||
.id(row.getId())
|
||||
.moduleCode(row.getModuleCode())
|
||||
.moduleName(row.getModuleName())
|
||||
.datasetId(row.getDatasetId())
|
||||
.datasetName(row.getDatasetName())
|
||||
.aggregateDocumentId(row.getAggregateDocumentId())
|
||||
.existsOnDify(exists)
|
||||
.build());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DifyKbModuleOptionRespVO> listModuleFilterOptions() {
|
||||
ensureTenantContextAligned();
|
||||
List<AiAssistantDifyKbDO> rows = aiAssistantDifyKbMapper.selectList(
|
||||
buildTenantScopedWrapper(null, null));
|
||||
Map<String, String> distinct = new LinkedHashMap<>();
|
||||
for (AiAssistantDifyKbDO row : rows) {
|
||||
String code = StringUtils.trimToEmpty(row.getModuleCode());
|
||||
if (code.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
distinct.putIfAbsent(code, StringUtils.defaultString(row.getModuleName()));
|
||||
}
|
||||
return distinct.entrySet().stream()
|
||||
.map(e -> DifyKbModuleOptionRespVO.builder()
|
||||
.moduleCode(e.getKey())
|
||||
.moduleName(e.getValue())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<DifyKbDocumentRespVO> getDocumentPage(DifyKbDocumentPageReqVO reqVO) {
|
||||
ensureTenantContextAligned();
|
||||
requireBinding(reqVO.getDatasetId());
|
||||
DifyDocumentListDTO dto = difyKnowledgeApiClient.listDocuments(
|
||||
reqVO.getDatasetId(), reqVO.getPageNo(), reqVO.getPageSize());
|
||||
List<DifyKbDocumentRespVO> list = dto.getData().stream()
|
||||
.map(it -> DifyKbDocumentRespVO.builder()
|
||||
.id(it.getId())
|
||||
.name(it.getName())
|
||||
.createdAt(it.getCreatedAt())
|
||||
.indexingStatus(it.getIndexingStatus())
|
||||
.displayStatus(it.getDisplayStatus())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
return new PageResult<>(list, dto.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public DifyKbDocumentContentRespVO getDocumentContent(String datasetId, String documentId) {
|
||||
ensureTenantContextAligned();
|
||||
requireBinding(datasetId);
|
||||
DifyDocumentSegmentTextDTO dto = difyKnowledgeApiClient.aggregateDocumentTextBySegments(
|
||||
datasetId, documentId, MAX_DOCUMENT_PREVIEW_CHARS);
|
||||
return DifyKbDocumentContentRespVO.builder()
|
||||
.content(StringUtils.defaultString(dto.getText()))
|
||||
.truncated(dto.isTruncated())
|
||||
.segmentPieces(dto.getSegmentPieces())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDataset(String datasetId) {
|
||||
ensureTenantContextAligned();
|
||||
AiAssistantDifyKbDO row = requireBinding(datasetId);
|
||||
difyKnowledgeApiClient.deleteDataset(datasetId);
|
||||
aiAssistantDifyKbMapper.deleteById(row.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteDocument(String datasetId, String documentId) {
|
||||
ensureTenantContextAligned();
|
||||
AiAssistantDifyKbDO row = requireBinding(datasetId);
|
||||
difyKnowledgeApiClient.deleteDocument(datasetId, documentId);
|
||||
if (StringUtils.isNotBlank(row.getAggregateDocumentId())
|
||||
&& row.getAggregateDocumentId().equals(documentId)) {
|
||||
row.setAggregateDocumentId(null);
|
||||
aiAssistantDifyKbMapper.updateById(row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 MyBatis 多租户插件使用同一租户:优先使用「登录用户 + 访问租户 visitTenantId」,与切换租户、Header 语义一致。
|
||||
*/
|
||||
private void ensureTenantContextAligned() {
|
||||
Long effective = resolveEffectiveTenantId();
|
||||
if (effective == null) {
|
||||
throw invalidParamException("未获取到租户信息,无法查询知识库绑定");
|
||||
}
|
||||
Long holder = TenantContextHolder.getTenantId();
|
||||
if (!Objects.equals(holder, effective)) {
|
||||
log.warn("[DifyKbManage] 租户上下文与登录态不一致,已对齐为 effectiveTenantId={} tenantContextHolderWas={}",
|
||||
effective, holder);
|
||||
TenantContextHolder.setTenantId(effective);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换租户:visitTenantId 优先于登录租户 tenantId;否则回退 TenantContextHolder(请求头)。
|
||||
*/
|
||||
private Long resolveEffectiveTenantId() {
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser != null) {
|
||||
if (loginUser.getVisitTenantId() != null) {
|
||||
return loginUser.getVisitTenantId();
|
||||
}
|
||||
if (loginUser.getTenantId() != null) {
|
||||
return loginUser.getTenantId();
|
||||
}
|
||||
}
|
||||
return TenantContextHolder.getTenantId();
|
||||
}
|
||||
|
||||
private LambdaQueryWrapperX<AiAssistantDifyKbDO> buildTenantScopedWrapper(String moduleCode, String moduleName) {
|
||||
Long tid = resolveEffectiveTenantId();
|
||||
if (tid == null) {
|
||||
throw invalidParamException("未获取到租户信息,无法查询知识库绑定");
|
||||
}
|
||||
LambdaQueryWrapperX<AiAssistantDifyKbDO> w = new LambdaQueryWrapperX<>();
|
||||
w.eq(AiAssistantDifyKbDO::getTenantId, tid);
|
||||
if (StringUtils.isNotBlank(moduleCode)) {
|
||||
w.eq(AiAssistantDifyKbDO::getModuleCode, moduleCode);
|
||||
}
|
||||
if (StringUtils.isNotBlank(moduleName)) {
|
||||
w.like(AiAssistantDifyKbDO::getModuleName, moduleName);
|
||||
}
|
||||
w.orderByDesc(AiAssistantDifyKbDO::getId);
|
||||
return w;
|
||||
}
|
||||
|
||||
private AiAssistantDifyKbDO requireBinding(String datasetId) {
|
||||
if (StringUtils.isBlank(datasetId)) {
|
||||
throw invalidParamException("datasetId 不能为空");
|
||||
}
|
||||
Long tid = resolveEffectiveTenantId();
|
||||
if (tid == null) {
|
||||
throw invalidParamException("未获取到租户信息");
|
||||
}
|
||||
AiAssistantDifyKbDO row = aiAssistantDifyKbMapper.selectByDatasetIdAndTenantId(datasetId, tid);
|
||||
if (row == null) {
|
||||
throw invalidParamException("知识库不存在或不属于当前租户");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user