feat(controller): 添加上传本地技能压缩包V2接口
- 在SkillGenController中新增uploadSkillV4方法,支持上传zip或rar格式技能包 - 新增CmsContent uploadSkillV4接口实现技能包解析和内容生成 - 集成SevenZipJBinding库支持rar格式解压 - 实现压缩包目录树结构解析功能 - 添加YAML内容生成和技能信息提取功能 - 完善异常处理和错误信息返回机制
This commit is contained in:
parent
b548bfbc14
commit
a5631caab3
|
|
@ -128,4 +128,24 @@ public class SkillGenController {
|
|||
return CommonResult.success(cmsContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传本地技能压缩包V2
|
||||
*
|
||||
* @param file 技能压缩包文件
|
||||
* @return 生成的技能内容
|
||||
*/
|
||||
@PostMapping("/uploadSkillV4")
|
||||
@Operation(summary = "上传本地技能压缩包V2", description = "上传本地zip或rar文件并生成技能")
|
||||
public CommonResult<CmsContent> uploadSkillV4(
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
try {
|
||||
byte[] fileBytes = file.getBytes();
|
||||
String fileName = file.getOriginalFilename();
|
||||
CmsContent cmsContent = skillGenService.uploadSkillV4(fileBytes, fileName);
|
||||
return CommonResult.success(cmsContent);
|
||||
} catch (Exception e) {
|
||||
return CommonResult.failed("上传失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package com.kexue.skills.entity.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 技能包信息DTO
|
||||
*
|
||||
* @author AI技能生成助手
|
||||
* @since 2026-04-10
|
||||
*/
|
||||
@Data
|
||||
public class SkillPackageInfoDto implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 技能名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 技能描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 作者
|
||||
*/
|
||||
private String author;
|
||||
|
||||
/**
|
||||
* 创建日期
|
||||
*/
|
||||
private String created;
|
||||
|
||||
/**
|
||||
* 标签列表
|
||||
*/
|
||||
private List<String> tags;
|
||||
|
||||
/**
|
||||
* 目录结构
|
||||
*/
|
||||
private List<SkillStructureNodeDto> structure;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.kexue.skills.entity.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 技能包目录结构节点DTO
|
||||
*
|
||||
* @author AI技能生成助手
|
||||
* @since 2026-04-10
|
||||
*/
|
||||
@Data
|
||||
public class SkillStructureNodeDto implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 节点名称(文件或目录名)
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 节点类型:directory 或 file
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 父级路径
|
||||
*/
|
||||
private String path;
|
||||
|
||||
/**
|
||||
* 格式:dir、markdown、python
|
||||
*/
|
||||
private String format;
|
||||
|
||||
/**
|
||||
* 节点描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 子节点列表(目录类型使用)
|
||||
*/
|
||||
private List<SkillStructureNodeDto> children;
|
||||
|
||||
/**
|
||||
* 文件内容(文件类型使用)
|
||||
*/
|
||||
private String content;
|
||||
}
|
||||
|
|
@ -69,5 +69,7 @@ public interface SkillGenService {
|
|||
*/
|
||||
CmsContent uploadSkillV2(byte[] fileBytes, String fileName);
|
||||
|
||||
CmsContent uploadSkillV4(byte[] fileBytes, String fileName);
|
||||
|
||||
CmsContent uploadSkillV3(String yamlContent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import com.kexue.skills.config.GlmConfig;
|
|||
import com.kexue.skills.entity.CmsContent;
|
||||
import com.kexue.skills.entity.CmsTag;
|
||||
import com.kexue.skills.entity.dto.CmsTagDto;
|
||||
import com.kexue.skills.entity.dto.SkillPackageInfoDto;
|
||||
import com.kexue.skills.entity.dto.SkillStructureNodeDto;
|
||||
import com.kexue.skills.entity.request.SkillAnalyzeRequest;
|
||||
import com.kexue.skills.entity.request.SkillGenRequest;
|
||||
import com.kexue.skills.entity.request.SkillPreGenRequest;
|
||||
|
|
@ -22,20 +24,42 @@ import com.kexue.skills.service.CmsTagService;
|
|||
import com.kexue.skills.service.SkillGenService;
|
||||
import com.kexue.skills.utils.EscapeCharacterUtils;
|
||||
import com.kexue.skills.utils.YamlToMapUtil;
|
||||
import com.kexue.skills.utils.YamlUtil;
|
||||
import jodd.util.StringUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.sevenzipjbinding.IInArchive;
|
||||
import net.sf.sevenzipjbinding.SevenZip;
|
||||
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.yaml.snakeyaml.error.YAMLException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* 技能生成服务实现
|
||||
|
|
@ -747,4 +771,571 @@ public class SkillGenServiceImpl implements SkillGenService {
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public CmsContent uploadSkillV4(byte[] fileBytes, String fileName) {
|
||||
log.info("上传技能压缩包V4请求: {}", fileName);
|
||||
|
||||
try {
|
||||
// 1. 从文件名提取技能名称
|
||||
String skillName = extractSkillName(fileName);
|
||||
|
||||
// 2. 解析压缩包,构建目录树结构
|
||||
List<SkillStructureNodeDto> structureNodes = parseArchiveToStructure(fileBytes, fileName);
|
||||
|
||||
// 3. 从SKILL.md或README.md中提取描述信息(如果存在)
|
||||
String description = extractDescriptionFromStructure(structureNodes);
|
||||
if (description == null || description.isEmpty()) {
|
||||
description = "AI生成的技能包";
|
||||
}
|
||||
|
||||
// 4. 提取标签(如果没有则使用默认标签)
|
||||
List<String> tags = extractTagsFromStructure(structureNodes);
|
||||
if (tags == null || tags.isEmpty()) {
|
||||
tags = Arrays.asList("通用");
|
||||
}
|
||||
|
||||
// 5. 构建packageInfo
|
||||
SkillPackageInfoDto packageInfo = new SkillPackageInfoDto();
|
||||
packageInfo.setName(skillName);
|
||||
packageInfo.setVersion("1.0.0");
|
||||
packageInfo.setDescription(description);
|
||||
packageInfo.setAuthor("AI技能生成助手");
|
||||
packageInfo.setCreated(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
|
||||
packageInfo.setTags(tags);
|
||||
packageInfo.setStructure(structureNodes);
|
||||
|
||||
// 6. 生成YAML内容
|
||||
String yamlContent = generateYamlContent(packageInfo);
|
||||
log.info("生成的YAML内容:\n{}", yamlContent);
|
||||
|
||||
// 7. 构建SkillGenRequest
|
||||
SkillGenRequest request = new SkillGenRequest();
|
||||
request.setName(skillName);
|
||||
request.setDescription(description);
|
||||
// request.setIntroduce(genIntroduceByDescription(description));
|
||||
request.setTags(tags);
|
||||
|
||||
// 8. 生成CmsContent
|
||||
CmsContent cmsContent = getCmsContent(request, yamlContent, StpUtil.getLoginIdAsLong(), "");
|
||||
|
||||
// 9. 设置图标
|
||||
setSkillIcon(cmsContent, tags);
|
||||
|
||||
cmsContentMapper.insert(cmsContent);
|
||||
|
||||
return cmsContent;
|
||||
} catch (Exception e) {
|
||||
log.error("上传技能压缩包V4失败: {}", e.getMessage(), e);
|
||||
throw new BizException("上传技能压缩包V4失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析压缩包为目录树结构
|
||||
*/
|
||||
private List<SkillStructureNodeDto> parseArchiveToStructure(byte[] fileBytes, String fileName) {
|
||||
Map<String, byte[]> fileMap = new HashMap<>();
|
||||
|
||||
// 解压文件
|
||||
if (fileName.toLowerCase().endsWith(".zip")) {
|
||||
extractZipFiles(fileBytes, fileMap);
|
||||
} else if (fileName.toLowerCase().endsWith(".rar")) {
|
||||
extractRarFiles(fileBytes, fileMap);
|
||||
} else {
|
||||
throw new BizException("不支持的压缩包格式,仅支持 zip 或 rar");
|
||||
}
|
||||
|
||||
// 构建目录树
|
||||
return buildDirectoryTree(fileMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建目录树结构
|
||||
*/
|
||||
private List<SkillStructureNodeDto> buildDirectoryTree(Map<String, byte[]> fileMap) {
|
||||
log.info("开始构建目录树,文件数量: {}", fileMap.size());
|
||||
|
||||
// 收集所有唯一的目录路径
|
||||
Set<String> directorySet = new TreeSet<>();
|
||||
for (String filePath : fileMap.keySet()) {
|
||||
String normalizedPath = filePath.replace("\\", "/");
|
||||
// 从文件路径提取所有父目录
|
||||
int lastSlashIndex = normalizedPath.lastIndexOf("/");
|
||||
while (lastSlashIndex > 0) {
|
||||
String parentDir = normalizedPath.substring(0, lastSlashIndex);
|
||||
directorySet.add(parentDir);
|
||||
lastSlashIndex = parentDir.lastIndexOf("/");
|
||||
}
|
||||
}
|
||||
|
||||
log.info("检测到目录: {}", directorySet);
|
||||
|
||||
// 创建节点映射:path -> node
|
||||
Map<String, SkillStructureNodeDto> pathToNodeMap = new HashMap<>();
|
||||
|
||||
// 先创建所有目录节点
|
||||
for (String dirPath : directorySet) {
|
||||
String[] parts = dirPath.split("/");
|
||||
String dirName = parts[parts.length - 1];
|
||||
String parentPath = dirPath.contains("/") ? dirPath.substring(0, dirPath.lastIndexOf("/")) : "";
|
||||
|
||||
SkillStructureNodeDto dirNode = new SkillStructureNodeDto();
|
||||
dirNode.setName(dirName);
|
||||
dirNode.setType("directory");
|
||||
dirNode.setPath(parentPath.isEmpty() ? "/" : parentPath);
|
||||
dirNode.setFormat("dir");
|
||||
dirNode.setDescription(dirName + " 目录");
|
||||
dirNode.setChildren(new ArrayList<>());
|
||||
|
||||
pathToNodeMap.put(dirPath, dirNode);
|
||||
log.debug("创建目录节点: name={}, path={}", dirName, dirNode.getPath());
|
||||
}
|
||||
|
||||
// 将目录节点添加到父节点
|
||||
List<SkillStructureNodeDto> rootChildren = new ArrayList<>();
|
||||
for (Map.Entry<String, SkillStructureNodeDto> entry : pathToNodeMap.entrySet()) {
|
||||
String dirPath = entry.getKey();
|
||||
SkillStructureNodeDto node = entry.getValue();
|
||||
|
||||
String parentPath = dirPath.contains("/") ? dirPath.substring(0, dirPath.lastIndexOf("/")) : "";
|
||||
if (parentPath.isEmpty()) {
|
||||
// 一级目录,直接添加到根节点
|
||||
rootChildren.add(node);
|
||||
} else {
|
||||
// 找到父目录并添加
|
||||
SkillStructureNodeDto parentNode = pathToNodeMap.get(parentPath);
|
||||
if (parentNode != null && parentNode.getChildren() != null) {
|
||||
parentNode.getChildren().add(node);
|
||||
} else {
|
||||
log.warn("找不到父目录: {}, 将{}添加到根节点", parentPath, dirPath);
|
||||
rootChildren.add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 再处理所有文件
|
||||
for (Map.Entry<String, byte[]> entry : fileMap.entrySet()) {
|
||||
String filePath = entry.getKey().replace("\\", "/");
|
||||
if (filePath.endsWith("/")) {
|
||||
continue; // 跳过目录条目
|
||||
}
|
||||
|
||||
int lastSlashIndex = filePath.lastIndexOf("/");
|
||||
String fileName = lastSlashIndex >= 0 ? filePath.substring(lastSlashIndex + 1) : filePath;
|
||||
String parentPath = lastSlashIndex >= 0 ? filePath.substring(0, lastSlashIndex) : "";
|
||||
|
||||
SkillStructureNodeDto fileNode = new SkillStructureNodeDto();
|
||||
fileNode.setName(fileName);
|
||||
fileNode.setType("file");
|
||||
fileNode.setPath(parentPath.isEmpty() ? "/" : parentPath);
|
||||
fileNode.setFormat(getFileFormat(fileName));
|
||||
fileNode.setDescription(fileName + " 文件");
|
||||
|
||||
// 读取文件内容
|
||||
try {
|
||||
String content = new String(entry.getValue(), StandardCharsets.UTF_8);
|
||||
fileNode.setContent(content);
|
||||
} catch (Exception e) {
|
||||
log.warn("读取文件内容失败: {}", filePath, e);
|
||||
fileNode.setContent("");
|
||||
}
|
||||
|
||||
// 添加到父节点
|
||||
if (parentPath.isEmpty()) {
|
||||
// 根目录下的文件
|
||||
rootChildren.add(fileNode);
|
||||
} else {
|
||||
SkillStructureNodeDto parentNode = pathToNodeMap.get(parentPath);
|
||||
if (parentNode != null && parentNode.getChildren() != null) {
|
||||
parentNode.getChildren().add(fileNode);
|
||||
} else {
|
||||
log.warn("找不到文件的父目录: {}, 将{}添加到根节点", parentPath, filePath);
|
||||
rootChildren.add(fileNode);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("创建文件节点: name={}, path={}", fileName, fileNode.getPath());
|
||||
}
|
||||
|
||||
// 删除:不再强制生成skills.md,保持压缩包原有结构
|
||||
// 之前的代码:ensureSkillsMdExists(rootChildren);
|
||||
|
||||
// 如果只有一个根目录节点,将其children提升到根层级(去掉最外层技能包目录)
|
||||
if (rootChildren.size() == 1 && "directory".equals(rootChildren.get(0).getType())) {
|
||||
SkillStructureNodeDto rootDir = rootChildren.get(0);
|
||||
String removedDirName = rootDir.getName();
|
||||
log.info("检测到单个根目录节点: {},将其children提升到根层级", removedDirName);
|
||||
|
||||
// 获取其children作为新的根节点
|
||||
List<SkillStructureNodeDto> newRootChildren = rootDir.getChildren();
|
||||
if (newRootChildren != null && !newRootChildren.isEmpty()) {
|
||||
rootChildren = newRootChildren;
|
||||
|
||||
// 递归更新所有节点的path,去除被移除的目录名前缀
|
||||
updatePathsAfterFlattening(rootChildren, removedDirName);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("目录树构建完成,根节点数量: {}", rootChildren.size());
|
||||
for (SkillStructureNodeDto node : rootChildren) {
|
||||
log.info(" 根节点: {} ({})", node.getName(), node.getType());
|
||||
}
|
||||
return rootChildren;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扁平化后递归更新所有节点的path
|
||||
*/
|
||||
private void updatePathsAfterFlattening(List<SkillStructureNodeDto> nodes, String removedDirPrefix) {
|
||||
if (nodes == null || nodes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (SkillStructureNodeDto node : nodes) {
|
||||
String oldPath = node.getPath();
|
||||
String newPath;
|
||||
|
||||
// 如果path是被移除的目录名,改为 "/"
|
||||
if (removedDirPrefix.equals(oldPath)) {
|
||||
newPath = "/";
|
||||
} else if (oldPath.startsWith(removedDirPrefix + "/")) {
|
||||
// 如果被移除目录名是前缀,去掉它
|
||||
newPath = "/" + oldPath.substring(removedDirPrefix.length() + 1);
|
||||
} else {
|
||||
// 其他情况保持不变
|
||||
newPath = oldPath;
|
||||
}
|
||||
|
||||
node.setPath(newPath);
|
||||
|
||||
// 递归处理子节点
|
||||
if ("directory".equals(node.getType()) && node.getChildren() != null) {
|
||||
updatePathsAfterFlattening(node.getChildren(), removedDirPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保根目录下有skills.md文件
|
||||
*/
|
||||
private void ensureSkillsMdExists(List<SkillStructureNodeDto> rootChildren) {
|
||||
boolean hasSkillsMd = false;
|
||||
for (SkillStructureNodeDto node : rootChildren) {
|
||||
if ("skills.md".equals(node.getName())) {
|
||||
hasSkillsMd = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSkillsMd) {
|
||||
SkillStructureNodeDto skillsMd = new SkillStructureNodeDto();
|
||||
skillsMd.setName("skills.md");
|
||||
skillsMd.setType("file");
|
||||
skillsMd.setPath("/");
|
||||
skillsMd.setFormat("markdown");
|
||||
skillsMd.setDescription("技能说明文档");
|
||||
skillsMd.setContent("# 技能说明\n\n请在此处填写技能的详细说明。\n");
|
||||
rootChildren.add(skillsMd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从目录结构中提取描述
|
||||
*/
|
||||
private String extractDescriptionFromStructure(List<SkillStructureNodeDto> structureNodes) {
|
||||
// 查找SKILL.md或README.md文件
|
||||
for (SkillStructureNodeDto node : structureNodes) {
|
||||
if ("file".equals(node.getType())) {
|
||||
String lowerName = node.getName().toLowerCase();
|
||||
if ((lowerName.equals("skill.md") || lowerName.equals("readme.md")) && node.getContent() != null) {
|
||||
// 提取前200个字符作为描述
|
||||
String content = node.getContent();
|
||||
if (content.length() > 200) {
|
||||
return content.substring(0, 200);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
} else if ("directory".equals(node.getType()) && node.getChildren() != null) {
|
||||
// 递归查找子目录
|
||||
String desc = extractDescriptionFromStructure(node.getChildren());
|
||||
if (desc != null && !desc.isEmpty()) {
|
||||
return desc;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从目录结构中提取标签
|
||||
*/
|
||||
private List<String> extractTagsFromStructure(List<SkillStructureNodeDto> structureNodes) {
|
||||
// 这里可以解析SKILL.md中的front matter提取标签
|
||||
// 暂时返回空列表,由调用方设置默认值
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成YAML内容
|
||||
*/
|
||||
private String generateYamlContent(SkillPackageInfoDto packageInfo) {
|
||||
StringBuilder yaml = new StringBuilder();
|
||||
yaml.append("package:\n");
|
||||
yaml.append(" name: ").append(quoteIfNecessary(packageInfo.getName())).append("\n");
|
||||
yaml.append(" version: ").append(quoteIfNecessary(packageInfo.getVersion())).append("\n");
|
||||
|
||||
// description可能包含特殊字符,使用块标量
|
||||
String description = packageInfo.getDescription();
|
||||
if (description != null && !description.isEmpty()) {
|
||||
yaml.append(" description: |\n");
|
||||
String[] lines = description.split("\n");
|
||||
for (String line : lines) {
|
||||
yaml.append(" ").append(line).append("\n");
|
||||
}
|
||||
} else {
|
||||
yaml.append(" description: \"\"\n");
|
||||
}
|
||||
|
||||
yaml.append(" author: ").append(quoteIfNecessary(packageInfo.getAuthor())).append("\n");
|
||||
yaml.append(" created: ").append(quoteIfNecessary(packageInfo.getCreated())).append("\n");
|
||||
|
||||
// tags数组
|
||||
yaml.append(" tags:\n");
|
||||
if (packageInfo.getTags() != null) {
|
||||
for (String tag : packageInfo.getTags()) {
|
||||
yaml.append(" - ").append(quoteIfNecessary(tag)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// structure目录树
|
||||
yaml.append(" structure:\n");
|
||||
if (packageInfo.getStructure() != null) {
|
||||
appendStructureNodes(yaml, packageInfo.getStructure(), 2);
|
||||
}
|
||||
|
||||
return yaml.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要引号,需要则加双引号
|
||||
*/
|
||||
private String quoteIfNecessary(String value) {
|
||||
if (value == null) {
|
||||
return "\"\"";
|
||||
}
|
||||
// 如果包含特殊字符或可能产生歧义,加双引号
|
||||
if (value.contains(":") || value.contains("#") || value.contains("{") ||
|
||||
value.contains("}") || value.contains("[") || value.contains("]") ||
|
||||
value.contains(",") || value.contains("&") || value.contains("*") ||
|
||||
value.contains("?") || value.contains("|") || value.contains("-") ||
|
||||
value.contains("<") || value.contains(">") || value.contains("=") ||
|
||||
value.contains("!") || value.contains("%") || value.contains("@") ||
|
||||
value.contains("`") || value.startsWith("'") || value.startsWith(" ") ||
|
||||
value.endsWith(" ")) {
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归追加structure节点到YAML
|
||||
*/
|
||||
private void appendStructureNodes(StringBuilder yaml, List<SkillStructureNodeDto> nodes, int indentLevel) {
|
||||
if (nodes == null || nodes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String indent = " ".repeat(indentLevel);
|
||||
|
||||
for (SkillStructureNodeDto node : nodes) {
|
||||
yaml.append(indent).append("- name: ").append(node.getName()).append("\n");
|
||||
yaml.append(indent).append(" type: ").append(node.getType()).append("\n");
|
||||
yaml.append(indent).append(" path: ").append(node.getPath()).append("\n");
|
||||
yaml.append(indent).append(" format: ").append(node.getFormat()).append("\n");
|
||||
|
||||
if ("file".equals(node.getType())) {
|
||||
// 文件类型,添加content
|
||||
if (node.getContent() != null && !node.getContent().isEmpty()) {
|
||||
String rawContent = node.getContent();
|
||||
// 将Tab替换为4个空格(YAML不允许Tab)
|
||||
rawContent = rawContent.replace("\t", " ");
|
||||
|
||||
// 分割成行
|
||||
String[] lines = rawContent.split("\n", -1);
|
||||
|
||||
// 去除末尾的空行
|
||||
int lastNonEmpty = lines.length - 1;
|
||||
while (lastNonEmpty >= 0 && lines[lastNonEmpty].trim().isEmpty()) {
|
||||
lastNonEmpty--;
|
||||
}
|
||||
|
||||
if (lastNonEmpty >= 0) {
|
||||
yaml.append(indent).append(" content: |");
|
||||
// 如果原内容有尾随换行,保留一个
|
||||
if (lastNonEmpty < lines.length - 1) {
|
||||
yaml.append("\n");
|
||||
} else {
|
||||
yaml.append("-\n"); // |- 表示去掉末尾换行
|
||||
}
|
||||
|
||||
// 输出内容行,每行添加统一的缩进
|
||||
for (int i = 0; i <= lastNonEmpty; i++) {
|
||||
yaml.append(indent).append(" ").append(lines[i]).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("directory".equals(node.getType())) {
|
||||
// 目录类型,添加children
|
||||
if (node.getChildren() != null && !node.getChildren().isEmpty()) {
|
||||
yaml.append(indent).append(" children:\n");
|
||||
appendStructureNodes(yaml, node.getChildren(), indentLevel + 1);
|
||||
} else {
|
||||
yaml.append(indent).append(" children: []\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义YAML字符串中的特殊字符
|
||||
*/
|
||||
private String escapeYamlString(String str) {
|
||||
if (str == null) {
|
||||
return "";
|
||||
}
|
||||
// 转义双引号、反斜杠等特殊字符
|
||||
return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件格式
|
||||
*/
|
||||
private String getFileFormat(String fileName) {
|
||||
if (fileName == null) {
|
||||
return "unknown";
|
||||
}
|
||||
String lowerName = fileName.toLowerCase();
|
||||
if (lowerName.endsWith(".md")) {
|
||||
return "markdown";
|
||||
} else if (lowerName.endsWith(".py")) {
|
||||
return "python";
|
||||
} else if (lowerName.endsWith(".txt")) {
|
||||
return "text";
|
||||
} else if (lowerName.endsWith(".json")) {
|
||||
return "json";
|
||||
} else if (lowerName.endsWith(".yaml") || lowerName.endsWith(".yml")) {
|
||||
return "yaml";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置技能图标
|
||||
*/
|
||||
private void setSkillIcon(CmsContent cmsContent, List<String> tags) {
|
||||
if (tags == null || tags.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String firstTagId = tags.get(0);
|
||||
CmsTagDto tagDto = new CmsTagDto();
|
||||
tagDto.setDeleteFlag(0);
|
||||
tagDto.setStatus(1);
|
||||
List<CmsTag> tagList = cmsTagService.getList(tagDto);
|
||||
|
||||
for (CmsTag tag : tagList) {
|
||||
if (firstTagId.contains(tag.getTagId() + "")) {
|
||||
cmsContent.setIcon(tag.getIcon());
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("设置技能图标失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压 ZIP 文件
|
||||
*/
|
||||
private void extractZipFiles(byte[] fileBytes, Map<String, byte[]> fileMap) {
|
||||
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(fileBytes))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
String entryName = entry.getName();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[1024];
|
||||
int len;
|
||||
while ((len = zis.read(buffer)) > 0) {
|
||||
baos.write(buffer, 0, len);
|
||||
}
|
||||
fileMap.put(entryName, baos.toByteArray());
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解压 ZIP 文件失败: {}", e.getMessage(), e);
|
||||
throw new BizException("解压 ZIP 文件失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压 RAR 文件
|
||||
*/
|
||||
private void extractRarFiles(byte[] fileBytes, Map<String, byte[]> fileMap) {
|
||||
File tempFile = null;
|
||||
try {
|
||||
// 创建临时文件
|
||||
tempFile = File.createTempFile("skill_rar_", ".rar");
|
||||
try (FileOutputStream fos = new FileOutputStream(tempFile)) {
|
||||
fos.write(fileBytes);
|
||||
}
|
||||
|
||||
// 使用 SevenZipJBinding 解压 RAR
|
||||
RandomAccessFile randomAccessFile = new RandomAccessFile(tempFile, "r");
|
||||
IInArchive archive = SevenZip.openInArchive(null, new RandomAccessFileInStream(randomAccessFile));
|
||||
|
||||
int itemCount = archive.getNumberOfItems();
|
||||
for (int i = 0; i < itemCount; i++) {
|
||||
Object isFolder = archive.getProperty(i, net.sf.sevenzipjbinding.PropID.IS_FOLDER);
|
||||
if (!(isFolder instanceof Boolean) || !((Boolean) isFolder)) {
|
||||
String path = (String) archive.getProperty(i, net.sf.sevenzipjbinding.PropID.PATH);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
archive.extractSlow(i, data -> {
|
||||
try {
|
||||
baos.write(data);
|
||||
return data.length;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
fileMap.put(path, baos.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
archive.close();
|
||||
randomAccessFile.close();
|
||||
} catch (Exception e) {
|
||||
log.error("解压 RAR 文件失败: {}", e.getMessage(), e);
|
||||
throw new BizException("解压 RAR 文件失败:" + e.getMessage());
|
||||
} finally {
|
||||
if (tempFile != null && tempFile.exists()) {
|
||||
tempFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名提取技能名称
|
||||
*/
|
||||
private String extractSkillName(String fileName) {
|
||||
if (fileName.contains(".")) {
|
||||
int dotIndex = fileName.lastIndexOf(".");
|
||||
return fileName.substring(0, dotIndex);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.kexue.skills.utils;
|
||||
|
||||
import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
public class YamlUtil {
|
||||
|
||||
/**
|
||||
* 对象转 YAML 字符串
|
||||
*/
|
||||
public static String toYaml(Object obj) {
|
||||
DumperOptions options = new DumperOptions();
|
||||
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
options.setPrettyFlow(true);
|
||||
Yaml yaml = new Yaml(options);
|
||||
return yaml.dump(obj);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue