diff --git a/web-api/src/main/java/com/jeesite/modules/utils/SftpUtil.java b/web-api/src/main/java/com/jeesite/modules/utils/SftpUtil.java
new file mode 100644
index 0000000..d21ae9b
--- /dev/null
+++ b/web-api/src/main/java/com/jeesite/modules/utils/SftpUtil.java
@@ -0,0 +1,677 @@
+package com.jeesite.modules.utils;
+
+import com.jcraft.jsch.*;
+import com.jeesite.modules.apps.Module.SftpResult;
+import com.jeesite.modules.biz.entity.MySftpAccounts;
+import io.micrometer.common.util.StringUtils;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * SFTP 工具类
+ * 支持:上传、下载、删除、创建目录、重命名
+ *
+ * @author gaoxq
+ */
+public class SftpUtil {
+
+ private static final int SSH_TIMEOUT = 5000;
+
+ // ------------------------------------------------------------------ 连接
+
+ /**
+ * 建立 SFTP Channel,调用方负责关闭
+ */
+ private static ChannelSftp openChannel(MySftpAccounts account) throws JSchException {
+ JSch jsch = new JSch();
+ int port = Optional.ofNullable(account.getHostPort()).orElse(22);
+
+ if ("key".equalsIgnoreCase(account.getAuthType())
+ && StringUtils.isNotBlank(account.getPrivateKey())) {
+ jsch.addIdentity(
+ "tempKey",
+ account.getPrivateKey().getBytes(StandardCharsets.UTF_8),
+ null, null
+ );
+ }
+
+ Session session = jsch.getSession(account.getUsername(), account.getHostIp(), port);
+ session.setTimeout(SSH_TIMEOUT);
+
+ if (!"key".equalsIgnoreCase(account.getAuthType())) {
+ session.setPassword(account.getPassword());
+ }
+
+ Properties config = new Properties();
+ config.put("StrictHostKeyChecking", "no");
+ session.setConfig(config);
+ session.connect(SSH_TIMEOUT);
+
+ ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
+ channel.connect(SSH_TIMEOUT);
+ return channel;
+ }
+
+ /**
+ * 安全关闭 channel 及其 session
+ */
+ private static void close(ChannelSftp channel) {
+ if (channel == null) return;
+ try {
+ Session session = channel.getSession();
+ if (channel.isConnected()) channel.disconnect();
+ if (session != null && session.isConnected()) session.disconnect();
+ } catch (JSchException ignored) {
+ }
+ }
+
+ // ------------------------------------------------------------------ 上传
+
+ /**
+ * 上传本地文件到远程路径
+ *
+ * @param account SSH账号
+ * @param localPath 本地文件绝对路径
+ * @param remotePath 远程目标路径(含文件名),如 /data/app/test.txt
+ */
+ public static SftpResult upload(MySftpAccounts account, String localPath, String remotePath) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ channel.put(localPath, remotePath, ChannelSftp.OVERWRITE);
+ return SftpResult.ok("上传成功: " + remotePath);
+ } catch (Exception e) {
+ return SftpResult.fail("上传失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ /**
+ * 上传字节流到远程路径
+ *
+ * @param account SSH账号
+ * @param input 输入流
+ * @param remotePath 远程目标路径(含文件名)
+ */
+ public static SftpResult upload(MySftpAccounts account, InputStream input, String remotePath) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ channel.put(input, remotePath, ChannelSftp.OVERWRITE);
+ return SftpResult.ok("上传成功: " + remotePath);
+ } catch (Exception e) {
+ return SftpResult.fail("上传失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ // ------------------------------------------------------------------ 下载
+
+ /**
+ * 下载远程文件到本地
+ *
+ * 示例:
+ * - download(account, "/data/logs", "*.txt", "D:/tmp/") → 下载目录下所有txt
+ * - download(account, "/data", "app*.zip", "D:/tmp/") → 匹配 app 开头的 zip
+ * - download(account, "/data", "test.txt", "D:/tmp/test.txt") → 精确下载单个文件
+ *
+ * @param account SSH账号
+ * @param remoteDir 远程目录,如 /data/logs
+ * @param fileName 文件名或通配符,如 *.txt、app*.zip、test.txt
+ * @param localPath 本地保存路径。以 / 或 \ 结尾则视为目录,自动用远程文件名;否则视为完整路径
+ */
+ @SuppressWarnings("unchecked")
+ public static SftpResult download(MySftpAccounts account, String remoteDir, String fileName, String localPath) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ Vector entries = channel.ls(remoteDir);
+ boolean toDir = isDirectory(localPath);
+ String outDir = normalizeDir(localPath);
+ List downloaded = new ArrayList<>();
+ int skipped = 0;
+ for (ChannelSftp.LsEntry entry : entries) {
+ if (entry.getAttrs().isDir()) continue;
+ if (!globMatch(fileName, entry.getFilename())) continue;
+ String remoteFile = normalizeDir(remoteDir) + entry.getFilename();
+ String target = toDir ? outDir + entry.getFilename() : localPath;
+ try {
+ channel.get(remoteFile, target);
+ downloaded.add(entry.getFilename());
+ } catch (Exception e) {
+ skipped++;
+ }
+ }
+ if (downloaded.isEmpty()) {
+ return SftpResult.fail("无匹配文件: " + remoteDir + "/" + fileName);
+ }
+ String msg = "下载完成,成功 " + downloaded.size() + " 个" + (skipped > 0 ? ",失败 " + skipped + " 个" : "");
+ return SftpResult.ok(msg, downloaded);
+ } catch (Exception e) {
+ return SftpResult.fail("下载失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ /**
+ * 下载远程文件,返回字节数组(单文件,不支持通配符)
+ *
+ * @param account SSH账号
+ * @param remoteDir 远程目录
+ * @param fileName 文件名(精确)
+ */
+ public static SftpResult download(MySftpAccounts account, String remoteDir, String fileName) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ String remotePath = normalizeDir(remoteDir) + fileName;
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ channel.get(remotePath, out);
+ return SftpResult.ok("下载成功", out.toByteArray());
+ } catch (Exception e) {
+ return SftpResult.fail("下载失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ // ------------------------------------------------------------------ 删除
+
+ /**
+ * 删除远程文件
+ * 支持通配符文件名匹配
+ *
+ * 示例:
+ * - delete(account, "/data/logs", "*.log") → 删除所有 log 文件
+ * - delete(account, "/data", "test.txt") → 删除单个文件
+ *
+ * @param account SSH账号
+ * @param remoteDir 远程目录
+ * @param fileName 文件名或通配符,如 *.log、test.txt
+ */
+ @SuppressWarnings("unchecked")
+ public static SftpResult delete(MySftpAccounts account, String remoteDir, String fileName) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ Vector entries = channel.ls(remoteDir);
+ List deleted = new ArrayList<>();
+ for (ChannelSftp.LsEntry entry : entries) {
+ if (entry.getAttrs().isDir()) continue;
+ if (!globMatch(fileName, entry.getFilename())) continue;
+ try {
+ channel.rm(normalizeDir(remoteDir) + entry.getFilename());
+ deleted.add(entry.getFilename());
+ } catch (SftpException ignored) {
+ }
+ }
+ if (deleted.isEmpty()) {
+ return SftpResult.fail("无匹配文件: " + remoteDir + "/" + fileName);
+ }
+ return SftpResult.ok("删除完成,共 " + deleted.size() + " 个文件", deleted);
+ } catch (Exception e) {
+ return SftpResult.fail("删除失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ /**
+ * 删除远程空目录
+ *
+ * @param account SSH账号
+ * @param remoteDir 远程目录路径
+ */
+ public static SftpResult deleteDir(MySftpAccounts account, String remoteDir) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ channel.rmdir(remoteDir);
+ return SftpResult.ok("目录删除成功: " + remoteDir);
+ } catch (Exception e) {
+ return SftpResult.fail("目录删除失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ // ------------------------------------------------------------------ 创建目录
+
+ /**
+ * 创建远程目录(单级)
+ *
+ * @param account SSH账号
+ * @param remoteDir 远程目录路径
+ */
+ public static SftpResult mkdir(MySftpAccounts account, String remoteDir) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ channel.mkdir(remoteDir);
+ return SftpResult.ok("目录创建成功: " + remoteDir);
+ } catch (Exception e) {
+ return SftpResult.fail("目录创建失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ /**
+ * 递归创建远程目录(多级,类似 mkdir -p)
+ *
+ * @param account SSH账号
+ * @param remoteDir 远程目录路径,如 /data/a/b/c
+ */
+ public static SftpResult mkdirs(MySftpAccounts account, String remoteDir) {
+ ChannelSftp channel = null;
+ try {
+ channel = openChannel(account);
+ mkdirsInternal(channel, remoteDir);
+ return SftpResult.ok("目录创建成功: " + remoteDir);
+ } catch (Exception e) {
+ return SftpResult.fail("目录创建失败: " + e.getMessage());
+ } finally {
+ close(channel);
+ }
+ }
+
+ private static void mkdirsInternal(ChannelSftp channel, String path) throws SftpException {
+ String[] parts = path.replaceAll("^/", "").split("/");
+ StringBuilder current = new StringBuilder(path.startsWith("/") ? "/" : "");
+ for (String part : parts) {
+ if (part.isEmpty()) continue;
+ current.append(part);
+ try {
+ channel.stat(current.toString());
+ } catch (SftpException e) {
+ if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
+ channel.mkdir(current.toString());
+ } else {
+ throw e;
+ }
+ }
+ current.append("/");
+ }
+ }
+
+ // ------------------------------------------------------------------ 系统监控
+
+ /**
+ * 获取远程主机系统信息:CPU、内存、磁盘、负载、在线时长
+ * 通过 SSH exec 执行系统命令采集
+ *
+ * @param account SSH账号
+ * @return SftpResult,data 为 Map 结构
+ */
+ public static SftpResult systemInfo(MySftpAccounts account) {
+ Session session = null;
+ ChannelExec exec = null;
+ try {
+ session = openSession(account);
+ exec = (ChannelExec) session.openChannel("exec");
+ // 一次性采集所有指标
+ exec.setCommand(
+ "echo '===HOSTNAME===' && hostname && " +
+ "echo '===UPTIME===' && uptime -p 2>/dev/null || uptime && " +
+ "echo '===CPU===' && top -bn1 | head -5 && " +
+ "echo '===MEM===' && free -m && " +
+ "echo '===DISK===' && df -h"
+ );
+ InputStream in = exec.getInputStream();
+ InputStream errIn = exec.getErrStream();
+ exec.connect(SSH_TIMEOUT);
+
+ String output = readStream(in);
+ String err = readStream(errIn);
+ if (!err.isEmpty() && output.isEmpty()) {
+ return SftpResult.fail("采集失败: " + err);
+ }
+
+ Map info = new LinkedHashMap<>();
+ info.put("hostname", extract(output, "HOSTNAME", true));
+ info.put("uptime", extract(output, "UPTIME", true));
+ info.put("cpu", parseCpu(extract(output, "CPU", false)));
+ info.put("memory", parseMemory(extract(output, "MEM", false)));
+ info.put("disk", parseDisk(extract(output, "DISK", false)));
+ return SftpResult.ok("采集成功", info);
+ } catch (Exception e) {
+ return SftpResult.fail("采集失败: " + e.getMessage());
+ } finally {
+ if (exec != null && exec.isConnected()) exec.disconnect();
+ if (session != null && session.isConnected()) session.disconnect();
+ }
+ }
+
+ // ---- session 工具 ----
+
+ private static Session openSession(MySftpAccounts account) throws JSchException {
+ JSch jsch = new JSch();
+ int port = Optional.ofNullable(account.getHostPort()).orElse(22);
+ if ("key".equalsIgnoreCase(account.getAuthType())
+ && StringUtils.isNotBlank(account.getPrivateKey())) {
+ jsch.addIdentity("tempKey",
+ account.getPrivateKey().getBytes(StandardCharsets.UTF_8),
+ null, null);
+ }
+ Session session = jsch.getSession(account.getUsername(), account.getHostIp(), port);
+ session.setTimeout(SSH_TIMEOUT);
+ if (!"key".equalsIgnoreCase(account.getAuthType())) {
+ session.setPassword(account.getPassword());
+ }
+ Properties config = new Properties();
+ config.put("StrictHostKeyChecking", "no");
+ session.setConfig(config);
+ session.connect(SSH_TIMEOUT);
+ return session;
+ }
+
+ private static String readStream(InputStream in) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int len;
+ while ((len = in.read(buf)) != -1) {
+ out.write(buf, 0, len);
+ }
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ // ---- 解析工具 ----
+
+ /**
+ * 按 ===MARK=== 分割提取段落
+ */
+ private static String extract(String output, String mark, boolean singleLine) {
+ String start = "===" + mark + "===";
+ int s = output.indexOf(start);
+ if (s < 0) return "";
+ s += start.length();
+ if (s >= output.length()) return "";
+ // 跳过紧跟的换行
+ if (output.charAt(s) == '\n') s++;
+ if (singleLine) {
+ int e = output.indexOf('\n', s);
+ return (e < 0 ? output.substring(s) : output.substring(s, e)).trim();
+ }
+ // 取到下一个 === 或末尾
+ int e = output.indexOf("\n===", s);
+ return (e < 0 ? output.substring(s) : output.substring(s, e)).trim();
+ }
+
+ /**
+ * 解析 CPU 段(top -bn1 输出头部)
+ * 格式:Cpu(s): 1.2 us, 0.3 sy, 0.0 ni, 98.3 id, 0.1 wa, 0.1 si
+ */
+ private static Map parseCpu(String block) {
+ Map cpu = new LinkedHashMap<>();
+ for (String line : block.split("\n")) {
+ if (line.startsWith("Cpu") || line.startsWith("%Cpu")) {
+ // 统一去掉 %Cpu(s): 前缀
+ String data = line.replaceFirst(".*?:\\s*", "").replaceFirst("^%Cpu\\(s?\\):\\s*", "");
+ for (String part : data.split(",")) {
+ part = part.trim();
+ String[] kv = part.split("\\s+", 2);
+ if (kv.length >= 2) {
+ cpu.put(kv[1].trim(), kv[0].trim());
+ }
+ }
+ break;
+ }
+ }
+ return cpu;
+ }
+
+ /**
+ * 解析内存段(free -m 输出)
+ * Mem: total used free shared buff/cache available
+ */
+ private static Map parseMemory(String block) {
+ Map mem = new LinkedHashMap<>();
+ for (String line : block.split("\n")) {
+ if (line.startsWith("Mem:")) {
+ String[] cols = line.trim().split("\\s+");
+ if (cols.length >= 7) {
+ mem.put("total", cols[1] + " MB");
+ mem.put("used", cols[2] + " MB");
+ mem.put("free", cols[3] + " MB");
+ mem.put("available", cols[6] + " MB");
+ // 使用率
+ try {
+ double total = Double.parseDouble(cols[1]);
+ double used = Double.parseDouble(cols[2]);
+ double pct = total > 0 ? used / total * 100 : 0;
+ mem.put("usagePercent", String.format("%.1f%%", pct));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ break;
+ }
+ }
+ return mem;
+ }
+
+ /**
+ * 解析磁盘段(df -h 输出)
+ * Filesystem Size Used Avail Use% Mounted on
+ */
+ private static List