文章导入知识库

This commit is contained in:
2026-01-12 13:52:19 +08:00
parent 26df740dd0
commit 12dca45b4d
20 changed files with 1371 additions and 15 deletions

View File

@@ -148,6 +148,14 @@ crontab:
dify: dify:
knowledgeApiKey: dataset-nupqKP4LONpzdXmGthIrbjeJ knowledgeApiKey: dataset-nupqKP4LONpzdXmGthIrbjeJ
# 文章知识库配置
article:
knowledge:
# 默认文章知识库ID自动初始化后会使用此ID
default-id: article_news_resource
# 是否自动初始化知识库(首次启动时创建)
auto-init: true
# 文件存储配置 # 文件存储配置
file: file:
storage: storage:

View File

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

View File

@@ -19,6 +19,9 @@ CREATE TABLE `tb_resource` (
`is_audited` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已审核', `is_audited` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已审核',
`is_recommend` TINYINT(1) DEFAULT 0 COMMENT '是否推荐', `is_recommend` TINYINT(1) DEFAULT 0 COMMENT '是否推荐',
`is_banner` 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 '发布时间', `publish_time` TIMESTAMP NULL DEFAULT NULL COMMENT '发布时间',
`creator` VARCHAR(50) DEFAULT NULL COMMENT '创建者', `creator` VARCHAR(50) DEFAULT NULL COMMENT '创建者',
`updater` 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_tag` (`tag_id`),
KEY `idx_status` (`status`), KEY `idx_status` (`status`),
KEY `idx_publish_time` (`publish_time`), 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='资源表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资源表';

View File

@@ -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()), ('21', 'dify.upload.maxSize', '最大文件大小', '50', 'integer', 'input', 'Dify配置', '最大文件大小MB', '请输入最大文件大小', '单个文件上传的最大大小限制', NULL, 1, 500, 'MB', NULL, 21, 1, '1', now()),
-- Dify知识库配置 -- Dify知识库配置
('30', 'dify.dataset.defaultIndexingTechnique', '默认索引方式', 'high_quality', 'string', 'select', 'Dify配置', '默认索引方式', NULL, '知识库文档的默认索引方式', NULL, NULL, NULL, NULL, 'high_quality,economy', 30, 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模型', 'text-embedding-ada-002', 'string', 'input', 'Dify配置', '默认Embedding模型', '请输入Embedding模型名称', '知识库使用的默认Embedding模型', NULL, NULL, NULL, NULL, NULL, 31, 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、系统信息 -- 基础配置Logo、系统信息
('100', 'system.name', '系统名称', '红色思政学习平台', 'string', 'input', '基础配置', '系统显示名称', '请输入系统名称', '系统对外显示的名称', NULL, NULL, NULL, NULL, NULL, 100, 1, '1', now()), ('100', 'system.name', '系统名称', '红色思政学习平台', 'string', 'input', '基础配置', '系统显示名称', '请输入系统名称', '系统对外显示的名称', NULL, NULL, NULL, NULL, NULL, 100, 1, '1', now()),

View File

@@ -171,6 +171,15 @@ crontab:
# dify # dify
dify: dify:
knowledgeApiKey: dataset-nupqKP4LONpzdXmGthIrbjeJ knowledgeApiKey: dataset-nupqKP4LONpzdXmGthIrbjeJ
# 文章知识库配置
article:
knowledge:
# 默认文章知识库ID自动初始化后会使用此ID
default-id: article_news_resource
# 是否自动初始化知识库(首次启动时创建)
auto-init: true
# 文件存储配置 # 文件存储配置
file: file:
storage: storage:

View File

@@ -967,5 +967,163 @@ public class AiKnowledgeServiceImpl implements AiKnowledgeService {
} }
} }
@Override
@Transactional(rollbackFor = Exception.class)
public ResultDomain<TbAiKnowledge> createKnowledgeInternal(TbAiKnowledge knowledge) {
ResultDomain<TbAiKnowledge> 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;
}
}
} }

View File

@@ -125,4 +125,11 @@ public interface AiKnowledgeService {
* @return 文档列表 * @return 文档列表
*/ */
ResultDomain<Map<String, Object>> getDocumentList(String knowledgeId, Integer page, Integer limit); ResultDomain<Map<String, Object>> getDocumentList(String knowledgeId, Integer page, Integer limit);
/**
* 内部创建知识库(无需登录,供系统初始化使用)
* @param knowledge 知识库信息
* @return 创建结果
*/
ResultDomain<TbAiKnowledge> createKnowledgeInternal(TbAiKnowledge knowledge);
} }

View File

@@ -94,6 +94,21 @@ public class TbResource extends BaseDTO {
*/ */
private Boolean isBanner; private Boolean isBanner;
/**
* @description 是否已导入知识库
*/
private Boolean isInKnowledge;
/**
* @description 知识库文件ID
*/
private String knowledgeFileId;
/**
* @description 知识库导入时间
*/
private Date knowledgeImportTime;
/** /**
* @description 发布时间 * @description 发布时间
*/ */
@@ -239,6 +254,30 @@ public class TbResource extends BaseDTO {
this.isBanner = isBanner; 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() { public Date getPublishTime() {
return publishTime; return publishTime;
} }

View File

@@ -556,9 +556,14 @@ public class NewsCrawlerTask extends PythonCommandTask {
Date publishTime = item.getPublishTime() != null ? item.getPublishTime() : now; Date publishTime = item.getPublishTime() != null ? item.getPublishTime() : now;
resource.setPublishTime(publishTime); resource.setPublishTime(publishTime);
// 状态:已发布 // 状态:已发布(根据审核结果设置)
resource.setStatus(1); if (item.getIsAudited() != null && item.getIsAudited()) {
resource.setIsAudited(true); resource.setStatus(1);
resource.setIsAudited(true);
} else {
resource.setStatus(4); // 敏感词未通过
resource.setIsAudited(false);
}
resource.setViewCount(0); resource.setViewCount(0);
resource.setLikeCount(0); resource.setLikeCount(0);
resource.setCollectCount(0); resource.setCollectCount(0);

View File

@@ -80,5 +80,40 @@
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
</dependency> </dependency>
<!-- Flying Saucer - HTML转PDF (包含OpenPDF依赖) -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf-openpdf</artifactId>
<version>9.3.2</version>
</dependency>
<!-- Jsoup - HTML解析和清理 -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<!-- AI模块依赖 - 用于知识库上传 -->
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>api-ai</artifactId>
<version>1.0.0</version>
</dependency>
<!-- AI实现模块 - 用于AiKnowledgeMapper -->
<dependency>
<groupId>org.xyzh</groupId>
<artifactId>ai</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring Test - 用于MockMultipartFile -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

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

View File

@@ -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<TbResource> importToKnowledge(@PathVariable("resourceId") String resourceId) {
logger.info("手动导入文章到知识库: resourceId={}", resourceId);
return articleKnowledgeService.importArticleToDefaultKnowledgeById(resourceId);
}
/**
* 批量将文章导入知识库
* @param params 包含resourceIds的参数
* @return 导入结果
*/
@PostMapping("/import/batch")
@SuppressWarnings("unchecked")
public ResultDomain<TbResource> batchImportToKnowledge(@RequestBody Map<String, Object> params) {
List<String> resourceIds = (List<String>) params.get("resourceIds");
logger.info("批量导入文章到知识库: count={}", resourceIds != null ? resourceIds.size() : 0);
return articleKnowledgeService.batchImportToDefaultKnowledge(resourceIds);
}
/**
* 从知识库移除文章
* @param resourceId 资源ID
* @return 移除结果
*/
@DeleteMapping("/remove/{resourceId}")
public ResultDomain<Boolean> removeFromKnowledge(@PathVariable("resourceId") String resourceId) {
logger.info("从知识库移除文章: resourceId={}", resourceId);
return articleKnowledgeService.removeArticleFromKnowledge(resourceId);
}
}

View File

@@ -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<TbResource> importArticleToKnowledge(TbResource resource, String knowledgeId);
/**
* 将文章导入到默认知识库
* @param resource 文章资源
* @return 导入结果
*/
ResultDomain<TbResource> importArticleToDefaultKnowledge(TbResource resource);
/**
* 根据资源ID将文章导入到默认知识库
* @param resourceId 资源ID
* @return 导入结果
*/
ResultDomain<TbResource> importArticleToDefaultKnowledgeById(String resourceId);
/**
* 批量将文章导入到默认知识库
* @param resourceIds 资源ID列表
* @return 导入结果
*/
ResultDomain<TbResource> batchImportToDefaultKnowledge(List<String> resourceIds);
/**
* 从知识库中删除文章
* @param resourceId 文章资源ID
* @return 删除结果
*/
ResultDomain<Boolean> removeArticleFromKnowledge(String resourceId);
}

View File

@@ -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<TbResource> importArticleToKnowledge(TbResource resource, String knowledgeId) {
ResultDomain<TbResource> 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<TbAiUploadFile> 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<TbResource> importArticleToDefaultKnowledge(TbResource resource) {
ResultDomain<TbResource> 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<TbResource> importArticleToDefaultKnowledgeById(String resourceId) {
ResultDomain<TbResource> 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<TbResource> batchImportToDefaultKnowledge(List<String> resourceIds) {
ResultDomain<TbResource> 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<TbResource> successList = new ArrayList<>();
List<String> failedList = new ArrayList<>();
for (String resourceId : resourceIds) {
try {
ResultDomain<TbResource> 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<Boolean> removeArticleFromKnowledge(String resourceId) {
ResultDomain<Boolean> 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<Boolean> 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
);
}
}
}

View File

@@ -28,6 +28,7 @@ import org.xyzh.api.news.resource.ResourceService;
import org.xyzh.api.system.permission.ResourcePermissionService; import org.xyzh.api.system.permission.ResourcePermissionService;
import org.xyzh.common.vo.UserDeptRoleVO; import org.xyzh.common.vo.UserDeptRoleVO;
import org.xyzh.common.core.enums.ResourceType; import org.xyzh.common.core.enums.ResourceType;
import org.xyzh.news.service.ArticleKnowledgeService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
@@ -59,6 +60,9 @@ public class NCResourceServiceImpl implements ResourceService {
@Autowired @Autowired
private ResourceAuditService auditService; private ResourceAuditService auditService;
@Autowired
private ArticleKnowledgeService articleKnowledgeService;
@Override @Override
public ResultDomain<TbResource> getResourceList(TbResource filter) { public ResultDomain<TbResource> getResourceList(TbResource filter) {
ResultDomain<TbResource> resultDomain = new ResultDomain<>(); ResultDomain<TbResource> resultDomain = new ResultDomain<>();
@@ -328,6 +332,20 @@ public class NCResourceServiceImpl implements ResourceService {
logger.error("创建资源权限异常,但不影响资源创建: {}", e.getMessage(), e); logger.error("创建资源权限异常,但不影响资源创建: {}", e.getMessage(), e);
} }
// 如果文章直接发布status=1且审核通过导入知识库
if (resource.getStatus() == 1 && resource.getIsAudited()) {
try {
ResultDomain<TbResource> 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); resultDomain.success("创建资源成功", resourceVO);
return resultDomain; return resultDomain;
} else { } else {
@@ -529,7 +547,22 @@ public class NCResourceServiceImpl implements ResourceService {
if (result > 0) { if (result > 0) {
logger.info("更新资源状态成功: {}", resourceID); logger.info("更新资源状态成功: {}", resourceID);
// 重新查询返回完整数据 // 重新查询返回完整数据
TbResource updated = resourceMapper.selectByResourceId(resource.getId()); TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID());
// 如果状态变为发布且审核通过,导入知识库
if (status == 1 && resource.getIsAudited()) {
try {
ResultDomain<TbResource> 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); resultDomain.success("更新资源状态成功", updated);
return resultDomain; return resultDomain;
} else { } else {
@@ -586,7 +619,21 @@ public class NCResourceServiceImpl implements ResourceService {
if (result > 0) { if (result > 0) {
logger.info("发布资源成功: {}", resourceID); logger.info("发布资源成功: {}", resourceID);
// 重新查询返回完整数据 // 重新查询返回完整数据
TbResource updated = resourceMapper.selectByResourceId(resource.getId()); TbResource updated = resourceMapper.selectByResourceId(resourceID);
// 异步将文章导入知识库
try {
ResultDomain<TbResource> 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); resultDomain.success("发布资源成功", updated);
return resultDomain; return resultDomain;
} else { } else {
@@ -626,7 +673,7 @@ public class NCResourceServiceImpl implements ResourceService {
if (result > 0) { if (result > 0) {
logger.info("下架资源成功: {}", resourceID); logger.info("下架资源成功: {}", resourceID);
// 重新查询返回完整数据 // 重新查询返回完整数据
TbResource updated = resourceMapper.selectByResourceId(resource.getId()); TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID());
resultDomain.success("下架资源成功", updated); resultDomain.success("下架资源成功", updated);
return resultDomain; return resultDomain;
} else { } else {
@@ -695,7 +742,7 @@ public class NCResourceServiceImpl implements ResourceService {
if (result > 0) { if (result > 0) {
logger.info("增加资源点赞次数成功: {}", resourceID); logger.info("增加资源点赞次数成功: {}", resourceID);
// 重新查询返回完整数据 // 重新查询返回完整数据
TbResource updated = resourceMapper.selectByResourceId(resource.getId()); TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID());
resultDomain.success("增加点赞次数成功", updated); resultDomain.success("增加点赞次数成功", updated);
return resultDomain; return resultDomain;
} else { } else {
@@ -767,7 +814,7 @@ public class NCResourceServiceImpl implements ResourceService {
if (result > 0) { if (result > 0) {
logger.info("设置资源推荐状态成功: {} -> {}", resourceID, isRecommend); logger.info("设置资源推荐状态成功: {} -> {}", resourceID, isRecommend);
// 重新查询返回完整数据 // 重新查询返回完整数据
TbResource updated = resourceMapper.selectByResourceId(resource.getId()); TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID());
resultDomain.success("设置推荐状态成功", updated); resultDomain.success("设置推荐状态成功", updated);
return resultDomain; return resultDomain;
} else { } else {
@@ -811,7 +858,7 @@ public class NCResourceServiceImpl implements ResourceService {
if (result > 0) { if (result > 0) {
logger.info("设置资源轮播状态成功: {} -> {}", resourceID, isBanner); logger.info("设置资源轮播状态成功: {} -> {}", resourceID, isBanner);
// 重新查询返回完整数据 // 重新查询返回完整数据
TbResource updated = resourceMapper.selectByResourceId(resource.getId()); TbResource updated = resourceMapper.selectByResourceId(resource.getResourceID());
resultDomain.success("设置轮播状态成功", updated); resultDomain.success("设置轮播状态成功", updated);
return resultDomain; return resultDomain;
} else { } else {

View File

@@ -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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
html.append("<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">");
html.append("<html xmlns=\"http://www.w3.org/1999/xhtml\">");
html.append("<head>");
html.append("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"/>");
html.append("<style type=\"text/css\">");
html.append(getStyleContent());
html.append("</style>");
html.append("</head>");
html.append("<body>");
// 文章标题
html.append("<div class=\"article-header\">");
html.append("<h1 class=\"title\">").append(escapeHtml(resource.getTitle())).append("</h1>");
// 文章元信息
html.append("<div class=\"meta\">");
if (resource.getAuthor() != null && !resource.getAuthor().isEmpty()) {
html.append("<span>作者: ").append(escapeHtml(resource.getAuthor())).append("</span>");
}
if (resource.getSource() != null && !resource.getSource().isEmpty()) {
html.append("<span>来源: ").append(escapeHtml(resource.getSource())).append("</span>");
}
if (resource.getPublishTime() != null) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm");
html.append("<span>发布时间: ").append(sdf.format(resource.getPublishTime())).append("</span>");
}
html.append("</div>");
html.append("</div>");
// 文章内容
html.append("<div class=\"article-content\">");
html.append(cleanHtmlContent(resource.getContent()));
html.append("</div>");
html.append("</body>");
html.append("</html>");
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
/**
* 添加中文字体支持
*/
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());
}
}
}

View File

@@ -21,6 +21,9 @@
<result column="is_audited" property="isAudited" jdbcType="BOOLEAN"/> <result column="is_audited" property="isAudited" jdbcType="BOOLEAN"/>
<result column="is_recommend" property="isRecommend" jdbcType="BOOLEAN"/> <result column="is_recommend" property="isRecommend" jdbcType="BOOLEAN"/>
<result column="is_banner" property="isBanner" jdbcType="BOOLEAN"/> <result column="is_banner" property="isBanner" jdbcType="BOOLEAN"/>
<result column="is_in_knowledge" property="isInKnowledge" jdbcType="BOOLEAN"/>
<result column="knowledge_file_id" property="knowledgeFileId" jdbcType="VARCHAR"/>
<result column="knowledge_import_time" property="knowledgeImportTime" jdbcType="TIMESTAMP"/>
<result column="publish_time" property="publishTime" jdbcType="TIMESTAMP"/> <result column="publish_time" property="publishTime" jdbcType="TIMESTAMP"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/> <result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="updater" property="updater" jdbcType="VARCHAR"/> <result column="updater" property="updater" jdbcType="VARCHAR"/>
@@ -34,8 +37,8 @@
<sql id="Base_Column_List"> <sql id="Base_Column_List">
id, resource_id, title, content, summary, cover_image, tag_id, author, source, 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, source_url, view_count, like_count, collect_count, status, is_audited, is_recommend,
is_banner, publish_time, creator, updater, create_time, update_time, is_banner, is_in_knowledge, knowledge_file_id, knowledge_import_time, publish_time,
delete_time, deleted creator, updater, create_time, update_time, delete_time, deleted
</sql> </sql>
<!-- 通用条件 --> <!-- 通用条件 -->
@@ -260,6 +263,15 @@
<if test="isBanner != null"> <if test="isBanner != null">
is_banner = #{isBanner}, is_banner = #{isBanner},
</if> </if>
<if test="isInKnowledge != null">
is_in_knowledge = #{isInKnowledge},
</if>
<if test="knowledgeFileId != null">
knowledge_file_id = #{knowledgeFileId},
</if>
<if test="knowledgeImportTime != null">
knowledge_import_time = #{knowledgeImportTime},
</if>
<if test="isAudited != null"> <if test="isAudited != null">
is_audited = #{isAudited}, is_audited = #{isAudited},
</if> </if>
@@ -368,6 +380,9 @@
<result column="is_audited" property="isAudited" jdbcType="BOOLEAN"/> <result column="is_audited" property="isAudited" jdbcType="BOOLEAN"/>
<result column="is_recommend" property="isRecommend" jdbcType="BOOLEAN"/> <result column="is_recommend" property="isRecommend" jdbcType="BOOLEAN"/>
<result column="is_banner" property="isBanner" jdbcType="BOOLEAN"/> <result column="is_banner" property="isBanner" jdbcType="BOOLEAN"/>
<result column="is_in_knowledge" property="isInKnowledge" jdbcType="BOOLEAN"/>
<result column="knowledge_file_id" property="knowledgeFileId" jdbcType="VARCHAR"/>
<result column="knowledge_import_time" property="knowledgeImportTime" jdbcType="TIMESTAMP"/>
<result column="publish_time" property="publishTime" jdbcType="TIMESTAMP"/> <result column="publish_time" property="publishTime" jdbcType="TIMESTAMP"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/> <result column="creator" property="creator" jdbcType="VARCHAR"/>
<result column="updater" property="updater" jdbcType="VARCHAR"/> <result column="updater" property="updater" jdbcType="VARCHAR"/>

View File

@@ -228,6 +228,38 @@ export const resourceApi = {
async searchItems(request: { pageParam: PageParam; filter: Resource }): Promise<ResultDomain<TaskItemVO>> { async searchItems(request: { pageParam: PageParam; filter: Resource }): Promise<ResultDomain<TaskItemVO>> {
const response = await api.post<TaskItemVO>('/news/resources/search', request); const response = await api.post<TaskItemVO>('/news/resources/search', request);
return response.data; return response.data;
},
// ==================== 知识库操作 ====================
/**
* 将文章导入知识库
* @param resourceID 资源ID
* @returns Promise<ResultDomain<Resource>>
*/
async importToKnowledge(resourceID: string): Promise<ResultDomain<Resource>> {
const response = await api.post<Resource>(`/news/article-knowledge/import/${resourceID}`);
return response.data;
},
/**
* 批量将文章导入知识库
* @param resourceIds 资源ID列表
* @returns Promise<ResultDomain<Resource>>
*/
async batchImportToKnowledge(resourceIds: string[]): Promise<ResultDomain<Resource>> {
const response = await api.post<Resource>('/news/article-knowledge/import/batch', { resourceIds });
return response.data;
},
/**
* 从知识库移除文章
* @param resourceID 资源ID
* @returns Promise<ResultDomain<boolean>>
*/
async removeFromKnowledge(resourceID: string): Promise<ResultDomain<boolean>> {
const response = await api.delete<boolean>(`/news/article-knowledge/remove/${resourceID}`);
return response.data;
} }
}; };

View File

@@ -41,6 +41,12 @@ export interface Resource extends BaseDTO {
isRecommend?: boolean; isRecommend?: boolean;
/** 是否轮播 */ /** 是否轮播 */
isBanner?: boolean; isBanner?: boolean;
/** 是否已导入知识库 */
isInKnowledge?: boolean;
/** 知识库文件ID */
knowledgeFileId?: string;
/** 知识库导入时间 */
knowledgeImportTime?: string;
/** 发布时间 */ /** 发布时间 */
publishTime?: string; publishTime?: string;
/** 创建者 */ /** 创建者 */

View File

@@ -28,7 +28,14 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="250" fixed="right"> <el-table-column prop="isInKnowledge" label="知识库" width="100">
<template #default="{ row }">
<el-tag :type="row.isInKnowledge ? 'success' : 'info'" size="small">
{{ row.isInKnowledge ? '已导入' : '未导入' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="viewArticle(row)">查看</el-button> <el-button size="small" @click="viewArticle(row)">查看</el-button>
<el-button <el-button
@@ -39,6 +46,15 @@
{{ getActionButtonText(row.status) }} {{ getActionButtonText(row.status) }}
</el-button> </el-button>
<el-button size="small" @click="editArticle(row)">编辑</el-button> <el-button size="small" @click="editArticle(row)">编辑</el-button>
<el-button
size="small"
type="success"
:disabled="row.isInKnowledge || row.status !== 1"
:loading="importingIds.has(row.resourceID)"
@click="importToKnowledge(row)"
>
{{ row.isInKnowledge ? '已导入' : '导入知识库' }}
</el-button>
<el-button size="small" type="danger" @click="deleteArticle(row)">删除</el-button> <el-button size="small" type="danger" @click="deleteArticle(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -99,6 +115,7 @@ const articles = ref<Resource[]>([]);
const showViewDialog = ref(false); const showViewDialog = ref(false);
const currentArticle = ref<any>(null); const currentArticle = ref<any>(null);
const categoryList = ref<Tag[]>([]); // 改为使用Tag类型tagType=1表示文章分类 const categoryList = ref<Tag[]>([]); // 改为使用Tag类型tagType=1表示文章分类
const importingIds = ref<Set<string>>(new Set()); // 正在导入的文章ID集合
onMounted(() => { onMounted(() => {
loadArticles(); 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) { function getStatusType(status: number) {
const typeMap: Record<number, any> = { const typeMap: Record<number, any> = {
[ResourceStatus.DRAFT]: 'info', [ResourceStatus.DRAFT]: 'info',