Files
web_template/main.go
Geliebte 3580066a13 feat: 导航页文案可后台配置 + webhook修复 + 企业微信通知可用
- 导航页所有文案(标题/副标题/标签/咨询区/页脚/工作室名称)均可在后台「导航页设置」页配置
- 新增公开API GET /api/v1/nav-settings,导航页加载时自动拉取配置覆盖默认值
- CustomerHandler改为从数据库读取webhook URL,不再依赖config.yaml
- 新增默认企业微信webhook地址,开箱即用
- 后台新增webhook测试专用接口 POST /api/v1/admin/webhook/test
- 后台新增批量配置保存接口 PUT /api/v1/admin/config-batch
- 后台新增「导航页设置」管理页面,支持分组编辑保存
- 页脚添加工作室名称展示位
2026-09-09 02:49:39 +08:00

471 lines
15 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"
"embed"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"gotest/internal/config"
"gotest/internal/database"
"gotest/internal/geoip"
"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)
uploadService.EnsureUploadDir() // 创建各模块子目录
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)
customerHandler := handler.NewCustomerHandler(db.DB)
accessLogHandler := handler.NewAccessLogHandler(db.DB)
configHandler := handler.NewConfigHandler(db.DB)
// IP 归属地查询服务
ipLocationService := geoip.NewService()
// 8. 设置路由
r := router.Setup(cfg, db, authHandler, userHandler, adminHandler, uploadHandler, enterpriseHandler, shopHandler, foodHandler, gameHandler, appHandler, musicHandler, dashboardHandler, customerHandler, accessLogHandler, configHandler, ipLocationService)
// 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(" 跨境电商 D (古树): http://localhost:8080/shop4")
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")
shop4Index, _ := fs.ReadFile(distFS, "shop4/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(shop4Index) > 0 {
r.GET("/shop4", func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", shop4Index)
})
}
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, "/shop4"):
if len(shop4Index) > 0 {
c.Data(http.StatusOK, "text/html; charset=utf-8", shop4Index)
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})
}
})
}