修复: SFTP连接首拉不显示与传输挂起

- watcher握手期伪触发致幻影本地列表,根因修复
- 连接探测2.5s超时竞速,握手10s预算
- 30s保活自动掐死半开连接,取连接时剔除已断开
- 副本命名与进度计数抽公共,下载缓存清理去重
This commit is contained in:
lxy
2026-09-15 22:26:22 +08:00
parent e7a2c5148e
commit 6ea9d9ac99
9 changed files with 731 additions and 125 deletions
+189 -2
View File
@@ -1,8 +1,20 @@
package common
import (
"bytes"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string
@@ -16,8 +28,7 @@ func InterfaceSliceToStringSlice(slice []interface{}) []string {
return result
}
// FormatBytes 格式化字节大小为人类可读格式
// 例如: 1024 → "1.00 KB", 1048576 → "1.00 MB"
// FormatBytes 格式化字节大小为人类可读格式(uint64 版本)
func FormatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
@@ -31,6 +42,104 @@ func FormatBytes(bytes uint64) string {
return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// FormatBytesInt 格式化字节大小(int64 版本,用于文件大小)
func FormatBytesInt(bytes int64) string {
return FormatBytes(uint64(bytes))
}
// CopyFile 复制文件内容
func CopyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = out.ReadFrom(in)
return err
}
// BytesToString 智能编码转换:UTF-8 直接返回,否则尝试 GBK → UTF-8
func BytesToString(data []byte) string {
if utf8.Valid(data) {
return string(data)
}
reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder())
decoded, err := io.ReadAll(reader)
if err != nil || !utf8.Valid(decoded) {
return string(data)
}
return string(decoded)
}
var (
htmlResRegex = regexp.MustCompile(`(?:src|href|data-src|data-url|poster)=["']([^"']+)["']`)
htmlSrcsetRegex = regexp.MustCompile(`srcset\s*=\s*["']([^"']+)["']`)
htmlCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
)
// parseSrcsetUrls 从 srcset 属性值中提取各 URL
// srcset 格式: "small.jpg 480w, large.jpg 1024w" → ["small.jpg", "large.jpg"]
func parseSrcsetUrls(srcset string) []string {
var urls []string
for _, candidate := range strings.Split(srcset, ",") {
parts := strings.Fields(strings.TrimSpace(candidate))
if len(parts) > 0 {
urls = append(urls, parts[0])
}
}
return urls
}
// ExtractHtmlResources 从 HTML 内容提取资源路径
func ExtractHtmlResources(html string) []string {
seen := make(map[string]bool)
var resources []string
add := func(v string) {
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
seen[v] = true
resources = append(resources, v)
}
}
for _, m := range htmlResRegex.FindAllStringSubmatch(html, -1) {
if len(m) > 1 {
add(m[1])
}
}
for _, m := range htmlSrcsetRegex.FindAllStringSubmatch(html, -1) {
if len(m) > 1 {
for _, u := range parseSrcsetUrls(m[1]) {
add(u)
}
}
}
for _, m := range htmlCssUrlRe.FindAllStringSubmatch(html, -1) {
if len(m) > 1 {
add(m[1])
}
}
return resources
}
// ShouldSkipResource 判断资源路径是否应跳过
func ShouldSkipResource(p string) bool {
return strings.HasPrefix(p, "data:") ||
strings.HasPrefix(p, "http://") ||
strings.HasPrefix(p, "https://") ||
strings.HasPrefix(p, "//") ||
strings.HasPrefix(p, "#") ||
strings.HasPrefix(p, "javascript:") ||
strings.HasPrefix(p, "mailto:") ||
strings.HasPrefix(p, "blob:")
}
// Contains 检查切片是否包含元素
func Contains[T comparable](slice []T, item T) bool {
for _, s := range slice {
@@ -61,3 +170,81 @@ func IsWindows() bool {
return runtime.GOOS == "windows"
}
// siteCacheDir 返回站点预览的确定性缓存目录路径
func SiteCacheDir(transport, connID, remotePath string, fileSize int64, modTime string) string {
h := sha256.New()
h.Write([]byte(fmt.Sprintf("site:%s:%s:%s:%d:%s", transport, connID, remotePath, fileSize, modTime)))
hash := fmt.Sprintf("%x", h.Sum(nil))[:16]
return filepath.Join(os.TempDir(), "u-desk-cache", "site-"+hash)
}
// 站点预览补充下载限制(sftp 与 ossdrv 共用)
const (
MaxSupplementFiles = 50 // 每目录补充下载文件数上限
MaxSupplementFileSize = 5 << 20 // 单文件补充下载大小上限(5MB)
SiteDownloadConcurrency = 8 // 相对路径资源并行下载并发数
)
// RunConcurrent 以最多 limit 个并发执行 tasks,全部完成后返回
// fn 在独立 goroutine 中运行,需自行处理错误与共享状态的加锁
func RunConcurrent[T any](tasks []T, limit int, fn func(t T)) {
if len(tasks) == 0 || limit <= 0 {
return
}
if limit > len(tasks) {
limit = len(tasks)
}
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
for _, task := range tasks {
sem <- struct{}{}
wg.Add(1)
go func(t T) {
defer func() { <-sem; wg.Done() }()
fn(t)
}(task)
}
wg.Wait()
}
// UniqueName 返回不与现有条目冲突的文件名:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)。
// exists 报告候选文件名是否已被占用(本地文件系统与 SFTP 各自提供实现)。
func UniqueName(name string, exists func(candidate string) (bool, error)) (string, error) {
taken, err := exists(name)
if err != nil {
return "", err
}
if !taken {
return name, nil
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; ; i++ {
suffix := " - 副本"
if i > 1 {
suffix = fmt.Sprintf(" - 副本 (%d)", i)
}
candidate := base + suffix + ext
taken, err := exists(candidate)
if err != nil {
return "", err
}
if !taken {
return candidate, nil
}
}
}
// CountingReader 包装读取器,按实际读取字节数回调 OnN(n > 0 时)
type CountingReader struct {
R io.Reader
OnN func(n int64)
}
func (cr *CountingReader) Read(buf []byte) (int, error) {
n, err := cr.R.Read(buf)
if n > 0 {
cr.OnN(int64(n))
}
return n, err
}