Files
u-desk/internal/common/utils.go
T
lxy 6ea9d9ac99 修复: SFTP连接首拉不显示与传输挂起
- watcher握手期伪触发致幻影本地列表,根因修复
- 连接探测2.5s超时竞速,握手10s预算
- 30s保活自动掐死半开连接,取连接时剔除已断开
- 副本命名与进度计数抽公共,下载缓存清理去重
2026-09-15 22:26:22 +08:00

251 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
func InterfaceSliceToStringSlice(slice []interface{}) []string {
result := make([]string, 0, len(slice))
for _, v := range slice {
if str, ok := v.(string); ok && str != "" {
result = append(result, str)
}
}
return result
}
// FormatBytes 格式化字节大小为人类可读格式(uint64 版本)
func FormatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
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 {
if s == item {
return true
}
}
return false
}
// Difference 返回在 a 中但不在 b 中的元素
func Difference[T comparable](a, b []T) []T {
mb := make(map[T]struct{}, len(b))
for _, x := range b {
mb[x] = struct{}{}
}
var diff []T
for _, x := range a {
if _, found := mb[x]; !found {
diff = append(diff, x)
}
}
return diff
}
// IsWindows 判断是否为Windows系统
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
}