diff --git a/sql/mysql/ydoyun_ai_assistant_dify_kb.sql b/sql/mysql/ydoyun_ai_assistant_dify_kb.sql new file mode 100644 index 0000000..db7c39e --- /dev/null +++ b/sql/mysql/ydoyun_ai_assistant_dify_kb.sql @@ -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知识库绑定(每租户每页面一条)'; diff --git a/sql/mysql/ydoyun_ai_assistant_dify_kb_sync_log.sql b/sql/mysql/ydoyun_ai_assistant_dify_kb_sync_log.sql new file mode 100644 index 0000000..addcf87 --- /dev/null +++ b/sql/mysql/ydoyun_ai_assistant_dify_kb_sync_log.sql @@ -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知识库同步记录'; diff --git a/sql/mysql/ydoyun_dify_kb_manage_menu.sql b/sql/mysql/ydoyun_dify_kb_manage_menu.sql new file mode 100644 index 0000000..da5c1bb --- /dev/null +++ b/sql/mysql/ydoyun_dify_kb_manage_menu.sql @@ -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); +-- ============================================================================= diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbInfoRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbInfoRespVO.java new file mode 100644 index 0000000..cfee0fe --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbInfoRespVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncLogPageReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncLogPageReqVO.java new file mode 100644 index 0000000..af354d3 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncLogPageReqVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncLogRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncLogRespVO.java new file mode 100644 index 0000000..b661148 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncLogRespVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncReqVO.java new file mode 100644 index 0000000..0bf461b --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncReqVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncRespVO.java new file mode 100644 index 0000000..92d5f78 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/aiassistantreport/vo/DifyKbSyncRespVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/DifyKbManageController.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/DifyKbManageController.java new file mode 100644 index 0000000..9e91302 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/DifyKbManageController.java @@ -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> listBindings(DifyKbManageListReqVO reqVO) { + return success(difyKbManageService.listBindings(reqVO)); + } + + @GetMapping("/module-options") + @Operation(summary = "当前租户下模块筛选项(去重)") + @PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:query')") + public CommonResult> listModuleOptions() { + return success(difyKbManageService.listModuleFilterOptions()); + } + + @GetMapping("/document/page") + @Operation(summary = "分页查询某知识库下的文档(dataset 须为本租户已绑定)") + @PreAuthorize("@ss.hasPermission('ydoyun:dify-kb:difyquery')") + public CommonResult> 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 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 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 deleteDocument( + @RequestParam("datasetId") String datasetId, + @RequestParam("documentId") String documentId) { + difyKbManageService.deleteDocument(datasetId, documentId); + return success(true); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentContentRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentContentRespVO.java new file mode 100644 index 0000000..c01acd7 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentContentRespVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentPageReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentPageReqVO.java new file mode 100644 index 0000000..59f3a13 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentPageReqVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentRespVO.java new file mode 100644 index 0000000..e7f2d1c --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbDocumentRespVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbManageItemRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbManageItemRespVO.java new file mode 100644 index 0000000..73b08f5 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbManageItemRespVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbManageListReqVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbManageListReqVO.java new file mode 100644 index 0000000..db04b20 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbManageListReqVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbModuleOptionRespVO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbModuleOptionRespVO.java new file mode 100644 index 0000000..10227bf --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/controller/admin/difykb/vo/DifyKbModuleOptionRespVO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantdifykb/AiAssistantDifyKbDO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantdifykb/AiAssistantDifyKbDO.java new file mode 100644 index 0000000..4da778b --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantdifykb/AiAssistantDifyKbDO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantdifykb/AiAssistantDifyKbSyncLogDO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantdifykb/AiAssistantDifyKbSyncLogDO.java new file mode 100644 index 0000000..d9189a8 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/dataobject/aiassistantdifykb/AiAssistantDifyKbSyncLogDO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantdifykb/AiAssistantDifyKbMapper.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantdifykb/AiAssistantDifyKbMapper.java new file mode 100644 index 0000000..22a3fe8 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantdifykb/AiAssistantDifyKbMapper.java @@ -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 { + + default AiAssistantDifyKbDO selectByModuleCode(String moduleCode) { + return selectOne(new LambdaQueryWrapperX() + .eq(AiAssistantDifyKbDO::getModuleCode, moduleCode) + .orderByDesc(AiAssistantDifyKbDO::getId) + .last("LIMIT 1")); + } + + default AiAssistantDifyKbDO selectByDatasetId(String datasetId) { + return selectOne(new LambdaQueryWrapperX() + .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() + .eq(AiAssistantDifyKbDO::getDatasetId, datasetId.trim()) + .eq(AiAssistantDifyKbDO::getTenantId, tenantId)); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantdifykb/AiAssistantDifyKbSyncLogMapper.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantdifykb/AiAssistantDifyKbSyncLogMapper.java new file mode 100644 index 0000000..eb75419 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dal/mysql/aiassistantdifykb/AiAssistantDifyKbSyncLogMapper.java @@ -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 { + + default PageResult selectPage(DifyKbSyncLogPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(AiAssistantDifyKbSyncLogDO::getModuleCode, reqVO.getModuleCode()) + .orderByDesc(AiAssistantDifyKbSyncLogDO::getId)); + } + + default AiAssistantDifyKbSyncLogDO selectLatestByModuleCode(String moduleCode) { + return selectOne(new LambdaQueryWrapperX() + .eq(AiAssistantDifyKbSyncLogDO::getModuleCode, moduleCode) + .orderByDesc(AiAssistantDifyKbSyncLogDO::getId) + .last("LIMIT 1")); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/DifyKnowledgeApiClient.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/DifyKnowledgeApiClient.java new file mode 100644 index 0000000..6490d5c --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/DifyKnowledgeApiClient.java @@ -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 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 entity = new HttpEntity<>(headers); + ResponseEntity 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 entity = new HttpEntity<>(headers); + ResponseEntity 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 entity = new HttpEntity<>(headers); + ResponseEntity 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 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 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 entity = new HttpEntity<>(headers); + ResponseEntity 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 buildTextDocumentBody(String documentName, String text) { + Map body = new HashMap<>(); + body.put("name", documentName); + body.put("text", text); + body.put("indexing_technique", "high_quality"); + Map processRule = new HashMap<>(); + processRule.put("mode", "automatic"); + body.put("process_rule", processRule); + return body; + } + + private String postJsonFirstWorking(String urlPrimary, String urlFallback, Map 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 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 entity = new HttpEntity<>(jsonBody, headers); + ResponseEntity 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 body) { + if (body == null || body.isEmpty()) { + return "{}"; + } + try { + Map 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"; + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/dto/DifyDocumentListDTO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/dto/DifyDocumentListDTO.java new file mode 100644 index 0000000..9b8960f --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/dto/DifyDocumentListDTO.java @@ -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 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; + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/dto/DifyDocumentSegmentTextDTO.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/dto/DifyDocumentSegmentTextDTO.java new file mode 100644 index 0000000..13bd461 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/dify/dto/DifyDocumentSegmentTextDTO.java @@ -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; +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/AiAssistantDifyKbService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/AiAssistantDifyKbService.java new file mode 100644 index 0000000..7978ae6 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/AiAssistantDifyKbService.java @@ -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 getSyncLogPage(DifyKbSyncLogPageReqVO reqVO); +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/AiAssistantDifyKbServiceImpl.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/AiAssistantDifyKbServiceImpl.java new file mode 100644 index 0000000..3a445ac --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/AiAssistantDifyKbServiceImpl.java @@ -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 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 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 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 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 getSyncLogPage(DifyKbSyncLogPageReqVO reqVO) { + PageResult page = aiAssistantDifyKbSyncLogMapper.selectPage(reqVO); + return BeanUtils.toBean(page, DifyKbSyncLogRespVO.class); + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/DifyKbSyncRedisLock.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/DifyKbSyncRedisLock.java new file mode 100644 index 0000000..6e36d2f --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/aiassistantdifykb/DifyKbSyncRedisLock.java @@ -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 runWithLock(Long tenantId, String moduleCode, Supplier 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 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); + } + } +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/difykbmanage/DifyKbManageService.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/difykbmanage/DifyKbManageService.java new file mode 100644 index 0000000..d5522d1 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/difykbmanage/DifyKbManageService.java @@ -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 listBindings(DifyKbManageListReqVO reqVO); + + List listModuleFilterOptions(); + + PageResult getDocumentPage(DifyKbDocumentPageReqVO reqVO); + + DifyKbDocumentContentRespVO getDocumentContent(String datasetId, String documentId); + + void deleteDataset(String datasetId); + + void deleteDocument(String datasetId, String documentId); +} diff --git a/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/difykbmanage/DifyKbManageServiceImpl.java b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/difykbmanage/DifyKbManageServiceImpl.java new file mode 100644 index 0000000..fc86dc1 --- /dev/null +++ b/yudao-module-ydoyun/src/main/java/cn/iocoder/yudao/module/ydoyun/service/difykbmanage/DifyKbManageServiceImpl.java @@ -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 listBindings(DifyKbManageListReqVO reqVO) { + ensureTenantContextAligned(); + DifyKbManageListReqVO q = reqVO != null ? reqVO : new DifyKbManageListReqVO(); + List rows = aiAssistantDifyKbMapper.selectList( + buildTenantScopedWrapper(q.getModuleCode(), q.getModuleName())); + List 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 listModuleFilterOptions() { + ensureTenantContextAligned(); + List rows = aiAssistantDifyKbMapper.selectList( + buildTenantScopedWrapper(null, null)); + Map 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 getDocumentPage(DifyKbDocumentPageReqVO reqVO) { + ensureTenantContextAligned(); + requireBinding(reqVO.getDatasetId()); + DifyDocumentListDTO dto = difyKnowledgeApiClient.listDocuments( + reqVO.getDatasetId(), reqVO.getPageNo(), reqVO.getPageSize()); + List 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 buildTenantScopedWrapper(String moduleCode, String moduleName) { + Long tid = resolveEffectiveTenantId(); + if (tid == null) { + throw invalidParamException("未获取到租户信息,无法查询知识库绑定"); + } + LambdaQueryWrapperX 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; + } +}