feat(server): 添加缓存系统和视频流处理功能

- 初始化512MB内存缓存系统,TTL设置为60分钟
- 添加支持Range请求的视频流处理器,优化大文件加载性能
- 使用gin.Recovery()增强服务稳定性
- 将静态文件服务替换为专门的视频流处理器

fix(config): 更新服务器主机地址

- 将Host地址从192.168.124.2更新为192.168.124.8
```
This commit is contained in:
eson 2025-12-29 06:22:19 +08:00
parent 194ba376d6
commit afc68bf861
4 changed files with 422 additions and 2 deletions

158
server/cache.go Normal file
View File

@ -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,
}
}

View File

@ -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")

255
server/video_handler.go Normal file
View File

@ -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)
}

View File

@ -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;