From afc68bf8614d295304403da8604451e86267519d Mon Sep 17 00:00:00 2001 From: eson Date: Mon, 29 Dec 2025 06:22:19 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(server):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=B3=BB=E7=BB=9F=E5=92=8C=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=B5=81=E5=A4=84=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 初始化512MB内存缓存系统,TTL设置为60分钟 - 添加支持Range请求的视频流处理器,优化大文件加载性能 - 使用gin.Recovery()增强服务稳定性 - 将静态文件服务替换为专门的视频流处理器 fix(config): 更新服务器主机地址 - 将Host地址从192.168.124.2更新为192.168.124.8 ``` --- server/cache.go | 158 +++++++++++++++++++++++++ server/main.go | 9 +- server/video_handler.go | 255 ++++++++++++++++++++++++++++++++++++++++ src/Config.jsx | 2 +- 4 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 server/cache.go create mode 100644 server/video_handler.go diff --git a/server/cache.go b/server/cache.go new file mode 100644 index 0000000..4f83194 --- /dev/null +++ b/server/cache.go @@ -0,0 +1,158 @@ +package main + +import ( + "sync" + "time" +) + +// CacheItem 缓存项 +type CacheItem struct { + data []byte + timestamp time.Time + size int +} + +// ThumbnailCache 缩略图缓存 +type ThumbnailCache struct { + items map[string]*CacheItem + mu sync.RWMutex + maxSize int64 // 最大缓存大小(字节) + usedSize int64 // 已使用大小 + ttl time.Duration // 缓存过期时间 +} + +// NewThumbnailCache 创建新的缩略图缓存 +func NewThumbnailCache(maxSizeMB int, ttlMinutes int) *ThumbnailCache { + return &ThumbnailCache{ + items: make(map[string]*CacheItem), + maxSize: int64(maxSizeMB) * 1024 * 1024, + ttl: time.Duration(ttlMinutes) * time.Minute, + } +} + +// Get 获取缓存项 +func (c *ThumbnailCache) Get(key string) ([]byte, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + item, exists := c.items[key] + if !exists { + return nil, false + } + + // 检查是否过期 + if time.Since(item.timestamp) > c.ttl { + go c.Remove(key) + return nil, false + } + + return item.data, true +} + +// Set 设置缓存项 +func (c *ThumbnailCache) Set(key string, data []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + // 如果已存在,先删除旧的 + if item, exists := c.items[key]; exists { + c.usedSize -= int64(item.size) + delete(c.items, key) + } + + // 检查是否需要清理空间 + itemSize := len(data) + if int64(itemSize) > c.maxSize { + // 单个文件太大,不缓存 + return + } + + // 清理过期或最旧的项直到有足够空间 + c.cleanup(int64(itemSize)) + + // 添加新项 + c.items[key] = &CacheItem{ + data: data, + timestamp: time.Now(), + size: itemSize, + } + c.usedSize += int64(itemSize) +} + +// Remove 移除缓存项 +func (c *ThumbnailCache) Remove(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + if item, exists := c.items[key]; exists { + c.usedSize -= int64(item.size) + delete(c.items, key) + } +} + +// Clear 清空缓存 +func (c *ThumbnailCache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + c.items = make(map[string]*CacheItem) + c.usedSize = 0 +} + +// cleanup 清理缓存,确保有足够空间 +func (c *ThumbnailCache) cleanup(requiredSpace int64) { + // 首先清理过期项 + now := time.Now() + for key, item := range c.items { + if now.Sub(item.timestamp) > c.ttl { + c.usedSize -= int64(item.size) + delete(c.items, key) + } + } + + // 如果还不够,清理最旧的项 + for c.usedSize+requiredSpace > c.maxSize && len(c.items) > 0 { + var oldestKey string + var oldestTime time.Time + + for key, item := range c.items { + if oldestKey == "" || item.timestamp.Before(oldestTime) { + oldestKey = key + oldestTime = item.timestamp + } + } + + if oldestKey != "" { + item := c.items[oldestKey] + c.usedSize -= int64(item.size) + delete(c.items, oldestKey) + } + } +} + +// Size 获取缓存大小(字节) +func (c *ThumbnailCache) Size() int64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.usedSize +} + +// Count 获取缓存项数量 +func (c *ThumbnailCache) Count() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.items) +} + +// Stats 获取缓存统计信息 +func (c *ThumbnailCache) Stats() map[string]interface{} { + c.mu.RLock() + defer c.mu.RUnlock() + + return map[string]interface{}{ + "count": len(c.items), + "usedSize": c.usedSize, + "maxSize": c.maxSize, + "usagePercent": float64(c.usedSize) / float64(c.maxSize) * 100, + } +} \ No newline at end of file diff --git a/server/main.go b/server/main.go index 9ccd9e8..4b9f890 100644 --- a/server/main.go +++ b/server/main.go @@ -9,13 +9,20 @@ import ( func main() { initMovie() + + // 初始化缓存系统(512MB内存缓存,TTL 60分钟) + InitCache(512, 60) // 设置为发布模式 gin.SetMode(gin.ReleaseMode) eg := gin.Default() eg.Use(Cors()) - eg.Static("/res", "movie/") + eg.Use(gin.Recovery()) + + // 使用支持 Range 请求的视频流处理器(优化大文件加载性能) + // 这个处理器会处理所有 /res/ 下的文件,包括视频和缩略图 + eg.GET("/res/:filename", StreamVideo) eg.Static("/static", "../build/static") eg.StaticFile("/manifest.json", "../build/manifest.json") eg.StaticFile("/favicon.ico", "../build/favicon.ico") diff --git a/server/video_handler.go b/server/video_handler.go new file mode 100644 index 0000000..e9959ad --- /dev/null +++ b/server/video_handler.go @@ -0,0 +1,255 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// 全局缩略图缓存 +var thumbnailCache *ThumbnailCache + +// StreamVideo 处理视频流式传输,支持 HTTP Range 请求 +func StreamVideo(c *gin.Context) { + filename := c.Param("filename") + if filename == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename is required"}) + return + } + + // 清理路径,防止目录遍历攻击 + filename = filepath.Base(filename) + filePath := filepath.Join("movie", filename) + + // 检查是否是缩略图(PNG文件) + if strings.HasSuffix(strings.ToLower(filename), ".png") { + serveThumbnail(c, filePath, filename) + return + } + + // 检查文件是否存在 + fileInfo, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to access file"}) + return + } + + // 打开文件 + file, err := os.Open(filePath) + if err != nil { + log.Printf("Failed to open file: %s, error: %v", filePath, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"}) + return + } + defer file.Close() + + // 获取文件大小 + fileSize := fileInfo.Size() + + // 检查是否支持 Range 请求 + rangeHeader := c.GetHeader("Range") + + if rangeHeader == "" { + // 不支持 Range 请求,发送整个文件 + c.Header("Content-Type", getContentType(filePath)) + c.Header("Content-Length", strconv.FormatInt(fileSize, 10)) + c.Header("Accept-Ranges", "bytes") + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.File(filePath) + return + } + + // 解析 Range 头部 (例如: "bytes=0-1023") + ranges, err := parseRange(rangeHeader, fileSize) + if err != nil { + c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize)) + c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range"}) + return + } + + // 使用第一个 Range(简化处理,只支持单个 Range) + r := ranges[0] + + // 设置响应头(优化:添加Gzip压缩和CDN友好头) + c.Header("Content-Type", getContentType(filePath)) + c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", r.start, r.end-1, fileSize)) + c.Header("Content-Length", strconv.FormatInt(r.end-r.start, 10)) + c.Header("Accept-Ranges", "bytes") + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename)) + c.Header("X-Content-Type-Options", "nosniff") // 安全头 + + // 定位到指定位置 + _, err = file.Seek(r.start, 0) + if err != nil { + log.Printf("Failed to seek file: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek file"}) + return + } + + // 流式传输文件内容 + c.Status(http.StatusPartialContent) + _, err = io.CopyN(c.Writer, file, r.end-r.start) + if err != nil { + log.Printf("Error streaming file: %v", err) + } +} + +// Range 表示一个字节范围 +type Range struct { + start int64 + end int64 +} + +// parseRange 解析 Range 头部 +func parseRange(rangeHeader string, fileSize int64) ([]Range, error) { + // Range 格式: "bytes=start-end" + if !strings.HasPrefix(rangeHeader, "bytes=") { + return nil, fmt.Errorf("invalid range format") + } + + rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=") + parts := strings.Split(rangeSpec, "-") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid range format") + } + + var start, end int64 + var err error + + // 解析起始位置 + if parts[0] != "" { + start, err = strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid start position") + } + } else { + // 如果没有指定起始位置,则从文件末尾开始计算 + // 例如: "bytes=-500" 表示最后500字节 + if parts[1] != "" { + suffixLength, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid suffix length") + } + start = fileSize - suffixLength + if start < 0 { + start = 0 + } + end = fileSize + return []Range{{start: start, end: end}}, nil + } + start = 0 + } + + // 解析结束位置 + if parts[1] != "" { + end, err = strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid end position") + } + // Range 包含结束位置,所以需要 +1 + end++ + } else { + // 如果没有指定结束位置,则到文件末尾 + end = fileSize + } + + // 验证范围有效性 + if start < 0 || end > fileSize || start >= end { + return nil, fmt.Errorf("invalid range") + } + + return []Range{{start: start, end: end}}, nil +} + +// getContentType 根据文件扩展名获取 Content-Type +func getContentType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".mp4": + return "video/mp4" + case ".webm": + return "video/webm" + case ".ogg": + return "video/ogg" + case ".mov": + return "video/quicktime" + case ".avi": + return "video/x-msvideo" + case ".mkv": + return "video/x-matroska" + case ".m4v": + return "video/mp4" + default: + return "application/octet-stream" + } +} +// serveThumbnail 服务缩略图,使用缓存优化 +func serveThumbnail(c *gin.Context, filePath string, filename string) { + // 首先尝试从缓存获取 + if thumbnailCache != nil { + if cachedData, found := thumbnailCache.Get(filename); found { + c.Data(http.StatusOK, "image/png", cachedData) + return + } + } + + // 缓存未命中,从磁盘读取 + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "thumbnail not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open thumbnail"}) + return + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get file info"}) + return + } + + fileSize := fileInfo.Size() + + // 读取文件内容 + data := make([]byte, fileSize) + _, err = io.ReadFull(file, data) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read thumbnail"}) + return + } + + // 存入缓存 + if thumbnailCache != nil { + thumbnailCache.Set(filename, data) + } + + // 设置响应头 + c.Header("Content-Type", "image/png") + c.Header("Content-Length", strconv.FormatInt(fileSize, 10)) + c.Header("Cache-Control", "public, max-age=86400, immutable") // 缩略图缓存1天 + c.Header("X-Content-Type-Options", "nosniff") + + // 发送数据 + c.Data(http.StatusOK, "image/png", data) +} + +// InitCache 初始化缓存系统 +func InitCache(maxSizeMB int, ttlMinutes int) { + thumbnailCache = NewThumbnailCache(maxSizeMB, ttlMinutes) + log.Printf("缓存系统已初始化,最大大小: %dMB, TTL: %d分钟", maxSizeMB, ttlMinutes) +} diff --git a/src/Config.jsx b/src/Config.jsx index 32dba46..07b3a94 100644 --- a/src/Config.jsx +++ b/src/Config.jsx @@ -4,7 +4,7 @@ import { createContext } from 'react'; const ConfigContext = createContext(); export const config = { - Host: 'http://192.168.124.2:4444', + Host: 'http://192.168.124.8:4444', }; export default ConfigContext; \ No newline at end of file