0d08bcc0f8
- 使用 http.ServeFile 替代手动实现,利用 sendfile 零拷贝 - gzip 中间件跳过 /uploads/ 路径,避免额外开销 - 支持 Range 请求和断点续传(由标准库自动处理) - 移除不必要的手动 MIME 类型映射 Co-Authored-By: Claude <noreply@anthropic.com>
480 lines
12 KiB
Go
480 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"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) {
|
|
// 静态文件下载路径跳过 gzip 处理,避免影响下载性能
|
|
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// handleOptimizedStaticFile 优化的静态文件处理,使用 http.ServeFile 实现最高性能
|
|
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
|
|
}
|
|
|
|
// 设置缓存头
|
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
|
|
|
// 使用 http.ServeFile,它已经:
|
|
// 1. 支持 Range 请求(断点续传)
|
|
// 2. 使用 sendfile 系统调用(零拷贝)
|
|
// 3. 自动处理 MIME 类型
|
|
// 4. 高效处理大文件
|
|
http.ServeFile(c.Writer, c.Request, cleanPath)
|
|
}
|