文章导入知识库
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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='资源表';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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("&", "&")
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
/** 创建者 */
|
/** 创建者 */
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user