diff --git a/main.go b/main.go new file mode 100644 index 0000000..5c3c623 --- /dev/null +++ b/main.go @@ -0,0 +1,450 @@ +package main + +import ( + "context" + "embed" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "gotest/internal/config" + "gotest/internal/database" + "gotest/internal/handler" + "gotest/internal/repository" + "gotest/internal/router" + "gotest/internal/service" + "gotest/internal/sms" + "gotest/internal/storage" + "gotest/internal/task" + + "github.com/gin-gonic/gin" +) + +//go:embed all:web/dist +var webDist embed.FS + +func main() { + // 1. 加载配置 + cfg, err := config.Load("config.yaml") + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + log.Printf("当前环境: %s", cfg.Env.Name) + + // 确保运行时目录存在 + os.MkdirAll(cfg.Upload.Path, 0755) + os.MkdirAll("data", 0755) + + // 2. 初始化数据库 + db, err := database.Init(&cfg.Database) + if err != nil { + log.Fatalf("初始化数据库失败: %v", err) + } + + // 3. 初始化存储 + storageInstance := storage.NewStorage(&cfg.Storage) + + // 4. 初始化短信服务(预留,按需在业务中使用) + smsSender := sms.NewSender(&cfg.SMS) + _ = smsSender + + // 5. 初始化 repository + userRepo := repository.NewUserRepository(db.DB) + enterpriseRepo := repository.NewEnterpriseRepository(db.DB) + shopRepo := repository.NewShopRepository(db.DB) + foodRepo := repository.NewFoodRepository(db.DB) + gameRepo := repository.NewGameRepository(db.DB) + appRepo := repository.NewAppRepository(db.DB) + musicRepo := repository.NewMusicRepository(db.DB) + + // 6. 初始化 service + authService := service.NewAuthService(userRepo, cfg) + userService := service.NewUserService(userRepo) + adminService := service.NewAdminService(userRepo) + uploadService := service.NewUploadService(storageInstance, cfg) + enterpriseService := service.NewEnterpriseService(enterpriseRepo) + shopService := service.NewShopService(shopRepo) + foodService := service.NewFoodService(foodRepo) + gameService := service.NewGameService(gameRepo) + appService := service.NewAppService(appRepo) + musicService := service.NewMusicService(musicRepo) + + // 7. 初始化 handler + authHandler := handler.NewAuthHandler(authService) + userHandler := handler.NewUserHandler(userService) + adminHandler := handler.NewAdminHandler(adminService) + uploadHandler := handler.NewUploadHandler(uploadService) + enterpriseHandler := handler.NewEnterpriseHandler(enterpriseService) + shopHandler := handler.NewShopHandler(shopService) + foodHandler := handler.NewFoodHandler(foodService) + gameHandler := handler.NewGameHandler(gameService) + appHandler := handler.NewAppHandler(appService) + musicHandler := handler.NewMusicHandler(musicService) + dashboardHandler := handler.NewDashboardHandler(db.DB) + + // 8. 设置路由 + r := router.Setup(cfg, db, authHandler, userHandler, adminHandler, uploadHandler, enterpriseHandler, shopHandler, foodHandler, gameHandler, appHandler, musicHandler, dashboardHandler) + + // 9. 配置嵌入式前端:18 套网站模板 + setupFrontend(r) + + // 10. 启动定时任务 + scheduler := task.New() + scheduler.RegisterDefaults() + scheduler.Start() + + // 11. 启动 HTTP 服务(优雅关机) + port := cfg.Server.Port + if port == 0 { + port = 8080 + } + addr := ":" + strconv.Itoa(port) + + srv := &http.Server{ + Addr: addr, + Handler: r, + // 生产级超时配置(从 config.yaml 读取,有默认值兜底) + ReadTimeout: time.Duration(maxVal(cfg.Server.ReadTimeout, 15)) * time.Second, + WriteTimeout: time.Duration(maxVal(cfg.Server.WriteTimeout, 30)) * time.Second, + ReadHeaderTimeout: time.Duration(maxVal(cfg.Server.ReadTimeout, 15)) * time.Second, + IdleTimeout: time.Duration(maxVal(cfg.Server.IdleTimeout, 120)) * time.Second, + } + + // 启动 HTTP 服务(非阻塞) + go func() { + log.Printf("服务启动,监听端口 :%d", port) + log.Println("网站入口:") + log.Println(" 企业官网 A (科技): http://localhost:8080/site1") + log.Println(" 企业官网 B (制造): http://localhost:8080/site2") + log.Println(" 企业官网 C (咨询): http://localhost:8080/site3") + log.Println(" 跨境电商 A (红色): http://localhost:8080/shop") + log.Println(" 跨境电商 B (暗黑): http://localhost:8080/shop2") + log.Println(" 跨境电商 C (北欧): http://localhost:8080/shop3") + log.Println(" 外卖平台 A (橙黄): http://localhost:8080/food1") + log.Println(" 外卖平台 B (深色): http://localhost:8080/food2") + log.Println(" 外卖平台 C (清新): http://localhost:8080/food3") + log.Println(" 游戏平台 A (赛博): http://localhost:8080/game1") + log.Println(" 游戏平台 B (像素): http://localhost:8080/game2") + log.Println(" 游戏平台 C (云游): http://localhost:8080/game3") + log.Println(" 应用商店 A (极简): http://localhost:8080/app1") + log.Println(" 应用商店 B (暗黑): http://localhost:8080/app2") + log.Println(" 应用商店 C (清新): http://localhost:8080/app3") + log.Println(" 音乐平台 A (暗黑): http://localhost:8080/music1") + log.Println(" 音乐平台 B (黑胶): http://localhost:8080/music2") + log.Println(" 音乐平台 C (渐变): http://localhost:8080/music3") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("服务启动失败: %v", err) + } + }() + + // 等待中断信号(SIGINT / SIGTERM) + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("收到关机信号,开始优雅关机...") + + // 给正在处理的请求完成时间(从 config.yaml 读取,默认 15 秒) + shutdownTimeout := maxVal(cfg.Server.ShutdownTimeout, 15) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(shutdownTimeout)*time.Second) + defer cancel() + + // 优雅关机顺序:停止接收新请求 → 等待进行中的请求完成 → 停止定时任务 → 关闭数据库 + if err := srv.Shutdown(ctx); err != nil { + log.Printf("HTTP 服务关机超时: %v", err) + } + log.Println("HTTP 服务已停止") + + scheduler.Stop() + log.Println("定时任务已停止") + + if err := db.Close(); err != nil { + log.Printf("关闭数据库失败: %v", err) + } + log.Println("数据库连接已关闭") + + log.Println("优雅关机完成,再见") +} + +// maxVal 返回 a 和 fallback 中的较大值(用于配置默认值兜底) +func maxVal(a, fallback int) int { + if a > 0 { + return a + } + return fallback +} + +// setupFrontend 配置嵌入式前端:4 套网站模板 + 静态资源 + SPA 回退 +func setupFrontend(r *gin.Engine) { + distFS, err := fs.Sub(webDist, "web/dist") + if err != nil { + log.Printf("嵌入式前端不可用: %v", err) + return + } + + // 读取各站点 index.html + adminIndex, _ := fs.ReadFile(distFS, "admin/index.html") + site1Index, _ := fs.ReadFile(distFS, "site1/index.html") + site2Index, _ := fs.ReadFile(distFS, "site2/index.html") + site3Index, _ := fs.ReadFile(distFS, "site3/index.html") + shopIndex, _ := fs.ReadFile(distFS, "shop/index.html") + shop2Index, _ := fs.ReadFile(distFS, "shop2/index.html") + shop3Index, _ := fs.ReadFile(distFS, "shop3/index.html") + food1Index, _ := fs.ReadFile(distFS, "food1/index.html") + food2Index, _ := fs.ReadFile(distFS, "food2/index.html") + food3Index, _ := fs.ReadFile(distFS, "food3/index.html") + game1Index, _ := fs.ReadFile(distFS, "game1/index.html") + game2Index, _ := fs.ReadFile(distFS, "game2/index.html") + game3Index, _ := fs.ReadFile(distFS, "game3/index.html") + app1Index, _ := fs.ReadFile(distFS, "app1/index.html") + app2Index, _ := fs.ReadFile(distFS, "app2/index.html") + app3Index, _ := fs.ReadFile(distFS, "app3/index.html") + music1Index, _ := fs.ReadFile(distFS, "music1/index.html") + music2Index, _ := fs.ReadFile(distFS, "music2/index.html") + music3Index, _ := fs.ReadFile(distFS, "music3/index.html") + rootIndex, _ := fs.ReadFile(distFS, "index.html") + + // 各站点共享的静态资源目录 + sharedFS, _ := fs.Sub(distFS, "shared") + r.StaticFS("/shared", http.FS(sharedFS)) + + // /assets/* 静态资源(Vue 构建产物,如果存在) + if assetsFS, err := fs.Sub(distFS, "assets"); err == nil { + r.StaticFS("/assets", http.FS(assetsFS)) + } + + // favicon + if favicon, err := fs.ReadFile(distFS, "favicon.ico"); err == nil { + r.GET("/favicon.ico", func(c *gin.Context) { + c.Data(http.StatusOK, "image/x-icon", favicon) + }) + } + + // 站点入口路由 + r.GET("/", func(c *gin.Context) { + if len(rootIndex) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", rootIndex) + } else { + // 默认跳转到 site1 + c.Redirect(http.StatusFound, "/site1") + } + }) + + // 管理后台入口 + if len(adminIndex) > 0 { + r.GET("/admin", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", adminIndex) + }) + } + + // 每个站点的 index.html + if len(site1Index) > 0 { + r.GET("/site1", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", site1Index) + }) + } + if len(site2Index) > 0 { + r.GET("/site2", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", site2Index) + }) + } + if len(site3Index) > 0 { + r.GET("/site3", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", site3Index) + }) + } + if len(shopIndex) > 0 { + r.GET("/shop", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", shopIndex) + }) + } + if len(shop2Index) > 0 { + r.GET("/shop2", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", shop2Index) + }) + } + if len(shop3Index) > 0 { + r.GET("/shop3", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", shop3Index) + }) + } + if len(food1Index) > 0 { + r.GET("/food1", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", food1Index) + }) + } + if len(food2Index) > 0 { + r.GET("/food2", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", food2Index) + }) + } + if len(food3Index) > 0 { + r.GET("/food3", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", food3Index) + }) + } + if len(game1Index) > 0 { + r.GET("/game1", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", game1Index) + }) + } + if len(game2Index) > 0 { + r.GET("/game2", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", game2Index) + }) + } + if len(game3Index) > 0 { + r.GET("/game3", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", game3Index) + }) + } + if len(app1Index) > 0 { + r.GET("/app1", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", app1Index) + }) + } + if len(app2Index) > 0 { + r.GET("/app2", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", app2Index) + }) + } + if len(app3Index) > 0 { + r.GET("/app3", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", app3Index) + }) + } + if len(music1Index) > 0 { + r.GET("/music1", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", music1Index) + }) + } + if len(music2Index) > 0 { + r.GET("/music2", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", music2Index) + }) + } + if len(music3Index) > 0 { + r.GET("/music3", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", music3Index) + }) + } + + // SPA 回退:非 API 路由返回对应站点页面 + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + // API 路由返回 404 JSON + if strings.HasPrefix(path, "/api/") { + c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "接口不存在", "data": nil}) + return + } + // 根据路径前缀返回对应站点页面 + switch { + case strings.HasPrefix(path, "/admin"): + if len(adminIndex) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", adminIndex) + return + } + case strings.HasPrefix(path, "/site1"): + if len(site1Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", site1Index) + return + } + case strings.HasPrefix(path, "/site2"): + if len(site2Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", site2Index) + return + } + case strings.HasPrefix(path, "/site3"): + if len(site3Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", site3Index) + return + } + case strings.HasPrefix(path, "/shop2"): + if len(shop2Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", shop2Index) + return + } + case strings.HasPrefix(path, "/shop3"): + if len(shop3Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", shop3Index) + return + } + case strings.HasPrefix(path, "/shop"): + if len(shopIndex) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", shopIndex) + return + } + case strings.HasPrefix(path, "/food1"): + if len(food1Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", food1Index) + return + } + case strings.HasPrefix(path, "/food2"): + if len(food2Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", food2Index) + return + } + case strings.HasPrefix(path, "/food3"): + if len(food3Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", food3Index) + return + } + case strings.HasPrefix(path, "/game1"): + if len(game1Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", game1Index) + return + } + case strings.HasPrefix(path, "/game2"): + if len(game2Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", game2Index) + return + } + case strings.HasPrefix(path, "/game3"): + if len(game3Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", game3Index) + return + } + case strings.HasPrefix(path, "/app1"): + if len(app1Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", app1Index) + return + } + case strings.HasPrefix(path, "/app2"): + if len(app2Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", app2Index) + return + } + case strings.HasPrefix(path, "/app3"): + if len(app3Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", app3Index) + return + } + case strings.HasPrefix(path, "/music1"): + if len(music1Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", music1Index) + return + } + case strings.HasPrefix(path, "/music2"): + if len(music2Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", music2Index) + return + } + case strings.HasPrefix(path, "/music3"): + if len(music3Index) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", music3Index) + return + } + } + // 默认回退 + if len(rootIndex) > 0 { + c.Data(http.StatusOK, "text/html; charset=utf-8", rootIndex) + } else { + c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "页面不存在", "data": nil}) + } + }) +}