package sftp import ( "sync" "encoding/base64" "errors" "fmt" "log/slog" "io" "io/fs" "os" "path" "path/filepath" "strconv" "strings" "time" "u-desk/internal/common" "u-desk/internal/filesystem" "u-desk/internal/storage" sftpclient "github.com/pkg/sftp" ) // Service SFTP 文件操作服务 type Service struct { manager *Manager } // NewService 创建 SFTP 服务实例 func NewService() *Service { return &Service{manager: GetManager()} } // GetManager 获取底层连接管理器(供 App 层调用) func (s *Service) GetManager() *Manager { return s.manager } // ConnID 从配置生成连接标识符 func ConnID(host string, port int) string { return fmt.Sprintf("%s:%d", host, port) } // --- 核心文件操作 --- func (s *Service) ListDir(connID string, dirPath string) ([]map[string]interface{}, error) { c, err := s.getClient(connID) if err != nil { return nil, err } var entries []fs.FileInfo err = c.WithRetry(func(sc *sftpclient.Client) error { var e error entries, e = sc.ReadDir(dirPath) return e }) if err != nil { return nil, fmt.Errorf("读取目录失败: %w", err) } result := make([]map[string]interface{}, 0, len(entries)) for _, info := range entries { fullPath := path.Join(dirPath, info.Name()) result = append(result, map[string]interface{}{ "name": info.Name(), "path": toUnixPath(fullPath), "is_dir": info.IsDir(), "size": info.Size(), "mod_time": info.ModTime().Format("2006-01-02 15:04:05"), }) } return result, nil } func (s *Service) ReadFile(connID string, filePath string) (string, error) { c, err := s.getClient(connID) if err != nil { return "", err } // 大小限制(与本地模式 ReadFile 的 10MB 上限对齐) const maxSize int64 = 10 << 20 err = c.WithRetry(func(sc *sftpclient.Client) error { fi, e := sc.Stat(filePath) if e != nil { return e } if fi.Size() > maxSize { return fmt.Errorf("文件过大 (%s),超过 %d 限制", common.FormatBytesInt(fi.Size()), maxSize) } return nil }) if err != nil { return "", err } var data []byte err = c.WithRetry(func(sc *sftpclient.Client) error { f, e := sc.Open(filePath) if e != nil { return e } defer f.Close() data, e = io.ReadAll(f) return e }) if err != nil { return "", fmt.Errorf("读取文件失败: %w", err) } return common.BytesToString(data), nil } func (s *Service) WriteFile(connID string, filePath string, content string) error { c, err := s.getClient(connID) if err != nil { return err } return c.WithRetry(func(sc *sftpclient.Client) error { f, e := sc.Create(filePath) if e != nil { return fmt.Errorf("创建文件失败: %w", e) } defer f.Close() _, e = f.Write([]byte(content)) return e }) } // WriteBase64File 将 base64 编码的二进制内容写入远程文件(用于粘贴图片等场景) func (s *Service) WriteBase64File(connID string, filePath string, base64Content string) error { c, err := s.getClient(connID) if err != nil { return err } data, err := base64.StdEncoding.DecodeString(base64Content) if err != nil { return fmt.Errorf("base64 解码失败: %w", err) } return c.WithRetry(func(sc *sftpclient.Client) error { f, e := sc.Create(filePath) if e != nil { return fmt.Errorf("创建文件失败: %w", e) } defer f.Close() _, e = f.Write(data) return e }) } func (s *Service) GetFileInfo(connID string, filePath string) (map[string]interface{}, error) { c, err := s.getClient(connID) if err != nil { return nil, err } var info fs.FileInfo err = c.WithRetry(func(sc *sftpclient.Client) error { var e error info, e = sc.Stat(filePath) return e }) if err != nil { return nil, fmt.Errorf("获取文件信息失败: %w", err) } return map[string]interface{}{ "name": info.Name(), "path": toUnixPath(filePath), "size": info.Size(), "size_str": common.FormatBytesInt(info.Size()), "is_dir": info.IsDir(), "mod_time": info.ModTime().Format("2006-01-02 15:04:05"), "mode": info.Mode().String(), }, nil } func (s *Service) CreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) { c, err := s.getClient(connID) if err != nil { return nil, err } err = c.WithRetry(func(sc *sftpclient.Client) error { return sc.MkdirAll(dirPath) }) if err != nil { return nil, fmt.Errorf("创建目录失败: %w", err) } infoMap, _ := s.GetFileInfo(connID, dirPath) return toFileOperationResult(infoMap, true), nil } func (s *Service) CreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) { c, err := s.getClient(connID) if err != nil { return nil, err } err = c.WithRetry(func(sc *sftpclient.Client) error { f, e := sc.Create(filePath) if e != nil { return e } return f.Close() }) if err != nil { return nil, fmt.Errorf("创建文件失败: %w", err) } infoMap, err := s.GetFileInfo(connID, filePath) if err != nil { slog.Warn("创建后获取详情失败", "path", filePath, "error", err) } return toFileOperationResult(infoMap, false), nil } func (s *Service) DeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) { c, err := s.getClient(connID) if err != nil { return nil, err } infoMap, _ := s.GetFileInfo(connID, filePath) err = c.WithRetry(func(sc *sftpclient.Client) error { fi, e := sc.Stat(filePath) if e != nil { return e } if fi.IsDir() { // 递归删除目录 return sc.RemoveAll(filePath) } return sc.Remove(filePath) }) if err != nil { return nil, fmt.Errorf("删除失败: %w", err) } result := toFileOperationResult(infoMap, false) result.Deleted = true return result, nil } func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesystem.FileOperationResult, error) { c, err := s.getClient(connID) if err != nil { return nil, err } err = c.WithRetry(func(sc *sftpclient.Client) error { return sc.Rename(oldPath, newPath) }) if err != nil { return nil, fmt.Errorf("重命名失败: %w", err) } infoMap, _ := s.GetFileInfo(connID, newPath) result := toFileOperationResult(infoMap, false) result.OldPath = oldPath return result, nil } // DownloadToTemp 下载远程文件到本地临时目录(带 SQLite 缓存) func (s *Service) DownloadToTemp(connID string, remotePath string) (string, error) { // 先获取文件元信息用于缓存键,确保远程文件变更时能淘汰旧缓存 var fileSize int64 var modTime string if info, err := s.GetFileInfo(connID, remotePath); err == nil { if sz, ok := info["size"].(int64); ok { fileSize = sz } if mt, ok := info["mod_time"].(string); ok { modTime = mt } } return storage.DownloadToTempCached("sftp", connID, remotePath, fileSize, modTime, func() (string, error) { return s.downloadToTempDirect(connID, remotePath) }) } // downloadToTempDirect 实际执行下载(无缓存) func (s *Service) downloadToTempDirect(connID string, remotePath string) (string, error) { c, err := s.getClient(connID) if err != nil { return "", err } // 预览文件大小上限 50MB(比编辑模式宽松) const maxPreviewSize int64 = 50 << 20 err = c.WithRetry(func(sc *sftpclient.Client) error { fi, e := sc.Stat(remotePath) if e != nil { return e } if fi.Size() > maxPreviewSize { return fmt.Errorf("预览文件过大: %s", common.FormatBytesInt(fi.Size())) } return nil }) if err != nil { return "", err } tmpDir := os.TempDir() tmpFile, e := os.CreateTemp(tmpDir, "udesk-sftp-*-"+filepath.Base(remotePath)) if e != nil { return "", fmt.Errorf("创建临时文件失败: %w", e) } localPath := tmpFile.Name() tmpFile.Close() err = c.WithRetry(func(sc *sftpclient.Client) error { src, e := sc.Open(remotePath) if e != nil { return e } defer src.Close() dst, e := os.Create(localPath) if e != nil { return e } defer dst.Close() _, e = io.Copy(dst, src) return e }) if err != nil { os.Remove(localPath) return "", fmt.Errorf("下载文件失败: %w", err) } return localPath, nil } // DownloadToTempCached 带缓存的 SFTP 下载(支持传入文件元信息) func (s *Service) DownloadToTempCached(connID, remotePath string, fileSize int64, modTime string) (string, error) { return storage.DownloadToTempCached("sftp", connID, remotePath, fileSize, modTime, func() (string, error) { return s.downloadToTempDirect(connID, remotePath) }) } // DownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录 func (s *Service) DownloadSiteForPreview(connID string, remotePath string) (string, error) { c, err := s.getClient(connID) if err != nil { return "", err } // 1. 缓存检查:用确定性路径,命中直接返回 htmlInfo, _ := s.GetFileInfo(connID, remotePath) htmlSize, _ := htmlInfo["size"].(int64) htmlModTime, _ := htmlInfo["mod_time"].(string) cacheDir := common.SiteCacheDir("sftp", connID, remotePath, htmlSize, htmlModTime) htmlCachePath := filepath.Join(cacheDir, filepath.FromSlash(path.Dir(remotePath)), path.Base(remotePath)) if _, err := os.Stat(htmlCachePath); err == nil { return htmlCachePath, nil // 缓存命中 } os.RemoveAll(cacheDir) // 清理旧缓存 if err := os.MkdirAll(cacheDir, 0755); err != nil { return "", fmt.Errorf("创建缓存目录失败: %w", err) } tmpDir := cacheDir // 2. 确定远程网站根目录(从 HTML 路径推断) keyDir := path.Dir(remotePath) if keyDir == "." { keyDir = "" } var htmlLocalPath string if keyDir != "" { htmlLocalPath = filepath.Join(tmpDir, filepath.FromSlash(keyDir), path.Base(remotePath)) if err := os.MkdirAll(filepath.Dir(htmlLocalPath), 0755); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("创建目录失败: %w", err) } } else { htmlLocalPath = filepath.Join(tmpDir, path.Base(remotePath)) } // 3. 下载 HTML if err := s.sftpDownloadFile(c, remotePath, htmlLocalPath); err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("下载 HTML 失败: %w", err) } // 4. 解析 HTML 提取资源路径 htmlContent, err := os.ReadFile(htmlLocalPath) if err != nil { return htmlLocalPath, nil } resources := common.ExtractHtmlResources(string(htmlContent)) // 5. 下载静态引用资源(嗅探网站根) htmlRemoteDir := keyDir if htmlRemoteDir == "/" { htmlRemoteDir = "" } htmlLocalDir := filepath.Dir(htmlLocalPath) var siteRoot string var discoveredDirs []string seenDir := make(map[string]bool) recordDir := func(remoteKey string) { dir := path.Dir(remoteKey) if !seenDir[dir] { seenDir[dir] = true discoveredDirs = append(discoveredDirs, dir) } } // 绝对路径资源:串行嗅探 siteRoot,相对路径:并行下载 type relativeTask struct{ remoteKey, localPath string } var relativeTasks []relativeTask for _, resPath := range resources { if common.ShouldSkipResource(resPath) { continue } isAbsolute := strings.HasPrefix(resPath, "/") cleanPath := strings.TrimPrefix(resPath, "/") cleanPath = strings.TrimPrefix(cleanPath, "./") if cleanPath == "" { continue } localPath := filepath.Join(htmlLocalDir, filepath.FromSlash(cleanPath)) if isAbsolute { resolvedKey := sftpResolveAndDownload(s, c, htmlRemoteDir, cleanPath, localPath, &siteRoot) if resolvedKey != "" { recordDir(resolvedKey) } } else { remoteKey := path.Join(htmlRemoteDir, cleanPath) relativeTasks = append(relativeTasks, relativeTask{remoteKey, localPath}) } } // 相对路径资源并行下载(recordDir 需加锁,多 goroutine 并发回调) var mu sync.Mutex common.RunConcurrent(relativeTasks, common.SiteDownloadConcurrency, func(t relativeTask) { if s.sftpTryDownload(c, t.remoteKey, t.localPath) { mu.Lock() recordDir(t.remoteKey) mu.Unlock() } }) // 6. 补充下载已发现目录中的剩余文件(覆盖动态 chunk 等) for _, dir := range discoveredDirs { sftpSupplementDir(s, c, dir, tmpDir, siteRoot) } return htmlLocalPath, nil } // sftpDownloadFile 下载单个远程文件到本地路径 func (s *Service) sftpDownloadFile(c *Client, remotePath, localPath string) error { if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { return err } return c.WithRetry(func(sc *sftpclient.Client) error { src, err := sc.Open(remotePath) if err != nil { return err } defer src.Close() dst, err := os.Create(localPath) if err != nil { return err } defer dst.Close() _, err = io.Copy(dst, src) return err }) } // sftpTryDownload 尝试下载(失败静默,返回是否成功) func (s *Service) sftpTryDownload(c *Client, remotePath, localPath string) bool { err := s.sftpDownloadFile(c, remotePath, localPath) if err != nil { os.Remove(localPath) return false } return true } // sftpResolveAndDownload 嗅探网站根并下载绝对路径资源 func sftpResolveAndDownload(s *Service, c *Client, htmlDir string, cleanPath string, localPath string, siteRoot *string) string { if *siteRoot != "" { s.sftpTryDownload(c, *siteRoot+cleanPath, localPath) return *siteRoot + cleanPath } dir := htmlDir for { candidate := path.Join(dir, cleanPath) if s.sftpTryDownload(c, candidate, localPath) { if dir == "" { *siteRoot = "" } else { *siteRoot = dir + "/" } return candidate } if dir == "" { break } parent := path.Dir(dir) if parent == dir || parent == "." { dir = "" } else { dir = parent } } return "" } // sftpSupplementDir 补充下载远程目录中尚未下载的文件(只处理已知资源所在目录) // 数量与单文件大小上限见 common.MaxSupplementFiles / common.MaxSupplementFileSize func sftpSupplementDir(s *Service, c *Client, remoteDir string, tmpDir string, siteRoot string) { var entries []fs.FileInfo err := c.WithRetry(func(sc *sftpclient.Client) error { var e error entries, e = sc.ReadDir(remoteDir) return e }) if err != nil { return } count := 0 for _, entry := range entries { if entry.IsDir() || entry.Size() == 0 || entry.Size() > common.MaxSupplementFileSize { continue } if count >= common.MaxSupplementFiles { slog.Warn("站点下载补充扫描达到上限", "count", common.MaxSupplementFiles) break } fullPath := path.Join(remoteDir, entry.Name()) localPath := filepath.Join(tmpDir, filepath.FromSlash(fullPath)) if _, err := os.Stat(localPath); err == nil { continue } s.sftpTryDownload(c, fullPath, localPath) count++ } } // DownloadToFile 下载远程文件/目录到用户指定的本地目录(流式,无大小上限),返回实际落地路径 // 目标重名自动追加「 - 副本」后缀;onProgress 按累计字节回调(name 为当前文件名) func (s *Service) DownloadToFile(connID, remotePath, localDstDir string, onProgress func(fileName string, copied, total int64)) (string, error) { c, err := s.getClient(connID) if err != nil { return "", err } var info fs.FileInfo err = c.WithRetry(func(sc *sftpclient.Client) error { var e error info, e = sc.Stat(remotePath) return e }) if err != nil { return "", fmt.Errorf("获取远程文件信息失败: %w", err) } // 单文件:直接流式落地 if !info.IsDir() { dstPath := filesystem.UniquePath(filepath.Join(localDstDir, path.Base(remotePath))) var copied int64 if err := s.downloadFileTo(c, remotePath, dstPath, &copied, info.Size(), onProgress); err != nil { return "", err } return dstPath, nil } // 目录:先收集远端文件清单并统计总大小 type remoteEntry struct { remote, local string size int64 } var entries []remoteEntry var total int64 var walk func(dir, localDir string) error walk = func(dir, localDir string) error { var list []fs.FileInfo err := c.WithRetry(func(sc *sftpclient.Client) error { var e error list, e = sc.ReadDir(dir) return e }) if err != nil { return err } for _, fi := range list { rp := path.Join(dir, fi.Name()) lp := filepath.Join(localDir, fi.Name()) if fi.IsDir() { if err := walk(rp, lp); err != nil { return err } } else if fi.Mode().IsRegular() { entries = append(entries, remoteEntry{rp, lp, fi.Size()}) total += fi.Size() } } return nil } dstDir := filesystem.UniquePath(filepath.Join(localDstDir, path.Base(remotePath))) if err := walk(remotePath, dstDir); err != nil { return "", fmt.Errorf("遍历远程目录失败: %w", err) } // 空目录时清单为空,downloadFileTo 不会执行,目标目录须显式创建 if err := os.MkdirAll(dstDir, 0755); err != nil { return "", fmt.Errorf("创建本地目录失败: %w", err) } var copied int64 for _, e := range entries { if err := s.downloadFileTo(c, e.remote, e.local, &copied, total, onProgress); err != nil { return "", err } } return dstDir, nil } // downloadFileTo 流式下载单个远程文件,进度按累计字节回调 func (s *Service) downloadFileTo(c *Client, remotePath, localPath string, copied *int64, total int64, onProgress func(fileName string, copied, total int64)) error { if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { return fmt.Errorf("创建本地目录失败: %w", err) } // 重试会整文件重写,进度须回到进入前基线,否则跨重试累计使 copied 超过 total base := *copied return c.WithRetry(func(sc *sftpclient.Client) error { *copied = base src, err := sc.Open(remotePath) if err != nil { return err } defer src.Close() dst, err := os.Create(localPath) if err != nil { return err } defer dst.Close() var reader io.Reader = src if onProgress != nil { reader = &common.CountingReader{R: src, OnN: func(n int64) { *copied += n onProgress(path.Base(remotePath), *copied, total) }} } _, err = io.Copy(dst, reader) return err }) } // UploadFromFile 上传本地文件/目录到远程目录(流式),远端重名自动追加副本后缀 func (s *Service) UploadFromFile(connID, localPath, remoteDir string, onProgress func(fileName string, copied, total int64)) error { c, err := s.getClient(connID) if err != nil { return err } info, err := os.Stat(localPath) if err != nil { return fmt.Errorf("本地路径不存在: %w", err) } // 单文件:直接流式上传 if !info.IsDir() { remotePath, err := s.uniqueRemotePath(c, path.Join(remoteDir, filepath.Base(localPath))) if err != nil { return err } var copied int64 return s.uploadFileTo(c, localPath, remotePath, &copied, info.Size(), onProgress) } // 目录:本地清单 + 总大小(相对根路径,远端落地根确定后再拼完整路径) type localEntry struct { local, rel string size int64 } var files []localEntry var total int64 err = filepath.WalkDir(localPath, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.Type().IsRegular() { return nil } fi, e := d.Info() if e != nil { return e } rel, e := filepath.Rel(localPath, p) if e != nil { return e } files = append(files, localEntry{p, filepath.ToSlash(rel), fi.Size()}) total += fi.Size() return nil }) if err != nil { return fmt.Errorf("遍历本地目录失败: %w", err) } remoteRoot, err := s.uniqueRemotePath(c, path.Join(remoteDir, filepath.Base(localPath))) if err != nil { return err } // 空目录时文件清单为空,uploadFileTo 不会执行,远端根目录须显式创建 if err := c.WithRetry(func(sc *sftpclient.Client) error { return sc.MkdirAll(remoteRoot) }); err != nil { return fmt.Errorf("创建远程目录失败: %w", err) } var copied int64 for _, f := range files { if err := s.uploadFileTo(c, f.local, path.Join(remoteRoot, f.rel), &copied, total, onProgress); err != nil { return err } } return nil } // uploadFileTo 流式上传单个本地文件(自动创建远端父目录),进度按累计字节回调 func (s *Service) uploadFileTo(c *Client, localPath, remotePath string, copied *int64, total int64, onProgress func(fileName string, copied, total int64)) error { // 重试会整文件重传,进度须回到进入前基线,否则跨重试累计使 copied 超过 total base := *copied return c.WithRetry(func(sc *sftpclient.Client) error { *copied = base if err := sc.MkdirAll(path.Dir(remotePath)); err != nil { return err } src, err := os.Open(localPath) if err != nil { return err } defer src.Close() dst, err := sc.Create(remotePath) if err != nil { return err } defer dst.Close() var reader io.Reader = src if onProgress != nil { reader = &common.CountingReader{R: src, OnN: func(n int64) { *copied += n onProgress(filepath.Base(localPath), *copied, total) }} } _, err = io.Copy(dst, reader) return err }) } // uniqueRemotePath 远端重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入) // 命名循环复用 common.UniqueName(与本地 UniquePath 同源) func (s *Service) uniqueRemotePath(c *Client, remotePath string) (string, error) { dir := path.Dir(remotePath) unique, err := common.UniqueName(path.Base(remotePath), func(candidate string) (bool, error) { return s.remoteExists(c, path.Join(dir, candidate)) }) if err != nil { return "", err } return path.Join(dir, unique), nil } // remoteExists 判断远程路径是否存在(区分不存在与网络错误) func (s *Service) remoteExists(c *Client, p string) (bool, error) { var notExist bool err := c.WithRetry(func(sc *sftpclient.Client) error { _, e := sc.Stat(p) if e == nil { return nil } if errors.Is(e, fs.ErrNotExist) { notExist = true return nil } return e }) if err != nil { return false, err } return !notExist, nil } // GetCommonPaths 返回 SFTP 远程主机常用路径 func (s *Service) GetCommonPaths(connID string) (map[string]string, error) { c := s.manager.GetClient(connID) username := "root" if c != nil { c.mu.Lock() username = c.config.Username c.mu.Unlock() } home := "/root" if username != "root" && username != "" { home = fmt.Sprintf("/home/%s", username) } return map[string]string{ "home": home, "tmp": "/tmp", "root": "/", }, nil } // CleanupTempFiles 清理遗留的临时预览文件(已由 SQLite 缓存接管) func CleanupTempFiles() { storage.CleanupExpiredCache() } // GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存) func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) { c, err := s.getClient(connID) if err != nil { return nil, err } type cmdResult struct { key string // "df" | "mem" | "cpu" output string err error } // 并发执行三条命令(每条独立超时) results := make(chan cmdResult, 3) cmdTimeout := 6 * time.Second runCmd := func(key, cmd string) { out, e := c.RunCommand(cmd) results <- cmdResult{key, out, e} } go runCmd("df", "df -B1 / 2>/dev/null | tail -1") go runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2") go runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1") var dfOut, memOut, cpuOut string hasErr := false for i := 0; i < 3; i++ { select { case r := <-results: switch r.key { case "df": dfOut = r.output case "mem": memOut = r.output case "cpu": cpuOut = r.output } if r.err != nil { hasErr = true } case <-time.After(cmdTimeout): hasErr = true } } info := make(map[string]interface{}) // 解析 CPU 使用率 cpuOut = strings.TrimSpace(cpuOut) if usage, err := strconv.ParseFloat(cpuOut, 64); err == nil && usage >= 0 { info["cpu_usage"] = fmt.Sprintf("%.0f%%", usage) } // 解析磁盘信息: df -B1 / → Filesystem 1M-blocks Used Available Use% Mounted on dfOut = strings.TrimSpace(dfOut) if dfFields := strings.Fields(dfOut); len(dfFields) >= 5 { var diskTotal, diskUsed uint64 if v, err := strconv.ParseUint(dfFields[1], 10, 64); err == nil { diskTotal = v info["disk_total"] = v } if v, err := strconv.ParseUint(dfFields[2], 10, 64); err == nil { diskUsed = v info["disk_used"] = v } if diskTotal > 0 { info["disk_usage"] = fmt.Sprintf("%.0f%%", float64(diskUsed)/float64(diskTotal)*100) } } // 解析内存信息: free -b | grep Mem: 或 /proc/meminfo memOut = strings.TrimSpace(memOut) if strings.Contains(memOut, "MemTotal:") { parseProcMeminfo(memOut, info) } else if fields := strings.Fields(memOut); len(fields) >= 3 { var memTotal, memUsed uint64 if v, err := strconv.ParseUint(fields[1], 10, 64); err == nil { memTotal = v info["mem_total"] = v } if v, err := strconv.ParseUint(fields[2], 10, 64); err == nil { memUsed = v info["mem_used"] = v } if memTotal > 0 { info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(memUsed)/float64(memTotal)*100) } } if hasErr && len(info) == 0 { return nil, fmt.Errorf("采集远程系统信息失败") } return info, nil } func parseProcMeminfo(output string, info map[string]interface{}) { lines := strings.Split(output, "\n") memMap := make(map[string]uint64) for _, line := range lines { fields := strings.Fields(line) if len(fields) >= 2 { key := strings.TrimSuffix(fields[0], ":") if val, err := strconv.ParseUint(fields[1], 10, 64); err == nil { memMap[key] = val } } } total := memMap["MemTotal"] * 1024 // kB → bytes // 可用内存 ≈ MemAvailable (较新内核) 或 MemFree + Buffers + Cached free := memMap["MemFree"] if avail, ok := memMap["MemAvailable"]; ok { free = avail } else { free += memMap["Buffers"] + memMap["Cached"] } used := total - free*1024 info["mem_total"] = total info["mem_used"] = used if total > 0 { info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(used)/float64(total)*100) } } // --- 内部辅助 --- func (s *Service) getClient(connID string) (*Client, error) { c := s.manager.GetClient(connID) if c == nil { return nil, fmt.Errorf("SFTP 连接不存在: %s", connID) } if c.IsClosed() { // 保活失败或显式断开置位,剔除死连接避免后续操作继续挂在上面 s.manager.Evict(connID) return nil, fmt.Errorf("SFTP 连接已断开,请重新连接: %s", connID) } return c, nil } func toUnixPath(p string) string { return strings.ReplaceAll(p, "\\", "/") } func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.FileOperationResult { name, _ := m["name"].(string) p, _ := m["path"].(string) size, _ := m["size"].(int64) modTime, _ := m["mod_time"].(string) mode, _ := m["mode"].(string) return &filesystem.FileOperationResult{ Path: p, Name: name, Size: size, SizeStr: common.FormatBytesInt(size), IsDir: isDir, ModTime: modTime, Mode: mode, } }