Private
Public Access
1
0

新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放

This commit is contained in:
2026-05-12 11:06:28 +08:00
parent 545d7a864d
commit 2a363fd729
62 changed files with 6687 additions and 660 deletions

View File

@@ -0,0 +1,187 @@
package storage
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"time"
"u-desk/internal/storage/models"
"gorm.io/gorm"
)
const downloadCacheTTL = 24 * time.Hour
// cacheTempDir 确定性临时目录
var cacheTempDir = filepath.Join(os.TempDir(), "u-desk-cache")
// GetCachedPath 查询缓存,验证文件存在后返回本地路径
func GetCachedPath(transport, connID, remotePath string, fileSize int64, modTime string) (string, bool) {
db := GetDB()
if db == nil {
return "", false
}
var entry models.DownloadCache
err := db.Where("transport = ? AND conn_id = ? AND remote_path = ? AND file_size = ? AND mod_time = ?",
transport, connID, remotePath, fileSize, modTime).First(&entry).Error
if err != nil {
return "", false
}
// 检查文件是否仍然存在于磁盘
if _, err := os.Stat(entry.LocalPath); err != nil {
// 文件已丢失,清理过期记录
db.Delete(&entry)
return "", false
}
// 检查是否过期
if time.Since(entry.DownloadedAt) > downloadCacheTTL {
os.Remove(entry.LocalPath)
db.Delete(&entry)
return "", false
}
return entry.LocalPath, true
}
// SaveCache 保存或更新缓存记录
func SaveCache(transport, connID, remotePath string, fileSize int64, modTime, localPath string) {
db := GetDB()
if db == nil {
return
}
var existing models.DownloadCache
err := db.Where("transport = ? AND conn_id = ? AND remote_path = ? AND file_size = ? AND mod_time = ?",
transport, connID, remotePath, fileSize, modTime).First(&existing).Error
if err == gorm.ErrRecordNotFound {
db.Create(&models.DownloadCache{
Transport: transport,
ConnID: connID,
RemotePath: remotePath,
FileSize: fileSize,
ModTime: modTime,
LocalPath: localPath,
DownloadedAt: time.Now(),
})
} else if err == nil {
db.Model(&existing).Updates(map[string]any{
"local_path": localPath,
"downloaded_at": time.Now(),
})
}
}
// CleanupExpiredCache 清理超过 24h 的缓存记录并删除对应临时文件
func CleanupExpiredCache() {
db := GetDB()
if db == nil {
return
}
cutoff := time.Now().Add(-downloadCacheTTL)
var expired []models.DownloadCache
db.Where("downloaded_at < ?", cutoff).Find(&expired)
for _, entry := range expired {
os.Remove(entry.LocalPath)
db.Delete(&entry)
}
if len(expired) > 0 {
fmt.Printf("[下载缓存] 清理 %d 条过期记录\n", len(expired))
}
}
// DownloadToTempCached 带缓存的下载:命中返回本地路径,未命中调用 downloadFn 后缓存结果
func DownloadToTempCached(transport, connID, remotePath string, fileSize int64, modTime string, downloadFn func() (string, error)) (string, error) {
// 1. 查缓存
if localPath, hit := GetCachedPath(transport, connID, remotePath, fileSize, modTime); hit {
return localPath, nil
}
// 2. 缓存未命中,执行下载
tempPath, err := downloadFn()
if err != nil {
return "", err
}
// 3. 生成确定性路径并移动文件
deterministicPath, err := deterministicCachePath(transport, connID, remotePath, fileSize, modTime)
if err != nil {
// 降级:直接使用 downloadFn 返回的路径,仍然缓存
SaveCache(transport, connID, remotePath, fileSize, modTime, tempPath)
return tempPath, nil
}
// 确保目录存在
if err := os.MkdirAll(filepath.Dir(deterministicPath), 0755); err != nil {
SaveCache(transport, connID, remotePath, fileSize, modTime, tempPath)
return tempPath, nil
}
// 移动文件到确定性路径
if err := os.Rename(tempPath, deterministicPath); err != nil {
// Rename 可能跨卷失败,尝试 Copy+Delete
if copyFile(tempPath, deterministicPath) != nil {
SaveCache(transport, connID, remotePath, fileSize, modTime, tempPath)
return tempPath, nil
}
os.Remove(tempPath)
}
SaveCache(transport, connID, remotePath, fileSize, modTime, deterministicPath)
return deterministicPath, nil
}
// deterministicCachePath 根据文件信息生成确定性的缓存路径
func deterministicCachePath(transport, connID, remotePath string, fileSize int64, modTime string) (string, error) {
h := sha256.New()
h.Write([]byte(fmt.Sprintf("%s:%s:%s:%d:%s", transport, connID, remotePath, fileSize, modTime)))
hash := fmt.Sprintf("%x", h.Sum(nil))[:16]
baseName := filepath.Base(remotePath)
if baseName == "" || baseName == "." || baseName == "/" {
baseName = "file"
}
// 截断过长的文件名
if len(baseName) > 64 {
ext := filepath.Ext(baseName)
maxName := 64 - len(ext)
if maxName <= 0 {
maxName = 1
ext = ext[:63]
}
baseName = baseName[:maxName] + ext
}
fileName := fmt.Sprintf("%s_%s", hash, baseName)
return filepath.Join(cacheTempDir, fileName), nil
}
// 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()
if _, err := out.ReadFrom(in); err != nil {
os.Remove(dst)
return err
}
return nil
}

View File

@@ -0,0 +1,12 @@
package models
// BgmPlaylist BGM 播放列表持久化
type BgmPlaylist struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null;size:255"`
Path string `gorm:"not null;size:500;uniqueIndex"`
ProfileID string `gorm:"type:varchar(50)" json:"profile_id"`
Sort uint `gorm:"not null"`
}
func (BgmPlaylist) TableName() string { return "bgm_playlist" }

View File

@@ -11,7 +11,8 @@ type ConnectionProfile struct {
Username string `gorm:"type:varchar(100);default:root" json:"username"`
Password string `gorm:"type:text" json:"password"`
KeyPath string `gorm:"type:text" json:"key_path"`
Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp|qiniu|aliyun
Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp|oss
Provider string `gorm:"type:varchar(20)" json:"provider"` // qiniu|aliyun (仅 type=oss)
Token string `gorm:"type:text" json:"token"`
AccessKey string `gorm:"type:text" json:"access_key"`
SecretKey string `gorm:"type:text" json:"secret_key"`

View File

@@ -0,0 +1,17 @@
package models
import "time"
// DownloadCache 下载缓存模型SQLite 持久化)
type DownloadCache struct {
ID uint `gorm:"primaryKey"`
Transport string `gorm:"not null;size:10;index:idx_cache_lookup"`
ConnID string `gorm:"not null;index:idx_cache_lookup"`
RemotePath string `gorm:"not null;index:idx_cache_lookup"`
FileSize int64 `gorm:"not null;index:idx_cache_lookup"`
ModTime string `gorm:"not null;index:idx_cache_lookup"`
LocalPath string `gorm:"not null"`
DownloadedAt time.Time `gorm:"not null"`
}
func (DownloadCache) TableName() string { return "download_cache" }

View File

@@ -53,10 +53,14 @@ func InitFast() (*gorm.DB, error) {
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(time.Hour)
if e := db.AutoMigrate(&models.AppConfig{}, &models.ConnectionProfile{}); e != nil {
if e := db.AutoMigrate(&models.AppConfig{}, &models.ConnectionProfile{}, &models.DownloadCache{}, &models.BgmPlaylist{}); e != nil {
initErr = e
return
}
// 数据迁移qiniu/aliyun → oss + provider
db.Exec("UPDATE connection_profiles SET provider = type, type = 'oss' WHERE type IN ('qiniu', 'aliyun')")
// 为旧 BGM 播放列表补充 profile_id找第一个 OSS profile
db.Exec("UPDATE bgm_playlist SET profile_id = (SELECT CAST(id AS VARCHAR) FROM connection_profiles WHERE type = 'oss' LIMIT 1) WHERE (profile_id = '' OR profile_id IS NULL) AND path NOT LIKE '%:'")
globalDB = db
})
if initErr != nil {