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