Files
verify/backend/cmd/main.go
T
admin 6c191d1b0d perf: 优化静态文件下载性能,支持断点续传
- 替换 Gin 默认 Static 为自定义处理器
- 支持 Range 请求头,实现断点续传
- 使用 1MB 大缓冲区,减少系统调用次数
- 添加 Accept-Ranges 和精确 Content-Length 响应头
- 设置正确的 MIME 类型映射

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 23:08:53 +08:00

637 lines
16 KiB
Go

package main
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"
"verification-platform-backend/internal/middleware"
"verification-platform-backend/internal/router"
"verification-platform-backend/internal/scheduler"
"verification-platform-backend/internal/service"
"verification-platform-backend/pkg/logger"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
//go:embed all:embedded/dist
var embeddedFiles embed.FS
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "list-admin":
listAdmins()
case "reset-password":
resetPassword()
case "create-admin":
createAdmin()
case "help", "-h", "--help":
printHelp()
default:
fmt.Printf("未知命令: %s\n", os.Args[1])
printHelp()
os.Exit(1)
}
return
}
startServer()
}
func printHelp() {
fmt.Println("用法:")
fmt.Println(" verify 启动服务器")
fmt.Println(" verify list-admin 列出所有管理员账号")
fmt.Println(" verify reset-password <用户名> [新密码] 重置管理员密码")
fmt.Println(" verify create-admin <用户名> <密码> [邮箱] 创建新管理员")
fmt.Println(" verify help 显示帮助信息")
fmt.Println()
fmt.Println("示例:")
fmt.Println(" verify list-admin")
fmt.Println(" verify reset-password admin")
fmt.Println(" verify reset-password admin newpass")
fmt.Println(" verify create-admin newadmin password123 admin@example.com")
}
func listAdmins() {
config.Init()
database.Init()
var users []struct {
ID uint
Username string
Email *string
Role string
Status string
CreatedAt string
}
if err := database.DB.Table("users").Where("role = ?", "admin").Find(&users).Error; err != nil {
fmt.Printf("错误: 查询失败 - %v\n", err)
os.Exit(1)
}
if len(users) == 0 {
fmt.Println("没有找到管理员账号")
return
}
fmt.Println("管理员账号列表:")
fmt.Println("----------------------------------------")
for _, u := range users {
email := ""
if u.Email != nil {
email = *u.Email
}
fmt.Printf("ID: %d 用户名: %s 邮箱: %s 状态: %s\n", u.ID, u.Username, email, u.Status)
}
fmt.Println("----------------------------------------")
}
func createAdmin() {
config.Init()
database.Init()
if len(os.Args) < 4 {
fmt.Println("用法: verify create-admin <用户名> <密码> [邮箱]")
os.Exit(1)
}
username := os.Args[2]
password := os.Args[3]
email := ""
if len(os.Args) >= 5 {
email = os.Args[4]
}
if len(password) < 6 {
fmt.Println("错误: 密码长度至少6位")
os.Exit(1)
}
var count int64
database.DB.Table("users").Where("username = ?", username).Count(&count)
if count > 0 {
fmt.Printf("错误: 用户名 '%s' 已存在\n", username)
os.Exit(1)
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Printf("错误: 密码加密失败 - %v\n", err)
os.Exit(1)
}
emailPtr := (*string)(nil)
if email != "" {
emailPtr = &email
}
if err := database.DB.Table("users").Create(map[string]interface{}{
"username": username,
"password": string(hashedPassword),
"email": emailPtr,
"role": "admin",
"status": "active",
}).Error; err != nil {
fmt.Printf("错误: 创建失败 - %v\n", err)
os.Exit(1)
}
fmt.Printf("成功: 管理员账号 '%s' 已创建\n", username)
}
func resetPassword() {
config.Init()
database.Init()
if len(os.Args) < 3 {
fmt.Println("用法: verify reset-password <用户名> [新密码]")
os.Exit(1)
}
username := os.Args[2]
newPassword := ""
if len(os.Args) >= 4 {
newPassword = os.Args[3]
}
var user struct {
ID uint
Username string
Role string
}
if err := database.DB.Table("users").Where("username = ? AND role = ?", username, "admin").First(&user).Error; err != nil {
fmt.Printf("错误: 未找到管理员用户 '%s'\n", username)
os.Exit(1)
}
if newPassword == "" {
newPassword = generateRandomPassword(12)
}
if len(newPassword) < 6 {
fmt.Println("错误: 密码长度至少6位")
os.Exit(1)
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
fmt.Printf("错误: 密码加密失败 - %v\n", err)
os.Exit(1)
}
if err := database.DB.Table("users").Where("id = ?", user.ID).Update("password", string(hashedPassword)).Error; err != nil {
fmt.Printf("错误: 更新密码失败 - %v\n", err)
os.Exit(1)
}
fmt.Printf("成功: 用户 '%s' 的密码已重置\n", username)
fmt.Printf("新密码: %s\n", newPassword)
}
func generateRandomPassword(length int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%"
result := make([]byte, length)
for i := range result {
result[i] = chars[i%len(chars)]
}
for i := len(result) - 1; i > 0; i-- {
j := i % len(chars)
result[i], result[j] = result[j], result[i]
}
return string(result)
}
func startServer() {
config.Init()
logger.Init()
database.Init()
os.MkdirAll("uploads", 0755)
if config.GetString("app.env") == "production" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
r.MaxMultipartMemory = 32 << 20
r.Use(middleware.Logger())
r.Use(middleware.Recovery())
r.Use(middleware.Cors())
r.Use(gzipMiddleware())
r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
c.Next()
})
// 优化的静态文件服务,支持 Range 请求和断点续传
r.GET("/uploads/*filepath", handleOptimizedStaticFile)
router.SetupRoutes(r)
setupEmbeddedFrontend(r)
scheduler.StartCleanupScheduler()
port := config.GetString("app.port")
if port == "" {
port = "8080"
}
log.Printf("Server starting on port %s", port)
if err := r.Run(":" + port); err != nil {
log.Fatal("Failed to start server: ", err)
}
}
func setupEmbeddedFrontend(r *gin.Engine) {
distFS, err := fs.Sub(embeddedFiles, "embedded/dist")
if err != nil {
log.Printf("Warning: embedded dist not found, frontend will not be served")
return
}
indexHTML, err := fs.ReadFile(distFS, "index.html")
if err != nil {
log.Printf("Warning: index.html not found in embedded dist")
return
}
r.GET("/assets/*filepath", func(c *gin.Context) {
filepath := c.Param("filepath")
filepath = strings.TrimPrefix(filepath, "/")
decodedPath, err := url.PathUnescape(filepath)
if err != nil {
decodedPath = filepath
}
fullPath := "assets/" + decodedPath
data, err := fs.ReadFile(distFS, fullPath)
if err != nil {
c.Status(404)
return
}
contentType := getContentTypeByExtension(decodedPath)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(200, contentType, data)
})
r.NoRoute(func(c *gin.Context) {
if c.Request.Method != "GET" || strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(404, gin.H{"error": "not found"})
return
}
path := strings.TrimPrefix(c.Request.URL.Path, "/")
decodedPath, err := url.PathUnescape(path)
if err != nil {
decodedPath = path
}
if data, err := fs.ReadFile(distFS, decodedPath); err == nil {
contentType := getContentTypeByExtension(decodedPath)
c.Data(200, contentType, data)
return
}
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
serveIndexHTML(c, string(indexHTML))
})
}
func serveIndexHTML(c *gin.Context, indexHTML string) {
settingService := service.NewSettingService()
settings, err := settingService.GetSettingsByCategory("basic")
if err != nil {
settings = make(map[string]interface{})
}
siteName := getSettingString(settings, "site_name", "微授权")
siteDescription := getSettingString(settings, "site_description", "专业的应用验证平台")
siteLogo := getSettingString(settings, "site_logo", "")
siteFavicon := getSettingString(settings, "site_favicon", "")
html := indexHTML
html = strings.ReplaceAll(html, "{{.SiteName}}", siteName)
html = strings.ReplaceAll(html, "{{.SiteDescription}}", siteDescription)
html = strings.ReplaceAll(html, "{{.SiteKeywords}}", "验证平台,软件授权,卡密验证")
html = strings.ReplaceAll(html, "{{.LogoLight}}", siteLogo)
html = strings.ReplaceAll(html, "{{.LogoDark}}", siteLogo)
html = strings.ReplaceAll(html, "{{.Favicon}}", siteFavicon)
c.Data(200, "text/html; charset=utf-8", []byte(html))
}
var fallbackMimeTypes = map[string]string{
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
".xml": "application/xml",
".txt": "text/plain; charset=utf-8",
}
func getContentTypeByExtension(filename string) string {
ext := strings.ToLower(filename[strings.LastIndex(filename, "."):])
if ct, ok := fallbackMimeTypes[ext]; ok {
return ct
}
return "application/octet-stream"
}
func getSettingString(settings map[string]interface{}, key, defaultValue string) string {
if val, ok := settings[key]; ok {
if str, ok := val.(string); ok && str != "" {
return str
}
}
return defaultValue
}
func isCompressibleContentType(contentType string) bool {
return strings.HasPrefix(contentType, "text/") ||
strings.Contains(contentType, "javascript") ||
strings.Contains(contentType, "json") ||
strings.Contains(contentType, "xml") ||
strings.Contains(contentType, "svg")
}
func gzipMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") {
c.Next()
return
}
c.Header("Vary", "Accept-Encoding")
gw := &gzipResponseWriter{ResponseWriter: c.Writer, writer: gzip.NewWriter(c.Writer)}
c.Writer = gw
c.Next()
if gw.compressed {
gw.writer.Close()
}
}
}
type gzipResponseWriter struct {
gin.ResponseWriter
writer *gzip.Writer
compressed bool
}
func (w *gzipResponseWriter) Write(data []byte) (int, error) {
if !w.compressed {
contentType := w.Header().Get("Content-Type")
if isCompressibleContentType(contentType) {
w.Header().Del("Content-Length")
w.Header().Set("Content-Encoding", "gzip")
w.compressed = true
}
}
if w.compressed {
return w.writer.Write(data)
}
return w.ResponseWriter.Write(data)
}
func (w *gzipResponseWriter) WriteString(s string) (int, error) {
if !w.compressed {
contentType := w.Header().Get("Content-Type")
if isCompressibleContentType(contentType) {
w.Header().Del("Content-Length")
w.Header().Set("Content-Encoding", "gzip")
w.compressed = true
}
}
if w.compressed {
return w.writer.Write([]byte(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",
}