SkillVersionServiceImpl.java 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. package com.agent.management.service.impl;
  2. import com.agent.management.common.exception.BusinessException;
  3. import com.agent.management.config.SkillProperties;
  4. import com.agent.management.engine.hermes.HermesSkillSyncService;
  5. import com.agent.management.model.vo.SkillVersionVO;
  6. import com.agent.management.service.SkillVersionService;
  7. import com.fasterxml.jackson.databind.ObjectMapper;
  8. import com.fasterxml.jackson.databind.SerializationFeature;
  9. import lombok.RequiredArgsConstructor;
  10. import lombok.extern.slf4j.Slf4j;
  11. import org.apache.commons.io.FileUtils;
  12. import org.springframework.stereotype.Service;
  13. import java.io.File;
  14. import java.io.IOException;
  15. import java.nio.charset.StandardCharsets;
  16. import java.nio.file.*;
  17. import java.time.LocalDateTime;
  18. import java.util.*;
  19. import java.util.stream.Stream;
  20. @Slf4j
  21. @Service
  22. @RequiredArgsConstructor
  23. public class SkillVersionServiceImpl implements SkillVersionService {
  24. private final SkillProperties skillProperties;
  25. private final HermesSkillSyncService hermesSkillSyncService;
  26. private static final String VERSIONS_DIR = "_versions";
  27. private static final String VERSION_META_FILE = "_version.json";
  28. private static final ObjectMapper jsonMapper = new ObjectMapper()
  29. .enable(SerializationFeature.INDENT_OUTPUT);
  30. // ==================== 公有方法 ====================
  31. @Override
  32. public int createSnapshot(String folderName, String message) {
  33. Path skillDir = getSkillDir(folderName);
  34. Path versionsDir = skillDir.resolve(VERSIONS_DIR);
  35. VersionMeta meta = loadMeta(skillDir);
  36. // 版本号始终取历史最大值 + 1,保证单调递增(避免回滚后再保存版本号冲突)
  37. int maxVersion = meta.versions.stream()
  38. .mapToInt(e -> e.number)
  39. .max().orElse(0);
  40. int newVersion = maxVersion + 1;
  41. // 创建 v{n} 目录并复制工作区文件
  42. Path snapshotDir = versionsDir.resolve("v" + newVersion);
  43. copyWorkingDir(skillDir, snapshotDir);
  44. // 更新元数据
  45. VersionEntry entry = new VersionEntry();
  46. entry.number = newVersion;
  47. entry.createdAt = LocalDateTime.now().toString();
  48. entry.message = message != null ? message : "";
  49. meta.versions.add(entry);
  50. meta.currentVersion = newVersion;
  51. saveMeta(skillDir, meta);
  52. log.info("Skill [{}] 创建快照 v{}", folderName, newVersion);
  53. return newVersion;
  54. }
  55. @Override
  56. public int getCurrentVersion(String folderName) {
  57. Path skillDir = getSkillDir(folderName);
  58. return loadMeta(skillDir).currentVersion;
  59. }
  60. @Override
  61. public List<SkillVersionVO> listVersions(String folderName) {
  62. Path skillDir = getSkillDir(folderName);
  63. VersionMeta meta = loadMeta(skillDir);
  64. List<SkillVersionVO> result = new ArrayList<>();
  65. for (VersionEntry entry : meta.versions) {
  66. SkillVersionVO vo = new SkillVersionVO();
  67. vo.setNumber(entry.number);
  68. vo.setCreatedAt(LocalDateTime.parse(entry.createdAt));
  69. vo.setMessage(entry.message);
  70. vo.setCurrent(entry.number == meta.currentVersion);
  71. result.add(vo);
  72. }
  73. return result;
  74. }
  75. @Override
  76. public String getVersionContent(String folderName, int version) {
  77. Path skillDir = getSkillDir(folderName);
  78. validateVersionExists(skillDir, version);
  79. Path skillMd = skillDir.resolve(VERSIONS_DIR)
  80. .resolve("v" + version)
  81. .resolve("SKILL.md");
  82. if (!Files.exists(skillMd)) {
  83. throw new BusinessException(404, "版本 v" + version + " 无 SKILL.md");
  84. }
  85. try {
  86. return Files.readString(skillMd, StandardCharsets.UTF_8);
  87. } catch (IOException e) {
  88. throw new BusinessException("读取版本内容失败");
  89. }
  90. }
  91. @Override
  92. public void rollback(String folderName, int version) {
  93. Path skillDir = getSkillDir(folderName);
  94. VersionMeta meta = loadMeta(skillDir);
  95. validateVersionExists(skillDir, version);
  96. // 将快照内容恢复到工作区(排除 _versions 和 _version.json)
  97. Path snapshotDir = skillDir.resolve(VERSIONS_DIR).resolve("v" + version);
  98. restoreToWorkingDir(snapshotDir, skillDir);
  99. // 仅更新 currentVersion,不创建新快照
  100. meta.currentVersion = version;
  101. saveMeta(skillDir, meta);
  102. log.info("Skill [{}] 回滚到 v{}", folderName, version);
  103. hermesSkillSyncService.syncAfterWrite();
  104. }
  105. @Override
  106. public void updateVersionMessage(String folderName, int version, String message) {
  107. Path skillDir = getSkillDir(folderName);
  108. VersionMeta meta = loadMeta(skillDir);
  109. VersionEntry entry = meta.versions.stream()
  110. .filter(e -> e.number == version)
  111. .findFirst()
  112. .orElseThrow(() -> new BusinessException(404, "版本不存在: v" + version));
  113. entry.message = message != null ? message : "";
  114. saveMeta(skillDir, meta);
  115. }
  116. // ==================== 私有方法 ====================
  117. private Path getSkillDir(String folderName) {
  118. Path dir = Paths.get(skillProperties.getBasePath()).toAbsolutePath().resolve(folderName);
  119. if (!Files.exists(dir) || !Files.isDirectory(dir)) {
  120. throw new BusinessException(404, "Skill 不存在: " + folderName);
  121. }
  122. return dir;
  123. }
  124. /**
  125. * 复制工作区到快照目录(排除 _versions/ 和 _version.json)
  126. */
  127. private void copyWorkingDir(Path skillDir, Path snapshotDir) {
  128. try {
  129. Files.createDirectories(snapshotDir);
  130. try (Stream<Path> paths = Files.list(skillDir)) {
  131. paths.filter(p -> {
  132. String name = p.getFileName().toString();
  133. return !name.equals(VERSIONS_DIR) && !name.equals(VERSION_META_FILE);
  134. }).forEach(p -> {
  135. Path target = snapshotDir.resolve(p.getFileName().toString());
  136. try {
  137. if (Files.isDirectory(p)) {
  138. FileUtils.copyDirectory(p.toFile(), target.toFile());
  139. } else {
  140. Files.copy(p, target, StandardCopyOption.REPLACE_EXISTING);
  141. }
  142. } catch (IOException e) {
  143. log.error("复制文件失败: {} -> {}", p, target, e);
  144. throw new BusinessException("创建快照失败");
  145. }
  146. });
  147. }
  148. } catch (IOException e) {
  149. throw new BusinessException("创建快照失败");
  150. }
  151. }
  152. /**
  153. * 将快照内容恢复到工作区(先清空工作区文件,再从快照复制)
  154. */
  155. private void restoreToWorkingDir(Path snapshotDir, Path skillDir) {
  156. try {
  157. // 删除工作区中现有的业务文件(排除 _versions/ 和 _version.json)
  158. try (Stream<Path> paths = Files.list(skillDir)) {
  159. paths.filter(p -> {
  160. String name = p.getFileName().toString();
  161. return !name.equals(VERSIONS_DIR) && !name.equals(VERSION_META_FILE);
  162. }).forEach(p -> {
  163. try {
  164. if (Files.isDirectory(p)) {
  165. FileUtils.deleteDirectory(p.toFile());
  166. } else {
  167. Files.delete(p);
  168. }
  169. } catch (IOException e) {
  170. log.error("删除工作区文件失败: {}", p, e);
  171. }
  172. });
  173. }
  174. // 从快照复制到工作区
  175. try (Stream<Path> paths = Files.list(snapshotDir)) {
  176. paths.forEach(p -> {
  177. Path target = skillDir.resolve(p.getFileName().toString());
  178. try {
  179. if (Files.isDirectory(p)) {
  180. FileUtils.copyDirectory(p.toFile(), target.toFile());
  181. } else {
  182. Files.copy(p, target, StandardCopyOption.REPLACE_EXISTING);
  183. }
  184. } catch (IOException e) {
  185. log.error("恢复文件失败: {} -> {}", p, target, e);
  186. throw new BusinessException("回滚失败");
  187. }
  188. });
  189. }
  190. } catch (IOException e) {
  191. throw new BusinessException("回滚失败");
  192. }
  193. }
  194. private void validateVersionExists(Path skillDir, int version) {
  195. Path vDir = skillDir.resolve(VERSIONS_DIR).resolve("v" + version);
  196. if (!Files.exists(vDir)) {
  197. throw new BusinessException(404, "版本不存在: v" + version);
  198. }
  199. }
  200. // ==================== 元数据读写 ====================
  201. private VersionMeta loadMeta(Path skillDir) {
  202. Path metaFile = skillDir.resolve(VERSION_META_FILE);
  203. if (!Files.exists(metaFile)) {
  204. return new VersionMeta();
  205. }
  206. try {
  207. return jsonMapper.readValue(metaFile.toFile(), VersionMeta.class);
  208. } catch (IOException e) {
  209. log.warn("解析版本元数据失败,使用默认值: {}", metaFile, e);
  210. return new VersionMeta();
  211. }
  212. }
  213. private void saveMeta(Path skillDir, VersionMeta meta) {
  214. Path metaFile = skillDir.resolve(VERSION_META_FILE);
  215. try {
  216. jsonMapper.writeValue(metaFile.toFile(), meta);
  217. } catch (IOException e) {
  218. throw new BusinessException("保存版本元数据失败");
  219. }
  220. }
  221. // ==================== 内部数据结构 ====================
  222. /** _version.json 的数据结构 */
  223. @lombok.Data
  224. public static class VersionMeta {
  225. public int currentVersion = 0;
  226. public List<VersionEntry> versions = new ArrayList<>();
  227. }
  228. @lombok.Data
  229. public static class VersionEntry {
  230. public int number;
  231. public String createdAt;
  232. public String message;
  233. }
  234. }