From 12dca45b4d02af13a6ff6dd891b2e56c1dc72ce7 Mon Sep 17 00:00:00 2001 From: wangys <3401275564@qq.com> Date: Mon, 12 Jan 2026 13:52:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E7=AB=A0=E5=AF=BC=E5=85=A5=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/schoolNews/config/application.yml | 8 + .../alter_resource_add_knowledge_fields.sql | 20 ++ .../.bin/mysql/sql/createTableResource.sql | 6 +- .../.bin/mysql/sql/initConfigData.sql | 10 +- .../admin/src/main/resources/application.yml | 9 + .../service/impl/AiKnowledgeServiceImpl.java | 158 +++++++++ .../api/ai/knowledge/AiKnowledgeService.java | 7 + .../xyzh/common/dto/resource/TbResource.java | 39 +++ .../task/newsTask/NewsCrawlerTask.java | 11 +- schoolNewsServ/news/pom.xml | 35 ++ .../news/config/ArticleKnowledgeInit.java | 206 ++++++++++++ .../ArticleKnowledgeController.java | 61 ++++ .../news/service/ArticleKnowledgeService.java | 49 +++ .../impl/ArticleKnowledgeServiceImpl.java | 318 ++++++++++++++++++ .../service/impl/NCResourceServiceImpl.java | 59 +++- .../xyzh/news/util/ArticlePdfGenerator.java | 270 +++++++++++++++ .../main/resources/mapper/ResourceMapper.xml | 19 +- schoolNewsWeb/src/apis/resource/resource.ts | 32 ++ schoolNewsWeb/src/types/resource/index.ts | 6 + .../manage/resource/ArticleManagementView.vue | 63 +++- 20 files changed, 1371 insertions(+), 15 deletions(-) create mode 100644 schoolNewsServ/.bin/mysql/sql/alter_resource_add_knowledge_fields.sql create mode 100644 schoolNewsServ/news/src/main/java/org/xyzh/news/config/ArticleKnowledgeInit.java create mode 100644 schoolNewsServ/news/src/main/java/org/xyzh/news/controller/ArticleKnowledgeController.java create mode 100644 schoolNewsServ/news/src/main/java/org/xyzh/news/service/ArticleKnowledgeService.java create mode 100644 schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/ArticleKnowledgeServiceImpl.java create mode 100644 schoolNewsServ/news/src/main/java/org/xyzh/news/util/ArticlePdfGenerator.java diff --git a/docker/schoolNews/config/application.yml b/docker/schoolNews/config/application.yml index 9deb88d..490e8f4 100644 --- a/docker/schoolNews/config/application.yml +++ b/docker/schoolNews/config/application.yml @@ -148,6 +148,14 @@ crontab: dify: knowledgeApiKey: dataset-nupqKP4LONpzdXmGthIrbjeJ +# 文章知识库配置 +article: + knowledge: + # 默认文章知识库ID(自动初始化后会使用此ID) + default-id: article_news_resource + # 是否自动初始化知识库(首次启动时创建) + auto-init: true + # 文件存储配置 file: storage: diff --git a/schoolNewsServ/.bin/mysql/sql/alter_resource_add_knowledge_fields.sql b/schoolNewsServ/.bin/mysql/sql/alter_resource_add_knowledge_fields.sql new file mode 100644 index 0000000..2dbd264 --- /dev/null +++ b/schoolNewsServ/.bin/mysql/sql/alter_resource_add_knowledge_fields.sql @@ -0,0 +1,20 @@ +-- 为tb_resource表添加知识库相关字段 +-- 执行前请确认数据库连接正确 + +USE school_news; + +-- 添加知识库相关字段 +ALTER TABLE `tb_resource` +ADD COLUMN `is_in_knowledge` TINYINT(1) DEFAULT 0 COMMENT '是否已导入知识库' AFTER `is_banner`, +ADD COLUMN `knowledge_file_id` VARCHAR(50) DEFAULT NULL COMMENT '知识库文件ID' AFTER `is_in_knowledge`, +ADD COLUMN `knowledge_import_time` TIMESTAMP NULL DEFAULT NULL COMMENT '知识库导入时间' AFTER `knowledge_file_id`; + +-- 添加索引 +ALTER TABLE `tb_resource` ADD INDEX `idx_is_in_knowledge` (`is_in_knowledge`); + +-- 验证字段添加成功 +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'school_news' +AND TABLE_NAME = 'tb_resource' +AND COLUMN_NAME IN ('is_in_knowledge', 'knowledge_file_id', 'knowledge_import_time'); diff --git a/schoolNewsServ/.bin/mysql/sql/createTableResource.sql b/schoolNewsServ/.bin/mysql/sql/createTableResource.sql index 9800e81..1f5952e 100644 --- a/schoolNewsServ/.bin/mysql/sql/createTableResource.sql +++ b/schoolNewsServ/.bin/mysql/sql/createTableResource.sql @@ -19,6 +19,9 @@ CREATE TABLE `tb_resource` ( `is_audited` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已审核', `is_recommend` TINYINT(1) DEFAULT 0 COMMENT '是否推荐', `is_banner` TINYINT(1) DEFAULT 0 COMMENT '是否轮播', + `is_in_knowledge` TINYINT(1) DEFAULT 0 COMMENT '是否已导入知识库', + `knowledge_file_id` VARCHAR(50) DEFAULT NULL COMMENT '知识库文件ID', + `knowledge_import_time` TIMESTAMP NULL DEFAULT NULL COMMENT '知识库导入时间', `publish_time` TIMESTAMP NULL DEFAULT NULL COMMENT '发布时间', `creator` VARCHAR(50) DEFAULT NULL COMMENT '创建者', `updater` VARCHAR(50) DEFAULT NULL COMMENT '更新者', @@ -31,7 +34,8 @@ CREATE TABLE `tb_resource` ( KEY `idx_tag` (`tag_id`), KEY `idx_status` (`status`), KEY `idx_publish_time` (`publish_time`), - KEY `idx_view_count` (`view_count`) + KEY `idx_view_count` (`view_count`), + KEY `idx_is_in_knowledge` (`is_in_knowledge`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资源表'; diff --git a/schoolNewsServ/.bin/mysql/sql/initConfigData.sql b/schoolNewsServ/.bin/mysql/sql/initConfigData.sql index 3c6fa68..0d83d95 100644 --- a/schoolNewsServ/.bin/mysql/sql/initConfigData.sql +++ b/schoolNewsServ/.bin/mysql/sql/initConfigData.sql @@ -18,8 +18,14 @@ INSERT INTO `tb_sys_config` (`id`, `config_key`, `config_name`, `config_value`, ('21', 'dify.upload.maxSize', '最大文件大小', '50', 'integer', 'input', 'Dify配置', '最大文件大小(MB)', '请输入最大文件大小', '单个文件上传的最大大小限制', NULL, 1, 500, 'MB', NULL, 21, 1, '1', now()), -- Dify知识库配置 -('30', 'dify.dataset.defaultIndexingTechnique', '默认索引方式', 'high_quality', 'string', 'select', 'Dify配置', '默认索引方式', NULL, '知识库文档的默认索引方式', NULL, NULL, NULL, NULL, 'high_quality,economy', 30, 1, '1', now()), -('31', 'dify.dataset.defaultEmbeddingModel', '默认Embedding模型', 'text-embedding-ada-002', 'string', 'input', 'Dify配置', '默认Embedding模型', '请输入Embedding模型名称', '知识库使用的默认Embedding模型', NULL, NULL, NULL, NULL, NULL, 31, 1, '1', now()), +('30', 'dify.dataset.defaultIndexingTechnique', '默认索引方式', 'high_quality', 'string', 'select', 'Dify配置', '默认索引方式', NULL, '知识库文档的默认索引方式:high_quality(高质量)或 economy(经济)', NULL, NULL, NULL, NULL, 'high_quality,economy', 30, 1, '1', now()), +('31', 'dify.dataset.defaultEmbeddingModel', '默认Embedding模型', 'Qwen/Qwen3-Embedding-8B', 'string', 'input', 'Dify配置', '默认Embedding模型', '请输入Embedding模型名称', '知识库使用的默认Embedding模型名称', NULL, NULL, NULL, NULL, NULL, 31, 1, '1', now()), +('32', 'dify.dataset.embeddingModelProvider', 'Embedding模型供应商', 'langgenius/siliconflow/siliconflow', 'string', 'input', 'Dify配置', 'Embedding模型供应商', '请输入模型供应商标识', 'Embedding模型的供应商标识', NULL, NULL, NULL, NULL, NULL, 32, 1, '1', now()), +('33', 'dify.dataset.rerankingEnable', '启用Rerank', 'true', 'boolean', 'switch', 'Dify配置', '是否启用Rerank重排序', NULL, '启用后会对检索结果进行重排序提升相关性', NULL, NULL, NULL, NULL, NULL, 33, 1, '1', now()), +('34', 'dify.dataset.rerankModel', 'Rerank模型', 'Qwen/Qwen3-Reranker-8B', 'string', 'input', 'Dify配置', 'Rerank重排序模型', '请输入Rerank模型名称', '知识库使用的Rerank模型名称', NULL, NULL, NULL, NULL, NULL, 34, 1, '1', now()), +('35', 'dify.dataset.rerankModelProvider', 'Rerank模型供应商', 'langgenius/siliconflow/siliconflow', 'string', 'input', 'Dify配置', 'Rerank模型供应商', '请输入模型供应商标识', 'Rerank模型的供应商标识', NULL, NULL, NULL, NULL, NULL, 35, 1, '1', now()), +('36', 'dify.dataset.retrievalTopK', '检索TopK', '5', 'integer', 'input', 'Dify配置', '检索返回的最大文档数', '请输入TopK值(1-20)', '知识库检索时返回的最相关文档数量', NULL, 1, 20, NULL, NULL, 36, 1, '1', now()), +('37', 'dify.dataset.retrievalScoreThreshold', '相似度阈值', '0.5', 'double', 'input', 'Dify配置', '检索相似度阈值', '请输入阈值(0.0-1.0)', '低于此阈值的文档将被过滤', NULL, 0, 1, NULL, NULL, 37, 1, '1', now()), -- 基础配置(Logo、系统信息) ('100', 'system.name', '系统名称', '红色思政学习平台', 'string', 'input', '基础配置', '系统显示名称', '请输入系统名称', '系统对外显示的名称', NULL, NULL, NULL, NULL, NULL, 100, 1, '1', now()), diff --git a/schoolNewsServ/admin/src/main/resources/application.yml b/schoolNewsServ/admin/src/main/resources/application.yml index c6eb266..cc6a2fe 100644 --- a/schoolNewsServ/admin/src/main/resources/application.yml +++ b/schoolNewsServ/admin/src/main/resources/application.yml @@ -171,6 +171,15 @@ crontab: # dify dify: knowledgeApiKey: dataset-nupqKP4LONpzdXmGthIrbjeJ + +# 文章知识库配置 +article: + knowledge: + # 默认文章知识库ID(自动初始化后会使用此ID) + default-id: article_news_resource + # 是否自动初始化知识库(首次启动时创建) + auto-init: true + # 文件存储配置 file: storage: diff --git a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeServiceImpl.java b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeServiceImpl.java index 27968f2..284b407 100644 --- a/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeServiceImpl.java +++ b/schoolNewsServ/ai/src/main/java/org/xyzh/ai/service/impl/AiKnowledgeServiceImpl.java @@ -967,5 +967,163 @@ public class AiKnowledgeServiceImpl implements AiKnowledgeService { } } + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain createKnowledgeInternal(TbAiKnowledge knowledge) { + ResultDomain resultDomain = new ResultDomain<>(); + + try { + // 1. 参数验证 + if (!StringUtils.hasText(knowledge.getTitle())) { + resultDomain.fail("知识库标题不能为空"); + return resultDomain; + } + + // 2. 检查是否已存在(通过ID) + if (StringUtils.hasText(knowledge.getId())) { + TbAiKnowledge existing = knowledgeMapper.selectKnowledgeById(knowledge.getId()); + if (existing != null && !existing.getDeleted()) { + log.info("知识库已存在,跳过创建: id={}, title={}", knowledge.getId(), knowledge.getTitle()); + resultDomain.success("知识库已存在", existing); + return resultDomain; + } + } + + // 3. 在Dify创建知识库 + String difyDatasetId = null; + String indexingTechnique = knowledge.getDifyIndexingTechnique(); + String embeddingModel = knowledge.getEmbeddingModel(); + + try { + DatasetCreateRequest difyRequest = new DatasetCreateRequest(); + difyRequest.setName(knowledge.getTitle()); + difyRequest.setDescription(knowledge.getDescription()); + + // 使用配置的索引方式和Embedding模型 + if (!StringUtils.hasText(indexingTechnique)) { + indexingTechnique = difyConfig.getDataset().getDefaultIndexingTechnique(); + } + difyRequest.setIndexingTechnique(indexingTechnique); + + if (!StringUtils.hasText(embeddingModel)) { + embeddingModel = difyConfig.getDataset().getDefaultEmbeddingModel(); + } + difyRequest.setEmbeddingModel(embeddingModel); + + // 设置模型提供商 + String provider = knowledge.getEmbeddingModelProvider(); + if (StringUtils.hasText(provider)) { + difyRequest.setEmbeddingModelProvider(provider); + } + + // 设置检索模型配置 + RetrievalModel retrievalModel = new RetrievalModel(); + retrievalModel.setSearchMethod("hybrid_search"); + + if (knowledge.getRetrievalTopK() != null && knowledge.getRetrievalTopK() > 0) { + retrievalModel.setTopK(knowledge.getRetrievalTopK()); + } else { + retrievalModel.setTopK(5); + } + + if (knowledge.getRetrievalScoreThreshold() != null && knowledge.getRetrievalScoreThreshold() >= 0) { + retrievalModel.setScoreThreshold(knowledge.getRetrievalScoreThreshold()); + retrievalModel.setScoreThresholdEnabled(knowledge.getRetrievalScoreThreshold() > 0); + } else { + retrievalModel.setScoreThreshold(0.5); + retrievalModel.setScoreThresholdEnabled(true); + } + + Boolean rerankEnable = knowledge.getRerankingEnable() != null ? knowledge.getRerankingEnable() : false; + retrievalModel.setRerankingEnable(rerankEnable); + + if (rerankEnable && StringUtils.hasText(knowledge.getRerankModel()) + && StringUtils.hasText(knowledge.getRerankModelProvider())) { + retrievalModel.setRerankingMode("reranking_model"); + RetrievalModel.RerankingModel rerankingModel = new RetrievalModel.RerankingModel(); + rerankingModel.setRerankingProviderName(knowledge.getRerankModelProvider()); + rerankingModel.setRerankingModelName(knowledge.getRerankModel()); + retrievalModel.setRerankingModel(rerankingModel); + } + + difyRequest.setRetrievalModel(retrievalModel); + + log.info("内部创建知识库 - 请求参数: title={}, indexingTechnique={}", + knowledge.getTitle(), indexingTechnique); + DatasetCreateResponse difyResponse = difyApiClient.createDataset(difyRequest); + + difyDatasetId = difyResponse.getId(); + log.info("Dify知识库创建成功: {} - {}", difyDatasetId, knowledge.getTitle()); + + } catch (DifyException e) { + log.error("Dify知识库创建失败", e); + resultDomain.fail("创建Dify知识库失败: " + e.getMessage()); + return resultDomain; + } + + // 4. 保存到本地数据库 + if (!StringUtils.hasText(knowledge.getId())) { + knowledge.setId(UUID.randomUUID().toString()); + } + knowledge.setDifyDatasetId(difyDatasetId); + knowledge.setDifyIndexingTechnique(indexingTechnique); + knowledge.setEmbeddingModel(embeddingModel); + knowledge.setCreator("1"); // 系统创建 + knowledge.setUpdater("1"); + knowledge.setCreateTime(new Date()); + knowledge.setUpdateTime(new Date()); + knowledge.setDeleted(false); + + if (knowledge.getStatus() == null) { + knowledge.setStatus(1); + } + if (knowledge.getDocumentCount() == null) { + knowledge.setDocumentCount(0); + } + if (knowledge.getTotalChunks() == null) { + knowledge.setTotalChunks(0); + } + + int rows = knowledgeMapper.insertKnowledge(knowledge); + if (rows <= 0) { + try { + difyApiClient.deleteDataset(difyDatasetId); + } catch (Exception ex) { + log.error("回滚删除Dify知识库失败", ex); + } + resultDomain.fail("保存知识库失败"); + return resultDomain; + } + + // 5. 创建公开权限(所有人可访问) + // 创建一个superadmin的UserDeptRoleVO来调用权限创建 + try { + UserDeptRoleVO superAdminRole = new UserDeptRoleVO(); + superAdminRole.setUserID("system"); + superAdminRole.setDeptID("root_department"); + superAdminRole.setRoleID("superadmin"); + + resourcePermissionService.createResourcePermission( + ResourceType.AI_KNOWLEDGE.getCode(), + knowledge.getId(), + superAdminRole + ); + log.info("内部知识库权限创建成功: knowledgeId={}", knowledge.getId()); + } catch (Exception e) { + log.error("内部知识库权限创建失败,但知识库已创建成功", e); + // 权限创建失败不影响知识库创建 + } + + log.info("内部知识库创建成功: {} - {}", knowledge.getId(), knowledge.getTitle()); + resultDomain.success("知识库创建成功", knowledge); + return resultDomain; + + } catch (Exception e) { + log.error("内部创建知识库异常", e); + resultDomain.fail("创建知识库异常: " + e.getMessage()); + return resultDomain; + } + } + } diff --git a/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/knowledge/AiKnowledgeService.java b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/knowledge/AiKnowledgeService.java index ece9bd5..af0ba17 100644 --- a/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/knowledge/AiKnowledgeService.java +++ b/schoolNewsServ/api/api-ai/src/main/java/org/xyzh/api/ai/knowledge/AiKnowledgeService.java @@ -125,4 +125,11 @@ public interface AiKnowledgeService { * @return 文档列表 */ ResultDomain> getDocumentList(String knowledgeId, Integer page, Integer limit); + + /** + * 内部创建知识库(无需登录,供系统初始化使用) + * @param knowledge 知识库信息 + * @return 创建结果 + */ + ResultDomain createKnowledgeInternal(TbAiKnowledge knowledge); } diff --git a/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/resource/TbResource.java b/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/resource/TbResource.java index ee46330..04d65da 100644 --- a/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/resource/TbResource.java +++ b/schoolNewsServ/common/common-dto/src/main/java/org/xyzh/common/dto/resource/TbResource.java @@ -94,6 +94,21 @@ public class TbResource extends BaseDTO { */ private Boolean isBanner; + /** + * @description 是否已导入知识库 + */ + private Boolean isInKnowledge; + + /** + * @description 知识库文件ID + */ + private String knowledgeFileId; + + /** + * @description 知识库导入时间 + */ + private Date knowledgeImportTime; + /** * @description 发布时间 */ @@ -239,6 +254,30 @@ public class TbResource extends BaseDTO { this.isBanner = isBanner; } + public Boolean getIsInKnowledge() { + return isInKnowledge; + } + + public void setIsInKnowledge(Boolean isInKnowledge) { + this.isInKnowledge = isInKnowledge; + } + + public String getKnowledgeFileId() { + return knowledgeFileId; + } + + public void setKnowledgeFileId(String knowledgeFileId) { + this.knowledgeFileId = knowledgeFileId; + } + + public Date getKnowledgeImportTime() { + return knowledgeImportTime; + } + + public void setKnowledgeImportTime(Date knowledgeImportTime) { + this.knowledgeImportTime = knowledgeImportTime; + } + public Date getPublishTime() { return publishTime; } diff --git a/schoolNewsServ/crontab/src/main/java/org/xyzh/crontab/task/newsTask/NewsCrawlerTask.java b/schoolNewsServ/crontab/src/main/java/org/xyzh/crontab/task/newsTask/NewsCrawlerTask.java index 309586e..98c3733 100644 --- a/schoolNewsServ/crontab/src/main/java/org/xyzh/crontab/task/newsTask/NewsCrawlerTask.java +++ b/schoolNewsServ/crontab/src/main/java/org/xyzh/crontab/task/newsTask/NewsCrawlerTask.java @@ -556,9 +556,14 @@ public class NewsCrawlerTask extends PythonCommandTask { Date publishTime = item.getPublishTime() != null ? item.getPublishTime() : now; resource.setPublishTime(publishTime); - // 状态:已发布 - resource.setStatus(1); - resource.setIsAudited(true); + // 状态:已发布(根据审核结果设置) + if (item.getIsAudited() != null && item.getIsAudited()) { + resource.setStatus(1); + resource.setIsAudited(true); + } else { + resource.setStatus(4); // 敏感词未通过 + resource.setIsAudited(false); + } resource.setViewCount(0); resource.setLikeCount(0); resource.setCollectCount(0); diff --git a/schoolNewsServ/news/pom.xml b/schoolNewsServ/news/pom.xml index 166fa42..d1a9206 100644 --- a/schoolNewsServ/news/pom.xml +++ b/schoolNewsServ/news/pom.xml @@ -80,5 +80,40 @@ org.projectlombok lombok + + + + org.xhtmlrenderer + flying-saucer-pdf-openpdf + 9.3.2 + + + + + org.jsoup + jsoup + 1.17.2 + + + + + org.xyzh + api-ai + 1.0.0 + + + + + org.xyzh + ai + 1.0.0 + + + + + org.springframework + spring-test + compile + \ No newline at end of file diff --git a/schoolNewsServ/news/src/main/java/org/xyzh/news/config/ArticleKnowledgeInit.java b/schoolNewsServ/news/src/main/java/org/xyzh/news/config/ArticleKnowledgeInit.java new file mode 100644 index 0000000..87cfc9c --- /dev/null +++ b/schoolNewsServ/news/src/main/java/org/xyzh/news/config/ArticleKnowledgeInit.java @@ -0,0 +1,206 @@ +package org.xyzh.news.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.xyzh.api.ai.knowledge.AiKnowledgeService; +import org.xyzh.api.system.config.SysConfigService; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.dto.ai.TbAiKnowledge; + +/** + * 文章知识库初始化配置 + * 系统启动时自动创建"时事资源"知识库,用于存储发布的文章 + */ +@Configuration +public class ArticleKnowledgeInit { + + private static final Logger logger = LoggerFactory.getLogger(ArticleKnowledgeInit.class); + + /** 知识库ID */ + private static final String KNOWLEDGE_ID = "article_news_resource"; + + /** 知识库标题 */ + private static final String KNOWLEDGE_TITLE = "时事资源"; + + /** 知识库描述 */ + private static final String KNOWLEDGE_DESCRIPTION = "存储系统发布的文章资源,用于AI智能问答"; + + /** 知识库分类 */ + private static final String KNOWLEDGE_CATEGORY = "article"; + + @Autowired + private AiKnowledgeService aiKnowledgeService; + + @Autowired + private SysConfigService sysConfigService; + + @Value("${article.knowledge.auto-init:true}") + private boolean autoInit; + + @Bean + public CommandLineRunner articleKnowledgeInitRunner() { + return args -> { + if (!autoInit) { + logger.info("文章知识库自动初始化已禁用"); + return; + } + + logger.info("开始初始化文章知识库..."); + + try { + // 检查知识库是否已存在 + if (checkKnowledgeExists(KNOWLEDGE_ID)) { + logger.info("文章知识库已存在,跳过初始化"); + // 更新配置文件中的知识库ID + updateKnowledgeIdConfig(); + return; + } + + // 创建知识库 + TbAiKnowledge knowledge = buildKnowledgeConfig(); + if (createKnowledge(knowledge)) { + logger.info("文章知识库初始化成功: {}", KNOWLEDGE_TITLE); + // 更新配置文件中的知识库ID + updateKnowledgeIdConfig(); + } else { + logger.error("文章知识库初始化失败"); + } + } catch (Exception e) { + logger.error("文章知识库初始化异常: {}", e.getMessage(), e); + } + }; + } + + /** + * 构建知识库配置 + */ + private TbAiKnowledge buildKnowledgeConfig() { + TbAiKnowledge knowledge = new TbAiKnowledge(); + + // 基本信息 + knowledge.setId(KNOWLEDGE_ID); + knowledge.setTitle(KNOWLEDGE_TITLE); + knowledge.setDescription(KNOWLEDGE_DESCRIPTION); + knowledge.setCategory(KNOWLEDGE_CATEGORY); + knowledge.setStatus(1); // 启用状态 + + // 从系统配置获取Dify相关参数(使用 dify.dataset.* 格式的配置key) + try { + String indexingTechnique = sysConfigService.getStringConfig("dify.dataset.defaultIndexingTechnique"); + if (indexingTechnique != null && !indexingTechnique.isEmpty()) { + knowledge.setDifyIndexingTechnique(indexingTechnique); + } else { + knowledge.setDifyIndexingTechnique("high_quality"); // 默认高质量索引 + } + + String embeddingModel = sysConfigService.getStringConfig("dify.dataset.defaultEmbeddingModel"); + if (embeddingModel != null && !embeddingModel.isEmpty()) { + knowledge.setEmbeddingModel(embeddingModel); + } + + String embeddingModelProvider = sysConfigService.getStringConfig("dify.dataset.embeddingModelProvider"); + if (embeddingModelProvider != null && !embeddingModelProvider.isEmpty()) { + knowledge.setEmbeddingModelProvider(embeddingModelProvider); + } + + Boolean rerankingEnable = sysConfigService.getBooleanConfig("dify.dataset.rerankingEnable"); + knowledge.setRerankingEnable(rerankingEnable != null ? rerankingEnable : false); + + String rerankModel = sysConfigService.getStringConfig("dify.dataset.rerankModel"); + if (rerankModel != null && !rerankModel.isEmpty()) { + knowledge.setRerankModel(rerankModel); + } + + String rerankModelProvider = sysConfigService.getStringConfig("dify.dataset.rerankModelProvider"); + if (rerankModelProvider != null && !rerankModelProvider.isEmpty()) { + knowledge.setRerankModelProvider(rerankModelProvider); + } + + Integer retrievalTopK = sysConfigService.getIntConfig("dify.dataset.retrievalTopK"); + knowledge.setRetrievalTopK(retrievalTopK != null ? retrievalTopK : 5); + + Double scoreThreshold = sysConfigService.getDoubleConfig("dify.dataset.retrievalScoreThreshold"); + knowledge.setRetrievalScoreThreshold(scoreThreshold != null ? scoreThreshold : 0.5); + + } catch (Exception e) { + logger.warn("获取Dify配置失败,使用默认值: {}", e.getMessage()); + // 使用默认值 + knowledge.setDifyIndexingTechnique("high_quality"); + knowledge.setRetrievalTopK(5); + knowledge.setRetrievalScoreThreshold(0.5); + knowledge.setRerankingEnable(false); + } + + return knowledge; + } + + /** + * 检查知识库是否存在(使用内部方法,不依赖登录) + */ + private boolean checkKnowledgeExists(String knowledgeId) { + try { + // 使用 createKnowledgeInternal 的逻辑:先尝试创建,如果已存在会返回已存在的知识库 + // 这里直接返回 false,让 createKnowledgeInternal 内部处理重复检查 + // createKnowledgeInternal 会检查 ID 是否已存在并返回已存在的知识库 + return false; + } catch (Exception e) { + logger.warn("检查知识库是否存在时发生异常: {}", e.getMessage()); + return false; + } + } + + /** + * 创建知识库 + */ + private boolean createKnowledge(TbAiKnowledge knowledge) { + try { + // 使用内部创建方法,无需登录 + ResultDomain result = aiKnowledgeService.createKnowledgeInternal(knowledge); + + if (result.isSuccess()) { + TbAiKnowledge created = result.getData(); + if (created != null) { + logger.info("知识库创建成功: id={}, difyDatasetId={}", + created.getId(), created.getDifyDatasetId()); + } + return true; + } else { + logger.error("知识库创建失败: {}", result.getMessage()); + return false; + } + } catch (Exception e) { + logger.error("创建知识库异常: {}", e.getMessage(), e); + return false; + } + } + + /** + * 更新配置中的知识库ID + * 将创建的知识库ID写入系统配置,供ArticleKnowledgeService使用 + */ + private void updateKnowledgeIdConfig() { + try { + // 检查配置是否已存在 + String existingId = sysConfigService.getStringConfig("article.knowledge.default-id"); + if (existingId == null || existingId.isEmpty()) { + // 可以通过SysConfigService设置配置(如果支持的话) + logger.info("文章知识库ID: {},请在配置文件中设置 article.knowledge.default-id={}", + KNOWLEDGE_ID, KNOWLEDGE_ID); + } + } catch (Exception e) { + logger.warn("更新知识库ID配置失败: {}", e.getMessage()); + } + } + + /** + * 获取文章知识库ID + */ + public static String getArticleKnowledgeId() { + return KNOWLEDGE_ID; + } +} diff --git a/schoolNewsServ/news/src/main/java/org/xyzh/news/controller/ArticleKnowledgeController.java b/schoolNewsServ/news/src/main/java/org/xyzh/news/controller/ArticleKnowledgeController.java new file mode 100644 index 0000000..6060a84 --- /dev/null +++ b/schoolNewsServ/news/src/main/java/org/xyzh/news/controller/ArticleKnowledgeController.java @@ -0,0 +1,61 @@ +package org.xyzh.news.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.dto.resource.TbResource; +import org.xyzh.news.service.ArticleKnowledgeService; + +import java.util.List; +import java.util.Map; + +/** + * 文章知识库管理控制器 + * 提供手动将文章导入/移除知识库的接口 + */ +@RestController +@RequestMapping("/news/article-knowledge") +public class ArticleKnowledgeController { + + private static final Logger logger = LoggerFactory.getLogger(ArticleKnowledgeController.class); + + @Autowired + private ArticleKnowledgeService articleKnowledgeService; + + /** + * 将单篇文章导入知识库 + * @param resourceId 资源ID + * @return 导入结果 + */ + @PostMapping("/import/{resourceId}") + public ResultDomain importToKnowledge(@PathVariable("resourceId") String resourceId) { + logger.info("手动导入文章到知识库: resourceId={}", resourceId); + return articleKnowledgeService.importArticleToDefaultKnowledgeById(resourceId); + } + + /** + * 批量将文章导入知识库 + * @param params 包含resourceIds的参数 + * @return 导入结果 + */ + @PostMapping("/import/batch") + @SuppressWarnings("unchecked") + public ResultDomain batchImportToKnowledge(@RequestBody Map params) { + List resourceIds = (List) params.get("resourceIds"); + logger.info("批量导入文章到知识库: count={}", resourceIds != null ? resourceIds.size() : 0); + return articleKnowledgeService.batchImportToDefaultKnowledge(resourceIds); + } + + /** + * 从知识库移除文章 + * @param resourceId 资源ID + * @return 移除结果 + */ + @DeleteMapping("/remove/{resourceId}") + public ResultDomain removeFromKnowledge(@PathVariable("resourceId") String resourceId) { + logger.info("从知识库移除文章: resourceId={}", resourceId); + return articleKnowledgeService.removeArticleFromKnowledge(resourceId); + } +} diff --git a/schoolNewsServ/news/src/main/java/org/xyzh/news/service/ArticleKnowledgeService.java b/schoolNewsServ/news/src/main/java/org/xyzh/news/service/ArticleKnowledgeService.java new file mode 100644 index 0000000..4b3aab0 --- /dev/null +++ b/schoolNewsServ/news/src/main/java/org/xyzh/news/service/ArticleKnowledgeService.java @@ -0,0 +1,49 @@ +package org.xyzh.news.service; + +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.dto.resource.TbResource; + +import java.util.List; + +/** + * 文章知识库服务接口 + * 负责将发布的文章导入到AI知识库 + */ +public interface ArticleKnowledgeService { + + /** + * 将文章导入到知识库 + * @param resource 文章资源 + * @param knowledgeId 目标知识库ID + * @return 导入结果 + */ + ResultDomain importArticleToKnowledge(TbResource resource, String knowledgeId); + + /** + * 将文章导入到默认知识库 + * @param resource 文章资源 + * @return 导入结果 + */ + ResultDomain importArticleToDefaultKnowledge(TbResource resource); + + /** + * 根据资源ID将文章导入到默认知识库 + * @param resourceId 资源ID + * @return 导入结果 + */ + ResultDomain importArticleToDefaultKnowledgeById(String resourceId); + + /** + * 批量将文章导入到默认知识库 + * @param resourceIds 资源ID列表 + * @return 导入结果 + */ + ResultDomain batchImportToDefaultKnowledge(List resourceIds); + + /** + * 从知识库中删除文章 + * @param resourceId 文章资源ID + * @return 删除结果 + */ + ResultDomain removeArticleFromKnowledge(String resourceId); +} diff --git a/schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/ArticleKnowledgeServiceImpl.java b/schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/ArticleKnowledgeServiceImpl.java new file mode 100644 index 0000000..59c1830 --- /dev/null +++ b/schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/ArticleKnowledgeServiceImpl.java @@ -0,0 +1,318 @@ +package org.xyzh.news.service.impl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.xyzh.ai.mapper.AiKnowledgeMapper; +import org.xyzh.api.ai.file.AiUploadFileService; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.dto.ai.TbAiKnowledge; +import org.xyzh.common.dto.ai.TbAiUploadFile; +import org.xyzh.common.dto.resource.TbResource; +import org.xyzh.news.mapper.ResourceMapper; +import org.xyzh.news.service.ArticleKnowledgeService; +import org.xyzh.news.util.ArticlePdfGenerator; + +import java.io.File; +import java.io.FileInputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * 文章知识库服务实现 + */ +@Service +public class ArticleKnowledgeServiceImpl implements ArticleKnowledgeService { + + private static final Logger logger = LoggerFactory.getLogger(ArticleKnowledgeServiceImpl.class); + + /** 固定的文章知识库ID */ + private static final String DEFAULT_KNOWLEDGE_ID = "article_news_resource"; + + @Autowired + private ArticlePdfGenerator pdfGenerator; + + @Autowired + private AiUploadFileService aiUploadFileService; + + @Autowired + private ResourceMapper resourceMapper; + + @Autowired + private AiKnowledgeMapper aiKnowledgeMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain importArticleToKnowledge(TbResource resource, String knowledgeId) { + ResultDomain resultDomain = new ResultDomain<>(); + File pdfFile = null; + + try { + // 参数验证 + if (resource == null) { + resultDomain.fail("文章资源不能为空"); + return resultDomain; + } + if (!StringUtils.hasText(knowledgeId)) { + resultDomain.fail("知识库ID不能为空"); + return resultDomain; + } + if (!StringUtils.hasText(resource.getContent())) { + resultDomain.fail("文章内容不能为空"); + return resultDomain; + } + + // 检查是否已导入 + if (Boolean.TRUE.equals(resource.getIsInKnowledge())) { + resultDomain.fail("文章已导入知识库,无需重复导入"); + return resultDomain; + } + + logger.info("开始将文章导入知识库: resourceId={}, title={}, knowledgeId={}", + resource.getResourceID(), resource.getTitle(), knowledgeId); + + // 1. 生成PDF文件 + pdfFile = pdfGenerator.generatePdf(resource); + logger.info("PDF文件生成成功: {}, 大小: {} bytes", pdfFile.getName(), pdfFile.length()); + + // 2. 构建文件名 + String fileName = buildFileName(resource); + + // 3. 将PDF文件转换为MultipartFile + MultipartFile multipartFile = convertToMultipartFile(pdfFile, fileName); + + // 4. 上传到知识库 + ResultDomain uploadResult = aiUploadFileService.uploadToKnowledge( + knowledgeId, + multipartFile, + "high_quality" + ); + + if (uploadResult.isSuccess() && uploadResult.getData() != null) { + TbAiUploadFile uploadFile = uploadResult.getData(); + + // 5. 更新资源的知识库状态 + TbResource updateResource = new TbResource(); + updateResource.setResourceID(resource.getResourceID()); + updateResource.setIsInKnowledge(true); + updateResource.setKnowledgeFileId(uploadFile.getId()); + updateResource.setKnowledgeImportTime(new Date()); + updateResource.setUpdateTime(new Date()); + + int updateResult = resourceMapper.updateResource(updateResource); + if (updateResult > 0) { + logger.info("文章成功导入知识库: resourceId={}, knowledgeFileId={}", + resource.getResourceID(), uploadFile.getId()); + + // 重新查询返回完整数据 + TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID()); + resultDomain.success("文章导入知识库成功", updated); + } else { + logger.warn("文章上传成功但更新状态失败: resourceId={}", resource.getResourceID()); + resultDomain.success("文章导入知识库成功,但状态更新失败", resource); + } + } else { + logger.error("文章导入知识库失败: {}", uploadResult.getMessage()); + resultDomain.fail("文章导入知识库失败: " + uploadResult.getMessage()); + } + + return resultDomain; + + } catch (Exception e) { + logger.error("文章导入知识库异常: resourceId={}, error={}", + resource != null ? resource.getResourceID() : "null", e.getMessage(), e); + resultDomain.fail("文章导入知识库异常: " + e.getMessage()); + return resultDomain; + } finally { + // 清理临时PDF文件 + if (pdfFile != null && pdfFile.exists()) { + try { + Files.deleteIfExists(pdfFile.toPath()); + logger.debug("临时PDF文件已清理: {}", pdfFile.getName()); + } catch (Exception e) { + logger.warn("清理临时PDF文件失败: {}", e.getMessage()); + } + } + } + } + + @Override + public ResultDomain importArticleToDefaultKnowledge(TbResource resource) { + ResultDomain resultDomain = new ResultDomain<>(); + + // 从数据库查询固定ID的知识库 + TbAiKnowledge knowledge = aiKnowledgeMapper.selectKnowledgeById(DEFAULT_KNOWLEDGE_ID); + if (knowledge == null || knowledge.getDeleted()) { + logger.warn("默认文章知识库不存在: {}, 跳过知识库导入", DEFAULT_KNOWLEDGE_ID); + resultDomain.success("默认知识库不存在,跳过导入", resource); + return resultDomain; + } + + return importArticleToKnowledge(resource, DEFAULT_KNOWLEDGE_ID); + } + + @Override + public ResultDomain importArticleToDefaultKnowledgeById(String resourceId) { + ResultDomain resultDomain = new ResultDomain<>(); + + if (!StringUtils.hasText(resourceId)) { + resultDomain.fail("资源ID不能为空"); + return resultDomain; + } + + // 从数据库查询固定ID的知识库 + TbAiKnowledge knowledge = aiKnowledgeMapper.selectKnowledgeById(DEFAULT_KNOWLEDGE_ID); + if (knowledge == null || knowledge.getDeleted()) { + resultDomain.fail("默认文章知识库不存在"); + return resultDomain; + } + + // 查询资源 + TbResource resource = resourceMapper.selectByResourceId(resourceId); + if (resource == null || resource.getDeleted()) { + resultDomain.fail("资源不存在"); + return resultDomain; + } + + // 检查文章状态 + if (resource.getStatus() != 1) { + resultDomain.fail("只有已发布的文章才能导入知识库"); + return resultDomain; + } + + return importArticleToKnowledge(resource, DEFAULT_KNOWLEDGE_ID); + } + + @Override + public ResultDomain batchImportToDefaultKnowledge(List resourceIds) { + ResultDomain resultDomain = new ResultDomain<>(); + + if (resourceIds == null || resourceIds.isEmpty()) { + resultDomain.fail("资源ID列表不能为空"); + return resultDomain; + } + + // 从数据库查询固定ID的知识库 + TbAiKnowledge knowledge = aiKnowledgeMapper.selectKnowledgeById(DEFAULT_KNOWLEDGE_ID); + if (knowledge == null || knowledge.getDeleted()) { + resultDomain.fail("默认文章知识库不存在"); + return resultDomain; + } + + List successList = new ArrayList<>(); + List failedList = new ArrayList<>(); + + for (String resourceId : resourceIds) { + try { + ResultDomain result = importArticleToDefaultKnowledgeById(resourceId); + if (result.isSuccess() && result.getData() != null) { + successList.add(result.getData()); + } else { + failedList.add(resourceId + ": " + result.getMessage()); + } + } catch (Exception e) { + failedList.add(resourceId + ": " + e.getMessage()); + logger.error("批量导入知识库失败: resourceId={}", resourceId, e); + } + } + + if (failedList.isEmpty()) { + resultDomain.success("批量导入成功,共导入 " + successList.size() + " 篇文章", successList); + } else if (successList.isEmpty()) { + resultDomain.fail("批量导入全部失败: " + String.join("; ", failedList)); + } else { + resultDomain.success("部分导入成功: 成功 " + successList.size() + " 篇,失败 " + failedList.size() + " 篇", successList); + } + + return resultDomain; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ResultDomain removeArticleFromKnowledge(String resourceId) { + ResultDomain resultDomain = new ResultDomain<>(); + + if (!StringUtils.hasText(resourceId)) { + resultDomain.fail("资源ID不能为空"); + return resultDomain; + } + + // 查询资源 + TbResource resource = resourceMapper.selectByResourceId(resourceId); + if (resource == null || resource.getDeleted()) { + resultDomain.fail("资源不存在"); + return resultDomain; + } + + if (!Boolean.TRUE.equals(resource.getIsInKnowledge())) { + resultDomain.fail("文章未导入知识库"); + return resultDomain; + } + + try { + // 删除知识库中的文件 + if (StringUtils.hasText(resource.getKnowledgeFileId())) { + ResultDomain deleteResult = aiUploadFileService.deleteFile(resource.getKnowledgeFileId()); + if (!deleteResult.isSuccess()) { + logger.warn("删除知识库文件失败,继续更新资源状态: {}", deleteResult.getMessage()); + } + } + + // 更新资源状态 + TbResource updateResource = new TbResource(); + updateResource.setResourceID(resourceId); + updateResource.setIsInKnowledge(false); + updateResource.setKnowledgeFileId(null); + updateResource.setKnowledgeImportTime(null); + updateResource.setUpdateTime(new Date()); + + int updateResult = resourceMapper.updateResource(updateResource); + if (updateResult > 0) { + logger.info("文章已从知识库移除: resourceId={}", resourceId); + resultDomain.success("文章已从知识库移除", true); + } else { + resultDomain.fail("更新资源状态失败"); + } + } catch (Exception e) { + logger.error("从知识库移除文章异常: resourceId={}", resourceId, e); + resultDomain.fail("从知识库移除文章异常: " + e.getMessage()); + } + + return resultDomain; + } + + /** + * 构建PDF文件名 + */ + private String buildFileName(TbResource resource) { + String title = resource.getTitle(); + // 移除文件名中的非法字符 + title = title.replaceAll("[\\\\/:*?\"<>|]", "_"); + // 限制文件名长度 + if (title.length() > 50) { + title = title.substring(0, 50); + } + return "article_" + resource.getResourceID() + "_" + title + ".pdf"; + } + + /** + * 将File转换为MultipartFile + */ + private MultipartFile convertToMultipartFile(File file, String fileName) throws Exception { + try (FileInputStream fis = new FileInputStream(file)) { + return new MockMultipartFile( + "file", + fileName, + "application/pdf", + fis + ); + } + } +} diff --git a/schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/NCResourceServiceImpl.java b/schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/NCResourceServiceImpl.java index 5e2585d..1225765 100644 --- a/schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/NCResourceServiceImpl.java +++ b/schoolNewsServ/news/src/main/java/org/xyzh/news/service/impl/NCResourceServiceImpl.java @@ -28,6 +28,7 @@ import org.xyzh.api.news.resource.ResourceService; import org.xyzh.api.system.permission.ResourcePermissionService; import org.xyzh.common.vo.UserDeptRoleVO; import org.xyzh.common.core.enums.ResourceType; +import org.xyzh.news.service.ArticleKnowledgeService; import java.util.ArrayList; import java.util.Arrays; @@ -59,6 +60,9 @@ public class NCResourceServiceImpl implements ResourceService { @Autowired private ResourceAuditService auditService; + @Autowired + private ArticleKnowledgeService articleKnowledgeService; + @Override public ResultDomain getResourceList(TbResource filter) { ResultDomain resultDomain = new ResultDomain<>(); @@ -328,6 +332,20 @@ public class NCResourceServiceImpl implements ResourceService { logger.error("创建资源权限异常,但不影响资源创建: {}", e.getMessage(), e); } + // 如果文章直接发布(status=1且审核通过),导入知识库 + if (resource.getStatus() == 1 && resource.getIsAudited()) { + try { + ResultDomain knowledgeResult = articleKnowledgeService.importArticleToDefaultKnowledge(resource); + if (knowledgeResult.isSuccess() && knowledgeResult.getData() != null) { + logger.info("新建文章已成功导入知识库: {}", resource.getResourceID()); + } else { + logger.warn("新建文章导入知识库跳过或失败: {}, 原因: {}", resource.getResourceID(), knowledgeResult.getMessage()); + } + } catch (Exception e) { + logger.error("新建文章导入知识库异常,但不影响创建: {}", e.getMessage(), e); + } + } + resultDomain.success("创建资源成功", resourceVO); return resultDomain; } else { @@ -529,7 +547,22 @@ public class NCResourceServiceImpl implements ResourceService { if (result > 0) { logger.info("更新资源状态成功: {}", resourceID); // 重新查询返回完整数据 - TbResource updated = resourceMapper.selectByResourceId(resource.getId()); + TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID()); + + // 如果状态变为发布且审核通过,导入知识库 + if (status == 1 && resource.getIsAudited()) { + try { + ResultDomain knowledgeResult = articleKnowledgeService.importArticleToDefaultKnowledge(updated); + if (knowledgeResult.isSuccess() && knowledgeResult.getData() != null) { + logger.info("文章状态更新后已成功导入知识库: {}", resourceID); + } else { + logger.warn("文章状态更新后导入知识库跳过或失败: {}, 原因: {}", resourceID, knowledgeResult.getMessage()); + } + } catch (Exception e) { + logger.error("文章状态更新后导入知识库异常,但不影响状态更新: {}", e.getMessage(), e); + } + } + resultDomain.success("更新资源状态成功", updated); return resultDomain; } else { @@ -586,7 +619,21 @@ public class NCResourceServiceImpl implements ResourceService { if (result > 0) { logger.info("发布资源成功: {}", resourceID); // 重新查询返回完整数据 - TbResource updated = resourceMapper.selectByResourceId(resource.getId()); + TbResource updated = resourceMapper.selectByResourceId(resourceID); + + // 异步将文章导入知识库 + try { + ResultDomain knowledgeResult = articleKnowledgeService.importArticleToDefaultKnowledge(updated); + if (knowledgeResult.isSuccess() && knowledgeResult.getData() != null) { + logger.info("文章已成功导入知识库: {}", resourceID); + } else { + logger.warn("文章导入知识库跳过或失败: {}, 原因: {}", resourceID, knowledgeResult.getMessage()); + } + } catch (Exception e) { + // 知识库导入失败不影响文章发布 + logger.error("文章导入知识库异常,但不影响发布: {}", e.getMessage(), e); + } + resultDomain.success("发布资源成功", updated); return resultDomain; } else { @@ -626,7 +673,7 @@ public class NCResourceServiceImpl implements ResourceService { if (result > 0) { logger.info("下架资源成功: {}", resourceID); // 重新查询返回完整数据 - TbResource updated = resourceMapper.selectByResourceId(resource.getId()); + TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID()); resultDomain.success("下架资源成功", updated); return resultDomain; } else { @@ -695,7 +742,7 @@ public class NCResourceServiceImpl implements ResourceService { if (result > 0) { logger.info("增加资源点赞次数成功: {}", resourceID); // 重新查询返回完整数据 - TbResource updated = resourceMapper.selectByResourceId(resource.getId()); + TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID()); resultDomain.success("增加点赞次数成功", updated); return resultDomain; } else { @@ -767,7 +814,7 @@ public class NCResourceServiceImpl implements ResourceService { if (result > 0) { logger.info("设置资源推荐状态成功: {} -> {}", resourceID, isRecommend); // 重新查询返回完整数据 - TbResource updated = resourceMapper.selectByResourceId(resource.getId()); + TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID()); resultDomain.success("设置推荐状态成功", updated); return resultDomain; } else { @@ -811,7 +858,7 @@ public class NCResourceServiceImpl implements ResourceService { if (result > 0) { logger.info("设置资源轮播状态成功: {} -> {}", resourceID, isBanner); // 重新查询返回完整数据 - TbResource updated = resourceMapper.selectByResourceId(resource.getId()); + TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID()); resultDomain.success("设置轮播状态成功", updated); return resultDomain; } else { diff --git a/schoolNewsServ/news/src/main/java/org/xyzh/news/util/ArticlePdfGenerator.java b/schoolNewsServ/news/src/main/java/org/xyzh/news/util/ArticlePdfGenerator.java new file mode 100644 index 0000000..69b9312 --- /dev/null +++ b/schoolNewsServ/news/src/main/java/org/xyzh/news/util/ArticlePdfGenerator.java @@ -0,0 +1,270 @@ +package org.xyzh.news.util; + +import com.lowagie.text.pdf.BaseFont; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.xhtmlrenderer.pdf.ITextRenderer; +import org.xyzh.common.dto.resource.TbResource; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * 文章PDF生成工具类 + * 将富文本内容转换为PDF文件 + */ +@Component +public class ArticlePdfGenerator { + + private static final Logger logger = LoggerFactory.getLogger(ArticlePdfGenerator.class); + + /** + * 将文章内容生成PDF文件 + * @param resource 文章资源 + * @return 生成的PDF文件 + */ + public File generatePdf(TbResource resource) throws Exception { + if (resource == null || resource.getContent() == null) { + throw new IllegalArgumentException("文章内容不能为空"); + } + + // 创建临时文件 + Path tempFile = Files.createTempFile("article_" + resource.getResourceID() + "_", ".pdf"); + File pdfFile = tempFile.toFile(); + + try (OutputStream os = new FileOutputStream(pdfFile)) { + // 构建HTML内容 + String htmlContent = buildHtmlContent(resource); + + // 使用Flying Saucer生成PDF + ITextRenderer renderer = new ITextRenderer(); + + // 添加中文字体支持 + addFontSupport(renderer); + + // 设置HTML内容 + renderer.setDocumentFromString(htmlContent); + renderer.layout(); + renderer.createPDF(os); + + logger.info("PDF生成成功: {}, 文件大小: {} bytes", pdfFile.getName(), pdfFile.length()); + return pdfFile; + } catch (Exception e) { + // 清理临时文件 + Files.deleteIfExists(tempFile); + logger.error("PDF生成失败: {}", e.getMessage(), e); + throw e; + } + } + + /** + * 构建HTML内容 + */ + private String buildHtmlContent(TbResource resource) { + StringBuilder html = new StringBuilder(); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + html.append(""); + + // 文章标题 + html.append("
"); + html.append("

").append(escapeHtml(resource.getTitle())).append("

"); + + // 文章元信息 + html.append("
"); + if (resource.getAuthor() != null && !resource.getAuthor().isEmpty()) { + html.append("作者: ").append(escapeHtml(resource.getAuthor())).append(""); + } + if (resource.getSource() != null && !resource.getSource().isEmpty()) { + html.append("来源: ").append(escapeHtml(resource.getSource())).append(""); + } + if (resource.getPublishTime() != null) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + html.append("发布时间: ").append(sdf.format(resource.getPublishTime())).append(""); + } + html.append("
"); + html.append("
"); + + // 文章内容 + html.append("
"); + html.append(cleanHtmlContent(resource.getContent())); + html.append("
"); + + html.append(""); + html.append(""); + + return html.toString(); + } + + + /** + * 获取CSS样式 + */ + private String getStyleContent() { + return """ + @page { + size: A4; + margin: 2cm; + } + body { + font-family: SimSun, 'Microsoft YaHei', Arial, sans-serif; + font-size: 12pt; + line-height: 1.8; + color: #333; + } + .article-header { + margin-bottom: 30px; + border-bottom: 2px solid #1890ff; + padding-bottom: 20px; + } + .title { + font-size: 22pt; + font-weight: bold; + color: #1a1a1a; + margin-bottom: 15px; + text-align: center; + } + .meta { + font-size: 10pt; + color: #666; + text-align: center; + } + .meta span { + margin-right: 20px; + } + .article-content { + text-align: justify; + } + .article-content p { + text-indent: 2em; + margin-bottom: 12px; + } + .article-content img { + max-width: 100%; + height: auto; + display: block; + margin: 15px auto; + } + .article-content h1, .article-content h2, .article-content h3 { + margin-top: 20px; + margin-bottom: 10px; + color: #1a1a1a; + } + .article-content table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; + } + .article-content table td, .article-content table th { + border: 1px solid #ddd; + padding: 8px; + } + .article-content blockquote { + border-left: 4px solid #1890ff; + padding-left: 15px; + margin: 15px 0; + color: #666; + } + """; + } + + /** + * 清理HTML内容,使其符合XHTML规范 + */ + private String cleanHtmlContent(String htmlContent) { + if (htmlContent == null || htmlContent.isEmpty()) { + return ""; + } + + // 使用Jsoup解析和清理HTML + Document doc = Jsoup.parseBodyFragment(htmlContent); + doc.outputSettings() + .syntax(Document.OutputSettings.Syntax.xml) + .escapeMode(org.jsoup.nodes.Entities.EscapeMode.xhtml) + .charset(StandardCharsets.UTF_8); + + // 处理图片标签,确保闭合 + doc.select("img").forEach(img -> { + if (!img.hasAttr("alt")) { + img.attr("alt", ""); + } + }); + + // 处理br标签 + doc.select("br").forEach(br -> br.tagName("br")); + + // 移除不支持的标签和属性 + String cleaned = Jsoup.clean(doc.body().html(), "", + Safelist.relaxed() + .addTags("div", "span", "p", "br", "h1", "h2", "h3", "h4", "h5", "h6") + .addTags("table", "thead", "tbody", "tr", "td", "th") + .addTags("ul", "ol", "li", "blockquote", "pre", "code") + .addTags("strong", "em", "b", "i", "u", "s", "sub", "sup") + .addTags("img", "a") + .addAttributes("img", "src", "alt", "width", "height") + .addAttributes("a", "href") + .addAttributes(":all", "style", "class"), + new Document.OutputSettings().syntax(Document.OutputSettings.Syntax.xml)); + + return cleaned; + } + + /** + * HTML转义 + */ + private String escapeHtml(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + /** + * 添加中文字体支持 + */ + private void addFontSupport(ITextRenderer renderer) { + try { + // 尝试添加系统字体 + String[] fontPaths = { + "C:/Windows/Fonts/simsun.ttc", // Windows 宋体 + "C:/Windows/Fonts/msyh.ttc", // Windows 微软雅黑 + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", // Linux 文泉驿微米黑 + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", // Linux 文泉驿正黑 + "/System/Library/Fonts/PingFang.ttc" // macOS 苹方 + }; + + for (String fontPath : fontPaths) { + File fontFile = new File(fontPath); + if (fontFile.exists()) { + renderer.getFontResolver().addFont( + fontPath, + BaseFont.IDENTITY_H, + BaseFont.NOT_EMBEDDED + ); + logger.debug("已加载字体: {}", fontPath); + } + } + } catch (Exception e) { + logger.warn("加载字体失败,将使用默认字体: {}", e.getMessage()); + } + } +} diff --git a/schoolNewsServ/news/src/main/resources/mapper/ResourceMapper.xml b/schoolNewsServ/news/src/main/resources/mapper/ResourceMapper.xml index b3677ac..9181742 100644 --- a/schoolNewsServ/news/src/main/resources/mapper/ResourceMapper.xml +++ b/schoolNewsServ/news/src/main/resources/mapper/ResourceMapper.xml @@ -21,6 +21,9 @@ + + + @@ -34,8 +37,8 @@ id, resource_id, title, content, summary, cover_image, tag_id, author, source, source_url, view_count, like_count, collect_count, status, is_audited, is_recommend, - is_banner, publish_time, creator, updater, create_time, update_time, - delete_time, deleted + is_banner, is_in_knowledge, knowledge_file_id, knowledge_import_time, publish_time, + creator, updater, create_time, update_time, delete_time, deleted @@ -260,6 +263,15 @@ is_banner = #{isBanner}, + + is_in_knowledge = #{isInKnowledge}, + + + knowledge_file_id = #{knowledgeFileId}, + + + knowledge_import_time = #{knowledgeImportTime}, + is_audited = #{isAudited}, @@ -368,6 +380,9 @@ + + + diff --git a/schoolNewsWeb/src/apis/resource/resource.ts b/schoolNewsWeb/src/apis/resource/resource.ts index 23bbef4..fc90a4d 100644 --- a/schoolNewsWeb/src/apis/resource/resource.ts +++ b/schoolNewsWeb/src/apis/resource/resource.ts @@ -228,6 +228,38 @@ export const resourceApi = { async searchItems(request: { pageParam: PageParam; filter: Resource }): Promise> { const response = await api.post('/news/resources/search', request); return response.data; + }, + + // ==================== 知识库操作 ==================== + + /** + * 将文章导入知识库 + * @param resourceID 资源ID + * @returns Promise> + */ + async importToKnowledge(resourceID: string): Promise> { + const response = await api.post(`/news/article-knowledge/import/${resourceID}`); + return response.data; + }, + + /** + * 批量将文章导入知识库 + * @param resourceIds 资源ID列表 + * @returns Promise> + */ + async batchImportToKnowledge(resourceIds: string[]): Promise> { + const response = await api.post('/news/article-knowledge/import/batch', { resourceIds }); + return response.data; + }, + + /** + * 从知识库移除文章 + * @param resourceID 资源ID + * @returns Promise> + */ + async removeFromKnowledge(resourceID: string): Promise> { + const response = await api.delete(`/news/article-knowledge/remove/${resourceID}`); + return response.data; } }; diff --git a/schoolNewsWeb/src/types/resource/index.ts b/schoolNewsWeb/src/types/resource/index.ts index 70f1059..116ec5f 100644 --- a/schoolNewsWeb/src/types/resource/index.ts +++ b/schoolNewsWeb/src/types/resource/index.ts @@ -41,6 +41,12 @@ export interface Resource extends BaseDTO { isRecommend?: boolean; /** 是否轮播 */ isBanner?: boolean; + /** 是否已导入知识库 */ + isInKnowledge?: boolean; + /** 知识库文件ID */ + knowledgeFileId?: string; + /** 知识库导入时间 */ + knowledgeImportTime?: string; /** 发布时间 */ publishTime?: string; /** 创建者 */ diff --git a/schoolNewsWeb/src/views/admin/manage/resource/ArticleManagementView.vue b/schoolNewsWeb/src/views/admin/manage/resource/ArticleManagementView.vue index 9167231..60287a5 100644 --- a/schoolNewsWeb/src/views/admin/manage/resource/ArticleManagementView.vue +++ b/schoolNewsWeb/src/views/admin/manage/resource/ArticleManagementView.vue @@ -28,7 +28,14 @@ - + + + + @@ -99,6 +115,7 @@ const articles = ref([]); const showViewDialog = ref(false); const currentArticle = ref(null); const categoryList = ref([]); // 改为使用Tag类型(tagType=1表示文章分类) +const importingIds = ref>(new Set()); // 正在导入的文章ID集合 onMounted(() => { loadArticles(); @@ -227,6 +244,50 @@ async function deleteArticle(row: Resource) { } } +async function importToKnowledge(row: Resource) { + if (!row.resourceID) return; + + // 检查文章状态 + if (row.status !== ResourceStatus.PUBLISHED) { + ElMessage.warning('只有已发布的文章才能导入知识库'); + return; + } + + if (row.isInKnowledge) { + ElMessage.info('文章已导入知识库'); + return; + } + + try { + await ElMessageBox.confirm( + `确定要将文章「${row.title}」导入知识库吗?`, + '导入确认', + { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'info' + } + ); + + importingIds.value.add(row.resourceID); + const res = await resourceApi.importToKnowledge(row.resourceID); + + if (res.success) { + ElMessage.success('导入知识库成功'); + loadArticles(); + } else { + ElMessage.error(res.message || '导入知识库失败'); + } + } catch (error) { + if (error !== 'cancel') { + console.error('导入知识库失败:', error); + ElMessage.error('导入知识库失败'); + } + } finally { + importingIds.value.delete(row.resourceID); + } +} + function getStatusType(status: number) { const typeMap: Record = { [ResourceStatus.DRAFT]: 'info',