package com.agent.management.service.impl; import com.agent.management.common.exception.BusinessException; import com.agent.management.config.SkillProperties; import com.agent.management.engine.hermes.HermesSkillSyncService; import com.agent.management.model.vo.SkillVersionVO; import com.agent.management.service.SkillVersionService; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; import org.springframework.stereotype.Service; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Stream; @Slf4j @Service @RequiredArgsConstructor public class SkillVersionServiceImpl implements SkillVersionService { private final SkillProperties skillProperties; private final HermesSkillSyncService hermesSkillSyncService; private static final String VERSIONS_DIR = "_versions"; private static final String VERSION_META_FILE = "_version.json"; private static final ObjectMapper jsonMapper = new ObjectMapper() .enable(SerializationFeature.INDENT_OUTPUT); // ==================== 公有方法 ==================== @Override public int createSnapshot(String folderName, String message) { Path skillDir = getSkillDir(folderName); Path versionsDir = skillDir.resolve(VERSIONS_DIR); VersionMeta meta = loadMeta(skillDir); // 版本号始终取历史最大值 + 1,保证单调递增(避免回滚后再保存版本号冲突) int maxVersion = meta.versions.stream() .mapToInt(e -> e.number) .max().orElse(0); int newVersion = maxVersion + 1; // 创建 v{n} 目录并复制工作区文件 Path snapshotDir = versionsDir.resolve("v" + newVersion); copyWorkingDir(skillDir, snapshotDir); // 更新元数据 VersionEntry entry = new VersionEntry(); entry.number = newVersion; entry.createdAt = LocalDateTime.now().toString(); entry.message = message != null ? message : ""; meta.versions.add(entry); meta.currentVersion = newVersion; saveMeta(skillDir, meta); log.info("Skill [{}] 创建快照 v{}", folderName, newVersion); return newVersion; } @Override public int getCurrentVersion(String folderName) { Path skillDir = getSkillDir(folderName); return loadMeta(skillDir).currentVersion; } @Override public List listVersions(String folderName) { Path skillDir = getSkillDir(folderName); VersionMeta meta = loadMeta(skillDir); List result = new ArrayList<>(); for (VersionEntry entry : meta.versions) { SkillVersionVO vo = new SkillVersionVO(); vo.setNumber(entry.number); vo.setCreatedAt(LocalDateTime.parse(entry.createdAt)); vo.setMessage(entry.message); vo.setCurrent(entry.number == meta.currentVersion); result.add(vo); } return result; } @Override public String getVersionContent(String folderName, int version) { Path skillDir = getSkillDir(folderName); validateVersionExists(skillDir, version); Path skillMd = skillDir.resolve(VERSIONS_DIR) .resolve("v" + version) .resolve("SKILL.md"); if (!Files.exists(skillMd)) { throw new BusinessException(404, "版本 v" + version + " 无 SKILL.md"); } try { return Files.readString(skillMd, StandardCharsets.UTF_8); } catch (IOException e) { throw new BusinessException("读取版本内容失败"); } } @Override public void rollback(String folderName, int version) { Path skillDir = getSkillDir(folderName); VersionMeta meta = loadMeta(skillDir); validateVersionExists(skillDir, version); // 将快照内容恢复到工作区(排除 _versions 和 _version.json) Path snapshotDir = skillDir.resolve(VERSIONS_DIR).resolve("v" + version); restoreToWorkingDir(snapshotDir, skillDir); // 仅更新 currentVersion,不创建新快照 meta.currentVersion = version; saveMeta(skillDir, meta); log.info("Skill [{}] 回滚到 v{}", folderName, version); hermesSkillSyncService.syncAfterWrite(); } @Override public void updateVersionMessage(String folderName, int version, String message) { Path skillDir = getSkillDir(folderName); VersionMeta meta = loadMeta(skillDir); VersionEntry entry = meta.versions.stream() .filter(e -> e.number == version) .findFirst() .orElseThrow(() -> new BusinessException(404, "版本不存在: v" + version)); entry.message = message != null ? message : ""; saveMeta(skillDir, meta); } // ==================== 私有方法 ==================== private Path getSkillDir(String folderName) { Path dir = Paths.get(skillProperties.getBasePath()).toAbsolutePath().resolve(folderName); if (!Files.exists(dir) || !Files.isDirectory(dir)) { throw new BusinessException(404, "Skill 不存在: " + folderName); } return dir; } /** * 复制工作区到快照目录(排除 _versions/ 和 _version.json) */ private void copyWorkingDir(Path skillDir, Path snapshotDir) { try { Files.createDirectories(snapshotDir); try (Stream paths = Files.list(skillDir)) { paths.filter(p -> { String name = p.getFileName().toString(); return !name.equals(VERSIONS_DIR) && !name.equals(VERSION_META_FILE); }).forEach(p -> { Path target = snapshotDir.resolve(p.getFileName().toString()); try { if (Files.isDirectory(p)) { FileUtils.copyDirectory(p.toFile(), target.toFile()); } else { Files.copy(p, target, StandardCopyOption.REPLACE_EXISTING); } } catch (IOException e) { log.error("复制文件失败: {} -> {}", p, target, e); throw new BusinessException("创建快照失败"); } }); } } catch (IOException e) { throw new BusinessException("创建快照失败"); } } /** * 将快照内容恢复到工作区(先清空工作区文件,再从快照复制) */ private void restoreToWorkingDir(Path snapshotDir, Path skillDir) { try { // 删除工作区中现有的业务文件(排除 _versions/ 和 _version.json) try (Stream paths = Files.list(skillDir)) { paths.filter(p -> { String name = p.getFileName().toString(); return !name.equals(VERSIONS_DIR) && !name.equals(VERSION_META_FILE); }).forEach(p -> { try { if (Files.isDirectory(p)) { FileUtils.deleteDirectory(p.toFile()); } else { Files.delete(p); } } catch (IOException e) { log.error("删除工作区文件失败: {}", p, e); } }); } // 从快照复制到工作区 try (Stream paths = Files.list(snapshotDir)) { paths.forEach(p -> { Path target = skillDir.resolve(p.getFileName().toString()); try { if (Files.isDirectory(p)) { FileUtils.copyDirectory(p.toFile(), target.toFile()); } else { Files.copy(p, target, StandardCopyOption.REPLACE_EXISTING); } } catch (IOException e) { log.error("恢复文件失败: {} -> {}", p, target, e); throw new BusinessException("回滚失败"); } }); } } catch (IOException e) { throw new BusinessException("回滚失败"); } } private void validateVersionExists(Path skillDir, int version) { Path vDir = skillDir.resolve(VERSIONS_DIR).resolve("v" + version); if (!Files.exists(vDir)) { throw new BusinessException(404, "版本不存在: v" + version); } } // ==================== 元数据读写 ==================== private VersionMeta loadMeta(Path skillDir) { Path metaFile = skillDir.resolve(VERSION_META_FILE); if (!Files.exists(metaFile)) { return new VersionMeta(); } try { return jsonMapper.readValue(metaFile.toFile(), VersionMeta.class); } catch (IOException e) { log.warn("解析版本元数据失败,使用默认值: {}", metaFile, e); return new VersionMeta(); } } private void saveMeta(Path skillDir, VersionMeta meta) { Path metaFile = skillDir.resolve(VERSION_META_FILE); try { jsonMapper.writeValue(metaFile.toFile(), meta); } catch (IOException e) { throw new BusinessException("保存版本元数据失败"); } } // ==================== 内部数据结构 ==================== /** _version.json 的数据结构 */ @lombok.Data public static class VersionMeta { public int currentVersion = 0; public List versions = new ArrayList<>(); } @lombok.Data public static class VersionEntry { public int number; public String createdAt; public String message; } }