perf: 优化静态文件下载性能,支持断点续传
- 替换 Gin 默认 Static 为自定义处理器 - 支持 Range 请求头,实现断点续传 - 使用 1MB 大缓冲区,减少系统调用次数 - 添加 Accept-Ranges 和精确 Content-Length 响应头 - 设置正确的 MIME 类型映射 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+198
-1
@@ -4,10 +4,14 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"verification-platform-backend/internal/config"
|
"verification-platform-backend/internal/config"
|
||||||
"verification-platform-backend/internal/database"
|
"verification-platform-backend/internal/database"
|
||||||
@@ -238,7 +242,8 @@ func startServer() {
|
|||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Static("/uploads", "uploads")
|
// 优化的静态文件服务,支持 Range 请求和断点续传
|
||||||
|
r.GET("/uploads/*filepath", handleOptimizedStaticFile)
|
||||||
router.SetupRoutes(r)
|
router.SetupRoutes(r)
|
||||||
setupEmbeddedFrontend(r)
|
setupEmbeddedFrontend(r)
|
||||||
|
|
||||||
@@ -437,3 +442,195 @@ func (w *gzipResponseWriter) WriteString(s string) (int, error) {
|
|||||||
}
|
}
|
||||||
return w.ResponseWriter.WriteString(s)
|
return w.ResponseWriter.WriteString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 32KB 缓冲区用于文件传输
|
||||||
|
// 1MB 缓冲区用于高速文件传输,适合 G 口带宽和大文件
|
||||||
|
const fileBufferSize = 1024 * 1024
|
||||||
|
|
||||||
|
// handleOptimizedStaticFile 优化的静态文件处理,支持 Range 请求和断点续传
|
||||||
|
func handleOptimizedStaticFile(c *gin.Context) {
|
||||||
|
// 获取请求的文件路径
|
||||||
|
filepathParam := c.Param("filepath")
|
||||||
|
if filepathParam == "" {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全处理路径,防止目录遍历攻击
|
||||||
|
filepathParam = strings.TrimPrefix(filepathParam, "/")
|
||||||
|
fullPath := filepath.Join("uploads", filepathParam)
|
||||||
|
|
||||||
|
// 清理路径,防止 .. 攻击
|
||||||
|
cleanPath := filepath.Clean(fullPath)
|
||||||
|
if !strings.HasPrefix(cleanPath, "uploads"+string(os.PathSeparator)) && cleanPath != "uploads" {
|
||||||
|
c.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开文件
|
||||||
|
file, err := os.Open(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 获取文件信息
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是目录,返回 404
|
||||||
|
if fileInfo.IsDir() {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件大小
|
||||||
|
fileSize := fileInfo.Size()
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
c.Header("Accept-Ranges", "bytes")
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
|
||||||
|
// 获取 MIME 类型
|
||||||
|
ext := strings.ToLower(filepath.Ext(cleanPath))
|
||||||
|
if mimeType, ok := mimeTypes[ext]; ok {
|
||||||
|
c.Header("Content-Type", mimeType)
|
||||||
|
} else {
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Range 请求(断点续传)
|
||||||
|
rangeHeader := c.GetHeader("Range")
|
||||||
|
if rangeHeader != "" {
|
||||||
|
// 解析 Range 头
|
||||||
|
// 格式: bytes=start-end 或 bytes=start-
|
||||||
|
rangeParts := strings.TrimPrefix(rangeHeader, "bytes=")
|
||||||
|
if rangeParts != rangeHeader {
|
||||||
|
// 解析范围
|
||||||
|
var start, end int64
|
||||||
|
if strings.Contains(rangeParts, "-") {
|
||||||
|
parts := strings.Split(rangeParts, "-")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if parts[0] != "" {
|
||||||
|
start, _ = strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
}
|
||||||
|
if parts[1] != "" {
|
||||||
|
end, _ = strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
} else {
|
||||||
|
end = fileSize - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证范围
|
||||||
|
if start >= fileSize || start < 0 || end < start || end >= fileSize {
|
||||||
|
c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize))
|
||||||
|
c.Status(http.StatusRequestedRangeNotSatisfiable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置部分内容响应
|
||||||
|
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize))
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(end-start+1, 10))
|
||||||
|
c.Status(http.StatusPartialContent)
|
||||||
|
|
||||||
|
// 定位到起始位置
|
||||||
|
file.Seek(start, io.SeekStart)
|
||||||
|
|
||||||
|
// 使用缓冲区传输
|
||||||
|
buf := make([]byte, fileBufferSize)
|
||||||
|
remaining := end - start + 1
|
||||||
|
for remaining > 0 {
|
||||||
|
toRead := int64(fileBufferSize)
|
||||||
|
if remaining < toRead {
|
||||||
|
toRead = remaining
|
||||||
|
}
|
||||||
|
n, err := file.Read(buf[:toRead])
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.Writer.Write(buf[:n])
|
||||||
|
remaining -= int64(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通请求,发送整个文件
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(fileSize, 10))
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
|
||||||
|
// 使用缓冲区高效传输
|
||||||
|
buf := make([]byte, fileBufferSize)
|
||||||
|
_, err = io.CopyBuffer(c.Writer, file, buf)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error sending file %s: %v", cleanPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeTypes 常见 MIME 类型映射
|
||||||
|
var mimeTypes = map[string]string{
|
||||||
|
".zip": "application/zip",
|
||||||
|
".exe": "application/vnd.microsoft.windows.executable",
|
||||||
|
".dll": "application/vnd.microsoft.windows.dll",
|
||||||
|
".apk": "application/vnd.android.package-archive",
|
||||||
|
".ipa": "application/vnd.apple.ipa",
|
||||||
|
".dmg": "application/vnd.apple.dmg",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".doc": "application/msword",
|
||||||
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".ppt": "application/vnd.ms-powerpoint",
|
||||||
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".flac": "audio/flac",
|
||||||
|
".avi": "video/x-msvideo",
|
||||||
|
".mkv": "video/x-matroska",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".wmv": "video/x-ms-wmv",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".htm": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".mjs": "application/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".xml": "application/xml; charset=utf-8",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".md": "text/markdown; charset=utf-8",
|
||||||
|
".log": "text/plain; charset=utf-8",
|
||||||
|
".csv": "text/csv; charset=utf-8",
|
||||||
|
".tar": "application/x-tar",
|
||||||
|
".gz": "application/gzip",
|
||||||
|
".tgz": "application/gzip",
|
||||||
|
".rar": "application/vnd.rar",
|
||||||
|
".7z": "application/x-7z-compressed",
|
||||||
|
".iso": "application/x-iso9660-image",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
".otf": "font/otf",
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user