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"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"verification-platform-backend/internal/config"
|
||||
"verification-platform-backend/internal/database"
|
||||
@@ -238,7 +242,8 @@ func startServer() {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Static("/uploads", "uploads")
|
||||
// 优化的静态文件服务,支持 Range 请求和断点续传
|
||||
r.GET("/uploads/*filepath", handleOptimizedStaticFile)
|
||||
router.SetupRoutes(r)
|
||||
setupEmbeddedFrontend(r)
|
||||
|
||||
@@ -437,3 +442,195 @@ func (w *gzipResponseWriter) WriteString(s string) (int, error) {
|
||||
}
|
||||
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