修复: SFTP连接首拉不显示与传输挂起
- watcher握手期伪触发致幻影本地列表,根因修复 - 连接探测2.5s超时竞速,握手10s预算 - 30s保活自动掐死半开连接,取连接时剔除已断开 - 副本命名与进度计数抽公共,下载缓存清理去重
This commit is contained in:
+189
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user