This commit is contained in:
2026-04-17 09:56:29 +08:00
parent 63929de6a1
commit fb204618a3
27 changed files with 2022 additions and 0 deletions

View 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知识库绑定每租户每页面一条';

View 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知识库同步记录';

View File

@@ -0,0 +1,36 @@
-- =============================================================================
-- Dify 知识库管理:菜单 + 按钮权限system_menu
-- =============================================================================
-- 说明:
-- 1. type1=目录 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);
-- =============================================================================

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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"));
}
}

View File

@@ -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 知识库DatasetHTTP 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 列表中按「名称完全一致」查找知识库 idkeyword 检索 + 少量分页兜底)。
*/
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=exactNamefalse 时不带 keyword仅分页扫描兜底
*/
private String findDatasetIdInListPages(String exactName, boolean useKeyword) {
int page = 1;
final int limit = 50;
final int maxPages = 30;
while (page <= maxPages) {
// 使用 URLEncoderSpring 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";
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}