perf: 优化静态文件下载性能,支持断点续传

- 替换 Gin 默认 Static 为自定义处理器
- 支持 Range 请求头,实现断点续传
- 使用 1MB 大缓冲区,减少系统调用次数
- 添加 Accept-Ranges 和精确 Content-Length 响应头
- 设置正确的 MIME 类型映射

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:08:53 +08:00
parent 21b337f7c3
commit 6c191d1b0d
+198 -1
View File
@@ -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",
}