Files
web/server/main.go
whm cce3d158d5 fix(upload): 分片改 POST 并放宽 Nginx 反代,避免 PUT 大 body 断连
- 管理端分片请求改为 POST;后端同时保留 PUT
- /api/ 增加 proxy_request_buffering off;CORS Allow-Headers 略扩展

Made-with: Cursor
2026-04-13 15:09:31 +08:00

259 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"yh_web/server/config"
"yh_web/server/handlers"
"yh_web/server/middleware"
"yh_web/server/models"
"yh_web/server/pkg/logger"
"yh_web/server/pkg/schema"
"yh_web/server/pkg/weblive"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
// loadEnv 启动时自动加载 .env在 server 目录或项目根/server 下),不覆盖已存在的环境变量
func loadEnv() {
wd, _ := os.Getwd()
serverDir := wd
if !strings.HasSuffix(filepath.Clean(wd), "server") {
serverDir = filepath.Join(wd, "server")
}
envPath := filepath.Clean(filepath.Join(serverDir, ".env"))
if _, err := os.Stat(envPath); err == nil {
if err := godotenv.Load(envPath); err == nil {
log.Printf("已加载配置: %s", envPath)
}
}
}
func main() {
loadEnv()
// 初始化根目录 logs/server支持从项目根或 server 目录启动)
wd, _ := os.Getwd()
baseDir := filepath.Join(wd, "logs", "server")
if strings.HasSuffix(filepath.Clean(wd), "server") {
baseDir = filepath.Join(wd, "..", "logs", "server")
}
logger.Init(filepath.Clean(baseDir))
// 连接 MongoDBURI 从环境变量 MONGODB_URI 读取,默认 mongodb://localhost:27017SKIP_MONGODB=1 时可跳过
if os.Getenv("SKIP_MONGODB") != "1" {
mongoURI := os.Getenv("MONGODB_URI")
if mongoURI == "" {
mongoURI = "mongodb://localhost:27017"
}
if dbName := os.Getenv("MONGODB_DB"); dbName != "" {
config.DBName = dbName
}
if err := config.ConnectMongoDB(mongoURI); err != nil {
logger.Err("main", "MongoDB 连接失败: %v若仅需健康检查可设置环境变量 SKIP_MONGODB=1 后启动)", err)
log.Fatalf("MongoDB 连接失败: %v若仅需健康检查可设置环境变量 SKIP_MONGODB=1 后启动)", err)
}
defer config.CloseMongoDB()
// 启动时获取线上表结构并生成 sql缺失的集合在线上创建并生成 created_*.sql
projectRoot := wd
if strings.HasSuffix(filepath.Clean(wd), "server") {
projectRoot = filepath.Join(wd, "..")
}
projectRoot = filepath.Clean(projectRoot)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if err := schema.Sync(ctx, projectRoot); err != nil {
logger.Err("main", "启动时同步表结构失败: %v", err)
log.Printf("警告: 启动时同步表结构失败: %v", err)
}
cancel()
} else {
logger.Log("main", "已跳过 MongoDB 连接SKIP_MONGODB=1仅 /api/health 等不依赖数据库的接口可用")
log.Println("已跳过 MongoDB 连接SKIP_MONGODB=1仅 /api/health 等不依赖数据库的接口可用")
}
r := gin.Default()
r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
r.Use(middleware.ErrorLogger())
r.Use(middleware.TrafficMeter())
// CORSALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
r.Use(func(c *gin.Context) {
origin := c.GetHeader("Origin")
if allowedOriginsEnv != "" {
for _, o := range strings.Split(allowedOriginsEnv, ",") {
if strings.TrimSpace(o) == origin {
c.Header("Access-Control-Allow-Origin", origin)
break
}
}
} else {
c.Header("Access-Control-Allow-Origin", "*")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Origin, X-Requested-With")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
})
// 未连接 MongoDB 时,/api/admin 下所有接口返回 503健康检查不受影响
r.Use(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/admin") && config.MongoClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用,数据库未连接"})
c.Abort()
return
}
c.Next()
})
// 健康检查
r.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// 登录、注册(无需鉴权)
r.POST("/api/admin/login", handlers.Login)
r.POST("/api/admin/send-code", handlers.SendCode)
r.POST("/api/admin/register", handlers.Register)
r.POST("/api/admin/reset-password", handlers.ResetPassword)
// 后台 API 路由组(需鉴权)
admin := r.Group("/api/admin")
admin.Use(handlers.AuthRequired())
{
admin.GET("/info", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "admin api"})
})
admin.GET("/my-permissions", handlers.GetMyPermissions)
admin.GET("/db-structure", func(c *gin.Context) {
structure, err := config.GetDBStructure()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, structure)
})
admin.GET("/stats", handlers.GetStats)
admin.POST("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.CreateYuhengCloudRegister)
admin.GET("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.ListYuhengCloudRegisterRecords)
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
admin.PUT("/live/moderation/mute-user", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteUser)
// 用户管理
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
admin.GET("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.GetUserByID)
admin.POST("/users", handlers.RequirePermission(models.PermUserManage), handlers.CreateUser)
admin.PUT("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.UpdateUser)
admin.DELETE("/users/:id", handlers.RequirePermission(models.PermUserManage), handlers.DeleteUser)
// 工作空间
admin.GET("/workspaces", handlers.RequirePermission(models.PermWorkspaceManage), handlers.GetWorkspaces)
// 对话
admin.GET("/conversations", handlers.RequirePermission(models.PermConversationManage), handlers.GetConversations)
// 站点管理(带子路径的路由放前面;与 :site_id 统一,避免 Gin 路由冲突)
admin.GET("/sites/:site_id/homepage/download", handlers.RequirePermission(models.PermHomepageEdit), handlers.DownloadHomepage)
admin.GET("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.GetHomepage)
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage)
admin.GET("/sites/:site_id/assets/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
admin.POST("/sites/:site_id/assets/init-multipart", handlers.RequirePermission(models.PermModuleUpload), handlers.InitMultipartUpload)
admin.GET("/sites/:site_id/assets/multipart/:upload_id/status", handlers.RequirePermission(models.PermModuleUpload), handlers.MultipartUploadStatus)
// 分片用 POST部分反向代理对 PUT + 大 body 会断连,浏览器表现为 Network Error
admin.POST("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
admin.PUT("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
admin.POST("/sites/:site_id/assets/multipart/:upload_id/complete", handlers.RequirePermission(models.PermModuleUpload), handlers.CompleteMultipartUpload)
admin.DELETE("/sites/:site_id/assets/multipart/:upload_id", handlers.RequirePermission(models.PermModuleUpload), handlers.AbortMultipartUpload)
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder)
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
admin.GET("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.GetSiteByID)
admin.POST("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.CreateSite)
admin.PUT("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateSite)
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
admin.GET("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.GetChunkUploadCleanupConfig)
admin.PUT("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateChunkUploadCleanupConfig)
// 角色权限管理
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
admin.POST("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.CreateRole)
admin.PUT("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.UpdateRolePermissions)
admin.DELETE("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.DeleteRole)
// 网页管理(按站点)
admin.GET("/pages", handlers.RequirePermission(models.PermPageManage), handlers.GetPages)
admin.GET("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.GetPageByID)
admin.POST("/pages", handlers.RequirePermission(models.PermPageManage), handlers.CreatePage)
admin.PUT("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.UpdatePage)
admin.DELETE("/pages/:id", handlers.RequirePermission(models.PermPageManage), handlers.DeletePage)
// 短信平台配置
smsConfig := admin.Group("/sms-config")
smsConfig.Use(handlers.RequirePermission(models.PermSMSConfig))
{
smsConfig.GET("", handlers.GetSMSConfig)
smsConfig.PUT("", handlers.UpdateSMSConfig)
}
// 支付配置(微信、支付宝)
paymentConfig := admin.Group("/payment-config")
paymentConfig.Use(handlers.RequirePermission(models.PermPaymentConfig))
{
paymentConfig.GET("", handlers.GetPaymentConfig)
paymentConfig.PUT("", handlers.UpdatePaymentConfig)
}
}
// 官网站点首页(前台,无需鉴权)
r.GET("/api/web/homepage", handlers.GetWebHomepage)
r.GET("/api/web/routes", handlers.GetWebRoutes)
r.GET("/api/web/page", handlers.GetWebPageByPath)
// 前台直播弹幕账号(与后台 users 无关;需 MongoDB
r.POST("/api/web/site/register", handlers.WebSiteRegister)
r.POST("/api/web/site/login", handlers.WebSiteLogin)
// 前台 API 路由组
web := r.Group("/api/web")
{
web.GET("/info", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "web api"})
})
// 推广/产品视频:站点 uploads 下 promotion 目录(无需鉴权,与后台上传到 promotion/… 对应)
web.GET("/sites/:site_id/promotion-media/*filepath", handlers.ServePromotionMedia)
// 可下载资源公开下载(首页等链接指向此路径)
web.GET("/sites/:site_id/assets/:asset_id/download", handlers.DownloadSiteAsset)
// 站内 WebRTC 直播:信令 + 状态(单房间 MVP
weblive.RegisterRoutes(web)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
go handlers.SweepPromotionTranscodeOnStartup()
// 定期删除未合并、超过保留期的分片临时目录(.chunk-uploads
go handlers.StartStaleChunkUploadSweep(context.Background())
r.Run(":" + port)
}