- connect 失败回退目标校验仍在池中,防幻影本地列表 - SFTP 下载/上传进度跨重试累计,重试入口重置基线 - OSS 签名 base64 改标准字母表,URL 字母表致校验必败 - 剪切粘贴同目录 no-op 清剪贴板,防源文件被改名副本
979 lines
26 KiB
Go
979 lines
26 KiB
Go
package sftp
|
|
|
|
import (
|
|
"sync"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"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 {
|
|
log.Printf("[站点下载] 补充扫描达到上限: count=%d", 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)
|
|
}
|
|
|
|
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
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|