- 新增 ws.EventBus 接口与 RedisBus 实现;Hub 五类广播改为发布信封、各节点订阅后本地投递 - 单实例不设 REDIS_ADDR 保持进程内直投;Redis 不可用时自动兜底本地投递 - config 新增 REDIS_ADDR/REDIS_PASSWORD/REDIS_DB;dev compose 增加 --profile redis (6380) - prod compose 与 .env.prod.example 增加多副本配置说明;README 更新已知边界 - 已用双实例 + 真实 Redis 实测跨节点推送;新增总线单测(含发布失败兜底),-race 通过
99 lines
2.6 KiB
Go
99 lines
2.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"kefu-cloud/server/internal/config"
|
|
"kefu-cloud/server/internal/handler"
|
|
"kefu-cloud/server/internal/metrics"
|
|
"kefu-cloud/server/internal/middleware"
|
|
"kefu-cloud/server/internal/model"
|
|
"kefu-cloud/server/internal/storage"
|
|
"kefu-cloud/server/internal/ws"
|
|
)
|
|
|
|
func main() {
|
|
cfg := config.Load()
|
|
|
|
// 初始化数据库
|
|
model.InitDB(cfg.Database.DSN())
|
|
|
|
// 初始化 JWT
|
|
middleware.InitJWT(cfg.JWT.Secret)
|
|
|
|
// 对象存储(MinIO / S3 兼容)
|
|
store, err := storage.NewS3(cfg.Storage)
|
|
if err != nil {
|
|
log.Fatalf("对象存储初始化失败: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := store.EnsureBucket(ctx); err != nil {
|
|
log.Printf("警告: 确保存储桶失败(可稍后启动 MinIO): %v", err)
|
|
} else {
|
|
log.Printf("对象存储就绪: %s bucket=%s public=%s", cfg.Storage.Endpoint, cfg.Storage.Bucket, cfg.Storage.PublicBase)
|
|
}
|
|
|
|
// 启动 WebSocket Hub
|
|
ws.SetAllowedOrigins(cfg.AllowedOrigins)
|
|
// 多 API 副本部署时通过 REDIS_ADDR 启用跨节点广播;单实例保持进程内投递
|
|
if cfg.Redis.Addr != "" {
|
|
bus := ws.NewRedisBus(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB)
|
|
ws.DefaultHub.SetEventBus(bus)
|
|
log.Printf("WebSocket 跨节点广播已启用: redis=%s db=%d", cfg.Redis.Addr, cfg.Redis.DB)
|
|
defer func() { _ = bus.Close() }()
|
|
}
|
|
go ws.DefaultHub.Run()
|
|
|
|
// 设置 Gin 模式
|
|
gin.SetMode(cfg.Server.Mode)
|
|
|
|
r := gin.Default()
|
|
// 全站请求统计(运维监控 24h 曲线)
|
|
r.Use(metrics.Middleware())
|
|
|
|
// CORS
|
|
r.Use(func(c *gin.Context) {
|
|
origin := c.GetHeader("Origin")
|
|
allowed := false
|
|
for _, candidate := range cfg.AllowedOrigins {
|
|
if candidate == "*" || candidate == origin {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if allowed && origin != "" {
|
|
c.Header("Access-Control-Allow-Origin", origin)
|
|
c.Header("Vary", "Origin")
|
|
}
|
|
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
|
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Visitor-Token")
|
|
if c.Request.Method == "OPTIONS" {
|
|
if origin != "" && !allowed {
|
|
c.AbortWithStatus(http.StatusForbidden)
|
|
return
|
|
}
|
|
c.AbortWithStatus(204)
|
|
return
|
|
}
|
|
c.Next()
|
|
})
|
|
|
|
// 健康检查
|
|
r.GET("/health", func(c *gin.Context) {
|
|
c.JSON(200, gin.H{"status": "ok"})
|
|
})
|
|
|
|
// 注册路由
|
|
handler.SetupRoutes(r, store, cfg.Storage)
|
|
|
|
log.Printf("客服云服务启动在 :%s", cfg.Server.Port)
|
|
if err := r.Run(":" + cfg.Server.Port); err != nil {
|
|
log.Fatalf("启动失败: %v", err)
|
|
}
|
|
}
|