Files
kefu_cloud/server/cmd/main.go
T
yml2213 e675f427a7 feat(auth): JWT 滑动续期 + 401 自动登出,修复长挂机莫名掉线
- 后端:JWT 有效期可配(JWT_EXPIRE_HOURS,默认 24h);令牌寿命过半时经
  X-Renewed-Token 响应头滑动续期;新增 POST /api/auth/refresh 以数据库真实身份签发
- 前端 request 层:静默接管续期头并同步 localStorage;任何 401(登录/续期除外)
  统一广播会话过期事件
- auth store:监听过期事件清理登录态并跳转 /login?expired=1(不再卡 403 页);
  每 10 分钟定时续期,避免 WS 重连时令牌已过期
- 新增滑动续期单元测试(回签过期时间戳),go test -race 通过
2026-09-05 15:57:42 +08:00

99 lines
2.7 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(有效期可用 JWT_EXPIRE_HOURS 配置,默认 24 小时)
middleware.InitJWT(cfg.JWT.Secret, cfg.JWT.ExpireTime)
// 对象存储(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)
}
}