package main import ( "context" "encoding/json" "fmt" "log" "net/http" "os" "os/signal" "os/user" "path/filepath" "strconv" "strings" "sync/atomic" "syscall" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "monica-go-completion-backend/internal/api" "monica-go-completion-backend/internal/cluster" "monica-go-completion-backend/internal/completion" "monica-go-completion-backend/internal/logging" "monica-go-completion-backend/internal/lsp" "monica-go-completion-backend/internal/monitor" ) var requestIDSeed atomic.Int64 // config 汇总服务启动所需的环境配置。 type config struct { Port string // HTTP 监听端口,如 8080。 // WorkspaceDir 是 LSP 进程使用的工作区根目录。 WorkspaceDir string // AllowOrigin 控制 CORS 的 Access-Control-Allow-Origin。 AllowOrigin string // APIToken 为可选 API Key;为空时不启用鉴权。 APIToken string // RequestTimeout 为单次补全请求的超时时间。 RequestTimeout time.Duration // MaxBodyBytes 为 HTTP/WS 消息体最大字节数。 MaxBodyBytes int64 // SessionTTL 为会话空闲超时,超过后会被清理。 SessionTTL time.Duration // CleanupInterval 为后台扫描并清理闲置会话的周期。 CleanupInterval time.Duration // MaxSessions 限制本实例最多持有的活跃会话数。 MaxSessions int // InstanceID 是当前实例唯一标识,用于分布式会话归属判断。 InstanceID string // InstanceURL 是当前实例可被路由到的对外地址。 InstanceURL string // EnableRedis 控制是否启用 Redis 粘性路由/会话归属。 EnableRedis bool // RedisAddr 为 Redis 地址,例如 127.0.0.1:6379。 RedisAddr string // RedisPassword 为 Redis 密码,可为空。 RedisPassword string // RedisDB 为 Redis 数据库编号。 RedisDB int // RedisKeyPrefix 为 Redis 键前缀,避免与其他业务冲突。 RedisKeyPrefix string // InstanceTTL 为实例元数据在 Redis 中的过期时间。 InstanceTTL time.Duration // Heartbeat 为实例心跳刷新周期。 Heartbeat time.Duration // EnableNacosRegister 控制是否在启动时向 Nacos 注册实例。 EnableNacosRegister bool // NacosServerAddr 为 Nacos 地址(host:port)。 NacosServerAddr string // NacosNamespace 为 Nacos namespace,可为空。 NacosNamespace string // NacosGroup 为 Nacos group,默认 DEFAULT_GROUP。 NacosGroup string // NacosServiceName 为当前服务在 Nacos 中的服务名。 NacosServiceName string // NacosClusterName 为 Nacos clusterName,可为空。 NacosClusterName string // NacosUsername 为 Nacos 认证用户名,可为空。 NacosUsername string // NacosPassword 为 Nacos 认证密码,可为空。 NacosPassword string // NacosRegisterIP 为实例向 Nacos 注册时使用的 IP。 NacosRegisterIP string // NacosRegisterPort 为实例向 Nacos 注册时使用的端口。 NacosRegisterPort int // NacosEphemeral 控制是否使用临时实例模式。 NacosEphemeral bool // AppName 为日志目录与日志字段中的应用名。 AppName string // AppEnv 为运行环境标识,如 dev/prod。 AppEnv string // LogPath 为日志根目录。 LogPath string // LogLevel 控制全局日志等级。 LogLevel string // LogMaxSizeMB 控制单个日志文件滚动阈值(MB)。 LogMaxSizeMB int // LogMaxBackups 控制保留滚动文件数量。 LogMaxBackups int // LogMaxAgeDays 控制日志保留天数。 LogMaxAgeDays int // LogCompress 控制滚动日志是否压缩。 LogCompress bool // LogConsoleEnabled 控制是否输出到控制台。 LogConsoleEnabled bool // TrustedProxies 为受信代理列表(IP/CIDR),用于安全解析 X-Forwarded-For。 TrustedProxies []string // RemoteIPHeaders 为提取客户端真实 IP 的请求头白名单。 RemoteIPHeaders []string // ForwardedByClientIP 控制是否按代理头解析客户端 IP。 ForwardedByClientIP bool // Servers 定义各语言 LSP 服务的启动命令与参数。 Servers []completion.LanguageServerSpec } // fileConfig 对应 JSON 配置文件格式,使用指针区分“未设置”和“显式设置”。 type fileConfig struct { Port *string `json:"port"` WorkspaceDir *string `json:"workspaceDir"` AllowOrigin *string `json:"allowOrigin"` APIToken *string `json:"apiToken"` RequestTimeout *string `json:"requestTimeout"` MaxBodyBytes *int64 `json:"maxBodyBytes"` SessionTTL *string `json:"sessionTTL"` CleanupInterval *string `json:"cleanupInterval"` MaxSessions *int `json:"maxSessions"` InstanceID *string `json:"instanceID"` InstanceURL *string `json:"instanceURL"` EnableRedis *bool `json:"enableRedis"` RedisAddr *string `json:"redisAddr"` RedisPassword *string `json:"redisPassword"` RedisDB *int `json:"redisDB"` RedisKeyPrefix *string `json:"redisKeyPrefix"` InstanceTTL *string `json:"instanceTTL"` Heartbeat *string `json:"heartbeat"` EnableNacosRegister *bool `json:"enableNacosRegister"` NacosServerAddr *string `json:"nacosServerAddr"` NacosNamespace *string `json:"nacosNamespace"` NacosGroup *string `json:"nacosGroup"` NacosServiceName *string `json:"nacosServiceName"` NacosClusterName *string `json:"nacosClusterName"` NacosUsername *string `json:"nacosUsername"` NacosPassword *string `json:"nacosPassword"` NacosRegisterIP *string `json:"nacosRegisterIP"` NacosRegisterPort *int `json:"nacosRegisterPort"` NacosEphemeral *bool `json:"nacosEphemeral"` AppName *string `json:"appName"` AppEnv *string `json:"appEnv"` LogPath *string `json:"logPath"` LogLevel *string `json:"logLevel"` LogMaxSizeMB *int `json:"logMaxSizeMB"` LogMaxBackups *int `json:"logMaxBackups"` LogMaxAgeDays *int `json:"logMaxAgeDays"` LogCompress *bool `json:"logCompress"` LogConsoleEnabled *bool `json:"logConsoleEnabled"` TrustedProxies []string `json:"trustedProxies"` RemoteIPHeaders []string `json:"remoteIPHeaders"` ForwardedByClientIP *bool `json:"forwardedByClientIP"` Servers []completion.LanguageServerSpec `json:"servers"` } func main() { // 读取环境变量并组装运行配置。 cfg, err := loadConfig() if err != nil { log.Fatalf("load config failed: %v", err) } appLogger, logCleanup, err := logging.New(logging.Config{ AppName: cfg.AppName, Environment: cfg.AppEnv, BasePath: cfg.LogPath, Level: cfg.LogLevel, MaxSizeMB: cfg.LogMaxSizeMB, MaxBackups: cfg.LogMaxBackups, MaxAgeDays: cfg.LogMaxAgeDays, Compress: cfg.LogCompress, ConsoleEnabled: cfg.LogConsoleEnabled, }) if err != nil { log.Fatalf("init logger failed: %v", err) } defer func() { _ = logCleanup() }() restoreGlobals := zap.ReplaceGlobals(appLogger) defer restoreGlobals() restoreStdLog := zap.RedirectStdLog(appLogger) defer restoreStdLog() var registry completion.SessionRegistry if cfg.EnableRedis { // Redis 注册中心用于多实例下的会话归属协调(粘性路由)。 var err error registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{ Addr: cfg.RedisAddr, Password: cfg.RedisPassword, DB: cfg.RedisDB, KeyPrefix: cfg.RedisKeyPrefix, InstanceID: cfg.InstanceID, InstanceEndpoint: cfg.InstanceURL, SessionTTL: cfg.SessionTTL, InstanceTTL: cfg.InstanceTTL, HeartbeatInterval: cfg.Heartbeat, }) if err != nil { log.Fatalf("create redis registry failed: %v", err) } } var nacosRegistry *cluster.NacosRegistry if cfg.EnableNacosRegister { // Nacos 负责服务发现;Redis 仍负责会话归属与粘性路由。 var err error nacosRegistry, err = cluster.NewNacosRegistry(cluster.NacosRegistryConfig{ ServerAddr: cfg.NacosServerAddr, Namespace: cfg.NacosNamespace, Group: cfg.NacosGroup, ServiceName: cfg.NacosServiceName, ClusterName: cfg.NacosClusterName, Username: cfg.NacosUsername, Password: cfg.NacosPassword, IP: cfg.NacosRegisterIP, Port: uint64(cfg.NacosRegisterPort), Ephemeral: cfg.NacosEphemeral, Metadata: map[string]string{ "instanceId": cfg.InstanceID, "instanceUrl": cfg.InstanceURL, "component": "lsp-gateway", }, }) if err != nil { log.Fatalf("register nacos instance failed: %v", err) } } clientFactory := func(ctx context.Context, spec completion.LanguageServerSpec, workspaceDir string) (completion.RuntimeClient, error) { return lsp.NewClient(ctx, lsp.Config{ Command: spec.Command, Args: spec.Args, RootPath: workspaceDir, LanguageID: spec.LanguageID, ClientName: "monica-lsp-gateway", }) } manager := completion.NewManager(completion.ManagerConfig{ WorkspaceDir: cfg.WorkspaceDir, MaxSessions: cfg.MaxSessions, SessionTTL: cfg.SessionTTL, CleanupInterval: cfg.CleanupInterval, InstanceID: cfg.InstanceID, Registry: registry, }, cfg.Servers, clientFactory) defer func() { _ = manager.Close() }() // 后台预热各语言 LSP 会话,首次请求无需等待冷启动。 go manager.WarmUp(context.Background()) // 后台探测各语言 LSP 可用性(demo 实现:周期性握手探测)。 lspStatusMonitor := monitor.NewLSPStatusMonitor(cfg.Servers, cfg.WorkspaceDir, clientFactory, monitor.Config{ ProbeInterval: 60 * time.Second, ProbeTimeout: 30 * time.Second, FailureThreshold: 2, }) defer lspStatusMonitor.Close() // 注册通用中间件与业务路由。 router := gin.New() if err := configureProxySettings(router, cfg); err != nil { log.Fatalf("configure trusted proxies failed: %v", err) } router.Use(requestIDMiddleware()) router.Use(accessLogMiddleware()) router.Use(recoveryMiddleware()) router.Use(corsMiddleware(cfg.AllowOrigin)) router.Use(apiTokenMiddleware(cfg.APIToken)) api.RegisterRoutes(router, manager, api.RouteOptions{ RequestTimeout: cfg.RequestTimeout, MaxBodyBytes: cfg.MaxBodyBytes, LSPStatusProvider: lspStatusMonitor, }) server := &http.Server{ Addr: ":" + cfg.Port, Handler: router, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } go func() { // HTTP 服务主循环,非正常退出直接终止进程。 log.Printf( "lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t, nacos=%t", cfg.Port, cfg.WorkspaceDir, cfg.InstanceID, cfg.EnableRedis, cfg.EnableNacosRegister, ) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("http server failed: %v", err) } }() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) <-sigCh // 收到退出信号后,按超时窗口优雅关闭。 shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("http shutdown failed: %v", err) } if err := manager.Close(); err != nil { log.Printf("lsp manager close failed: %v", err) } if nacosRegistry != nil { if err := nacosRegistry.Close(); err != nil { log.Printf("nacos deregister failed: %v", err) } } } // loadConfig 从环境变量读取配置并应用默认值。 func loadConfig() (config, error) { cwd, err := os.Getwd() if err != nil { cwd = "." } cfg := config{ Port: "8080", WorkspaceDir: cwd, AllowOrigin: "*", RequestTimeout: 10 * time.Second, MaxBodyBytes: 2 << 20, SessionTTL: 20 * time.Minute, CleanupInterval: 2 * time.Minute, MaxSessions: 256, EnableRedis: true, RedisAddr: "10.0.0.10:6379", RedisDB: 1, RedisKeyPrefix: "lsp-gateway", InstanceTTL: 30 * time.Second, Heartbeat: 10 * time.Second, NacosServerAddr: "10.0.0.10:8848", NacosGroup: "DEFAULT_GROUP", NacosServiceName: "lsp-gateway", NacosEphemeral: true, AppName: "lsp-gateway", AppEnv: "dev", LogLevel: "info", LogMaxSizeMB: 100, LogMaxBackups: 31, LogMaxAgeDays: 31, LogCompress: true, LogConsoleEnabled: true, RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"}, ForwardedByClientIP: true, Servers: defaultLanguageServers(), } if configPath, ok := resolveConfigFilePath(); ok { fileCfg, err := readConfigFile(configPath) if err != nil { return config{}, fmt.Errorf("read config file %q: %w", configPath, err) } if err := applyFileConfig(&cfg, fileCfg); err != nil { return config{}, fmt.Errorf("apply config file %q: %w", configPath, err) } log.Printf("config file loaded: %s", configPath) } else { log.Printf("config file not found, using defaults + environment variables") } applyEnvOverrides(&cfg) finalizeConfig(&cfg, cwd) log.Printf( "effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s, logPath=%s, logLevel=%s, trustedProxies=%d", cfg.Port, cfg.WorkspaceDir, cfg.EnableRedis, cfg.EnableNacosRegister, cfg.NacosServerAddr, cfg.LogPath, cfg.LogLevel, len(cfg.TrustedProxies), ) return cfg, nil } func resolveConfigFilePath() (string, bool) { explicitPath := strings.TrimSpace(os.Getenv("CONFIG_FILE")) if explicitPath != "" { return explicitPath, true } const defaultPath = "config.json" info, err := os.Stat(defaultPath) if err == nil && !info.IsDir() { return defaultPath, true } return "", false } func readConfigFile(path string) (fileConfig, error) { body, err := os.ReadFile(path) if err != nil { return fileConfig{}, err } var cfg fileConfig if err := json.Unmarshal(body, &cfg); err != nil { return fileConfig{}, err } return cfg, nil } func applyFileConfig(cfg *config, fc fileConfig) error { if fc.Port != nil { cfg.Port = strings.TrimSpace(*fc.Port) } if fc.WorkspaceDir != nil { cfg.WorkspaceDir = strings.TrimSpace(*fc.WorkspaceDir) } if fc.AllowOrigin != nil { cfg.AllowOrigin = strings.TrimSpace(*fc.AllowOrigin) } if fc.APIToken != nil { cfg.APIToken = strings.TrimSpace(*fc.APIToken) } if fc.RequestTimeout != nil { d, err := time.ParseDuration(strings.TrimSpace(*fc.RequestTimeout)) if err != nil { return fmt.Errorf("requestTimeout: %w", err) } cfg.RequestTimeout = d } if fc.MaxBodyBytes != nil { cfg.MaxBodyBytes = *fc.MaxBodyBytes } if fc.SessionTTL != nil { d, err := time.ParseDuration(strings.TrimSpace(*fc.SessionTTL)) if err != nil { return fmt.Errorf("sessionTTL: %w", err) } cfg.SessionTTL = d } if fc.CleanupInterval != nil { d, err := time.ParseDuration(strings.TrimSpace(*fc.CleanupInterval)) if err != nil { return fmt.Errorf("cleanupInterval: %w", err) } cfg.CleanupInterval = d } if fc.MaxSessions != nil { cfg.MaxSessions = *fc.MaxSessions } if fc.InstanceID != nil { cfg.InstanceID = strings.TrimSpace(*fc.InstanceID) } if fc.InstanceURL != nil { cfg.InstanceURL = strings.TrimSpace(*fc.InstanceURL) } if fc.EnableRedis != nil { cfg.EnableRedis = *fc.EnableRedis } if fc.RedisAddr != nil { cfg.RedisAddr = strings.TrimSpace(*fc.RedisAddr) } if fc.RedisPassword != nil { cfg.RedisPassword = strings.TrimSpace(*fc.RedisPassword) } if fc.RedisDB != nil { cfg.RedisDB = *fc.RedisDB } if fc.RedisKeyPrefix != nil { cfg.RedisKeyPrefix = strings.TrimSpace(*fc.RedisKeyPrefix) } if fc.InstanceTTL != nil { d, err := time.ParseDuration(strings.TrimSpace(*fc.InstanceTTL)) if err != nil { return fmt.Errorf("instanceTTL: %w", err) } cfg.InstanceTTL = d } if fc.Heartbeat != nil { d, err := time.ParseDuration(strings.TrimSpace(*fc.Heartbeat)) if err != nil { return fmt.Errorf("heartbeat: %w", err) } cfg.Heartbeat = d } if fc.EnableNacosRegister != nil { cfg.EnableNacosRegister = *fc.EnableNacosRegister } if fc.NacosServerAddr != nil { cfg.NacosServerAddr = strings.TrimSpace(*fc.NacosServerAddr) } if fc.NacosNamespace != nil { cfg.NacosNamespace = strings.TrimSpace(*fc.NacosNamespace) } if fc.NacosGroup != nil { cfg.NacosGroup = strings.TrimSpace(*fc.NacosGroup) } if fc.NacosServiceName != nil { cfg.NacosServiceName = strings.TrimSpace(*fc.NacosServiceName) } if fc.NacosClusterName != nil { cfg.NacosClusterName = strings.TrimSpace(*fc.NacosClusterName) } if fc.NacosUsername != nil { cfg.NacosUsername = strings.TrimSpace(*fc.NacosUsername) } if fc.NacosPassword != nil { cfg.NacosPassword = strings.TrimSpace(*fc.NacosPassword) } if fc.NacosRegisterIP != nil { cfg.NacosRegisterIP = strings.TrimSpace(*fc.NacosRegisterIP) } if fc.NacosRegisterPort != nil { cfg.NacosRegisterPort = *fc.NacosRegisterPort } if fc.NacosEphemeral != nil { cfg.NacosEphemeral = *fc.NacosEphemeral } if fc.AppName != nil { cfg.AppName = strings.TrimSpace(*fc.AppName) } if fc.AppEnv != nil { cfg.AppEnv = strings.TrimSpace(*fc.AppEnv) } if fc.LogPath != nil { cfg.LogPath = strings.TrimSpace(*fc.LogPath) } if fc.LogLevel != nil { cfg.LogLevel = strings.TrimSpace(*fc.LogLevel) } if fc.LogMaxSizeMB != nil { cfg.LogMaxSizeMB = *fc.LogMaxSizeMB } if fc.LogMaxBackups != nil { cfg.LogMaxBackups = *fc.LogMaxBackups } if fc.LogMaxAgeDays != nil { cfg.LogMaxAgeDays = *fc.LogMaxAgeDays } if fc.LogCompress != nil { cfg.LogCompress = *fc.LogCompress } if fc.LogConsoleEnabled != nil { cfg.LogConsoleEnabled = *fc.LogConsoleEnabled } if fc.TrustedProxies != nil { cfg.TrustedProxies = filterEmptyStrings(fc.TrustedProxies) } if fc.RemoteIPHeaders != nil { cfg.RemoteIPHeaders = filterEmptyStrings(fc.RemoteIPHeaders) } if fc.ForwardedByClientIP != nil { cfg.ForwardedByClientIP = *fc.ForwardedByClientIP } if fc.Servers != nil { cfg.Servers = normalizeServerSpecs(fc.Servers) } return nil } func applyEnvOverrides(cfg *config) { cfg.Port = getenv("PORT", cfg.Port) cfg.WorkspaceDir = getenv("WORKSPACE_DIR", cfg.WorkspaceDir) cfg.AllowOrigin = getenv("CORS_ALLOW_ORIGIN", cfg.AllowOrigin) cfg.APIToken = getenv("LSP_API_TOKEN", cfg.APIToken) cfg.RequestTimeout = getenvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout) cfg.MaxBodyBytes = getenvInt64("MAX_BODY_BYTES", cfg.MaxBodyBytes) cfg.SessionTTL = getenvDuration("SESSION_TTL", cfg.SessionTTL) cfg.CleanupInterval = getenvDuration("SESSION_CLEANUP_INTERVAL", cfg.CleanupInterval) cfg.MaxSessions = getenvInt("MAX_SESSIONS", cfg.MaxSessions) cfg.InstanceID = getenv("INSTANCE_ID", cfg.InstanceID) cfg.InstanceURL = getenv("INSTANCE_URL", cfg.InstanceURL) cfg.EnableRedis = getenvBool("ENABLE_REDIS_STICKY", cfg.EnableRedis) cfg.RedisAddr = getenv("REDIS_ADDR", cfg.RedisAddr) cfg.RedisPassword = getenv("REDIS_PASSWORD", cfg.RedisPassword) cfg.RedisDB = getenvInt("REDIS_DB", cfg.RedisDB) cfg.RedisKeyPrefix = getenv("REDIS_KEY_PREFIX", cfg.RedisKeyPrefix) cfg.InstanceTTL = getenvDuration("INSTANCE_TTL", cfg.InstanceTTL) cfg.Heartbeat = getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", cfg.Heartbeat) cfg.EnableNacosRegister = getenvBool("ENABLE_NACOS_REGISTER", cfg.EnableNacosRegister) cfg.NacosServerAddr = getenv("NACOS_SERVER_ADDR", cfg.NacosServerAddr) cfg.NacosNamespace = getenv("NACOS_NAMESPACE", cfg.NacosNamespace) cfg.NacosGroup = getenv("NACOS_GROUP", cfg.NacosGroup) cfg.NacosServiceName = getenv("NACOS_SERVICE_NAME", cfg.NacosServiceName) cfg.NacosClusterName = getenv("NACOS_CLUSTER_NAME", cfg.NacosClusterName) cfg.NacosUsername = getenv("NACOS_USERNAME", cfg.NacosUsername) cfg.NacosPassword = getenv("NACOS_PASSWORD", cfg.NacosPassword) cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP) cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort) cfg.NacosEphemeral = getenvBool("NACOS_EPHEMERAL", cfg.NacosEphemeral) cfg.AppName = getenv("APP_NAME", cfg.AppName) cfg.AppEnv = getenv("APP_ENV", cfg.AppEnv) cfg.LogPath = getenv("LOG_PATH", cfg.LogPath) cfg.LogLevel = getenv("LOG_LEVEL", cfg.LogLevel) cfg.LogMaxSizeMB = getenvInt("LOG_MAX_SIZE_MB", cfg.LogMaxSizeMB) cfg.LogMaxBackups = getenvInt("LOG_MAX_BACKUPS", cfg.LogMaxBackups) cfg.LogMaxAgeDays = getenvInt("LOG_MAX_AGE_DAYS", cfg.LogMaxAgeDays) cfg.LogCompress = getenvBool("LOG_COMPRESS", cfg.LogCompress) cfg.LogConsoleEnabled = getenvBool("LOG_CONSOLE_ENABLED", cfg.LogConsoleEnabled) cfg.TrustedProxies = getenvCSV("TRUSTED_PROXIES", cfg.TrustedProxies) cfg.RemoteIPHeaders = getenvCSV("REMOTE_IP_HEADERS", cfg.RemoteIPHeaders) cfg.ForwardedByClientIP = getenvBool("FORWARDED_BY_CLIENT_IP", cfg.ForwardedByClientIP) cfg.Servers = applyLanguageServerEnvOverrides(cfg.Servers) } func finalizeConfig(cfg *config, cwd string) { if strings.TrimSpace(cfg.Port) == "" { cfg.Port = "8080" } if strings.TrimSpace(cfg.WorkspaceDir) == "" { cfg.WorkspaceDir = cwd } if strings.TrimSpace(cfg.AllowOrigin) == "" { cfg.AllowOrigin = "*" } if cfg.RequestTimeout <= 0 { cfg.RequestTimeout = 10 * time.Second } if cfg.MaxBodyBytes <= 0 { cfg.MaxBodyBytes = 2 << 20 } if cfg.SessionTTL <= 0 { cfg.SessionTTL = 20 * time.Minute } if cfg.CleanupInterval <= 0 { cfg.CleanupInterval = 2 * time.Minute } if cfg.MaxSessions <= 0 { cfg.MaxSessions = 256 } if strings.TrimSpace(cfg.InstanceID) == "" { cfg.InstanceID = defaultInstanceID() } if strings.TrimSpace(cfg.InstanceURL) == "" { cfg.InstanceURL = "http://127.0.0.1:" + cfg.Port } if strings.TrimSpace(cfg.RedisAddr) == "" { cfg.RedisAddr = "10.0.0.10:6379" } if strings.TrimSpace(cfg.RedisKeyPrefix) == "" { cfg.RedisKeyPrefix = "lsp-gateway" } if cfg.InstanceTTL <= 0 { cfg.InstanceTTL = 30 * time.Second } if cfg.Heartbeat <= 0 { cfg.Heartbeat = 10 * time.Second } if strings.TrimSpace(cfg.NacosServerAddr) == "" { cfg.NacosServerAddr = "10.0.0.10:8848" } if strings.TrimSpace(cfg.NacosGroup) == "" { cfg.NacosGroup = "DEFAULT_GROUP" } if strings.TrimSpace(cfg.NacosServiceName) == "" { cfg.NacosServiceName = "lsp-gateway" } if cfg.NacosRegisterPort <= 0 { cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080) } cfg.NacosRegisterIP = cluster.ResolveRegisterIP(cfg.NacosRegisterIP, cfg.InstanceURL) if strings.TrimSpace(cfg.AppName) == "" { cfg.AppName = "lsp-gateway" } if strings.TrimSpace(cfg.AppEnv) == "" { cfg.AppEnv = "dev" } if strings.TrimSpace(cfg.LogLevel) == "" { cfg.LogLevel = "info" } if cfg.LogMaxSizeMB <= 0 { cfg.LogMaxSizeMB = 100 } if cfg.LogMaxBackups <= 0 { cfg.LogMaxBackups = 31 } if cfg.LogMaxAgeDays <= 0 { cfg.LogMaxAgeDays = 31 } if strings.TrimSpace(cfg.LogPath) == "" { cfg.LogPath = filepath.Join(".", "logs", cfg.AppName) } if cfg.RemoteIPHeaders == nil || len(cfg.RemoteIPHeaders) == 0 { cfg.RemoteIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"} } cfg.TrustedProxies = filterEmptyStrings(cfg.TrustedProxies) if len(cfg.Servers) == 0 { cfg.Servers = defaultLanguageServers() } } func parsePortOrDefault(port string, fallback int) int { trimmed := strings.TrimSpace(port) if trimmed == "" { return fallback } n, err := strconv.Atoi(trimmed) if err != nil || n <= 0 { return fallback } return n } func defaultLanguageServers() []completion.LanguageServerSpec { return []completion.LanguageServerSpec{ { Language: "go", LanguageID: "go", Command: "gopls", }, { Language: "javascript", LanguageID: "javascript", Command: "typescript-language-server", Args: []string{"--stdio"}, }, { Language: "typescript", LanguageID: "typescript", Command: "typescript-language-server", Args: []string{"--stdio"}, }, } } func normalizeServerSpecs(specs []completion.LanguageServerSpec) []completion.LanguageServerSpec { out := make([]completion.LanguageServerSpec, 0, len(specs)) for _, spec := range specs { spec.Language = strings.TrimSpace(spec.Language) spec.LanguageID = strings.TrimSpace(spec.LanguageID) spec.Command = strings.TrimSpace(spec.Command) out = append(out, spec) } return out } func applyLanguageServerEnvOverrides(servers []completion.LanguageServerSpec) []completion.LanguageServerSpec { overridden := normalizeServerSpecs(servers) overridden = applySingleLanguageServerEnv(overridden, "go", "go", "GO_LSP_COMMAND", "GO_LSP_ARGS", "gopls", "") overridden = applySingleLanguageServerEnv(overridden, "javascript", "javascript", "JAVASCRIPT_LSP_COMMAND", "JAVASCRIPT_LSP_ARGS", "typescript-language-server", "--stdio") overridden = applySingleLanguageServerEnv(overridden, "typescript", "typescript", "TYPESCRIPT_LSP_COMMAND", "TYPESCRIPT_LSP_ARGS", "typescript-language-server", "--stdio") return overridden } func applySingleLanguageServerEnv( servers []completion.LanguageServerSpec, language string, defaultLanguageID string, commandEnv string, argsEnv string, defaultCommand string, defaultArgs string, ) []completion.LanguageServerSpec { index := findServerSpecIndex(servers, language) if index < 0 { servers = append(servers, completion.LanguageServerSpec{ Language: language, LanguageID: defaultLanguageID, }) index = len(servers) - 1 } spec := servers[index] if strings.TrimSpace(spec.Language) == "" { spec.Language = language } if strings.TrimSpace(spec.LanguageID) == "" { spec.LanguageID = defaultLanguageID } if cmd, ok := lookupEnv(commandEnv); ok { trimmed := strings.TrimSpace(cmd) if trimmed == "" { spec.Command = defaultCommand } else { spec.Command = trimmed } } else if strings.TrimSpace(spec.Command) == "" { spec.Command = defaultCommand } if argsValue, ok := lookupEnv(argsEnv); ok { spec.Args = splitArgs(argsValue) } else if len(spec.Args) == 0 { spec.Args = splitArgs(defaultArgs) } servers[index] = spec return servers } func findServerSpecIndex(specs []completion.LanguageServerSpec, language string) int { target := strings.ToLower(strings.TrimSpace(language)) for i, spec := range specs { if strings.ToLower(strings.TrimSpace(spec.Language)) == target { return i } } return -1 } func splitArgs(raw string) []string { value := strings.TrimSpace(raw) if value == "" { return nil } return strings.Fields(value) } func lookupEnv(key string) (string, bool) { v, ok := os.LookupEnv(key) return v, ok } func filterEmptyStrings(values []string) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)) for _, v := range values { trimmed := strings.TrimSpace(v) if trimmed != "" { out = append(out, trimmed) } } if len(out) == 0 { return nil } return out } func splitCSV(raw string) []string { parts := strings.Split(raw, ",") return filterEmptyStrings(parts) } func getenvCSV(key string, fallback []string) []string { v, ok := lookupEnv(key) if !ok { return fallback } parsed := splitCSV(v) if parsed == nil { return nil } return parsed } // defaultInstanceID 生成默认实例 ID,便于在日志和 Redis 中区分节点。 func defaultInstanceID() string { host, err := os.Hostname() if err != nil || strings.TrimSpace(host) == "" { host = "unknown-host" } userName := "unknown-user" if u, err := user.Current(); err == nil && strings.TrimSpace(u.Username) != "" { userName = strings.ReplaceAll(u.Username, "\\", "-") } return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid()) } // getenv 读取字符串环境变量,空值时返回 fallback。 func getenv(key, fallback string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } return v } // getenvArgs 按 shell 风格拆分参数列表。 func getenvArgs(key, fallback string) []string { value := getenv(key, fallback) if value == "" { return nil } return strings.Fields(value) } // getenvInt 读取整数环境变量,解析失败时返回 fallback。 func getenvInt(key string, fallback int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } parsed, err := strconv.Atoi(v) if err == nil { return parsed } return fallback } // getenvInt64 读取 int64 环境变量,解析失败时返回 fallback。 func getenvInt64(key string, fallback int64) int64 { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } parsed, err := strconv.ParseInt(v, 10, 64) if err == nil { return parsed } return fallback } // getenvDuration 读取 time.Duration 格式的环境变量。 func getenvDuration(key string, fallback time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } parsed, err := time.ParseDuration(v) if err != nil { return fallback } return parsed } // getenvBool 读取布尔环境变量,支持常见开关写法。 func getenvBool(key string, fallback bool) bool { v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) if v == "" { return fallback } switch v { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return fallback } } // corsMiddleware 处理跨域响应头与 OPTIONS 预检请求。 func corsMiddleware(allowOrigin string) gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", allowOrigin) c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-API-Key") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } // apiTokenMiddleware 校验 X-API-Key;未配置 token 时放行。 func apiTokenMiddleware(token string) gin.HandlerFunc { required := strings.TrimSpace(token) if required == "" { return func(c *gin.Context) { c.Next() } } return func(c *gin.Context) { provided := strings.TrimSpace(c.GetHeader("X-API-Key")) if provided == required { c.Next() return } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) } } // requestIDMiddleware 为请求补齐并回传 X-Request-Id。 func requestIDMiddleware() gin.HandlerFunc { return func(c *gin.Context) { rid := strings.TrimSpace(c.GetHeader("X-Request-Id")) if rid == "" { rid = strings.TrimSpace(c.GetHeader("X-Trace-Id")) } if rid == "" { rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1)) } c.Header("X-Request-Id", rid) c.Header("X-Trace-Id", rid) c.Set("requestId", rid) c.Set("traceId", rid) c.Next() } } // accessLogMiddleware 输出统一访问日志,便于排查前端请求链路。 func accessLogMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next() latency := time.Since(start) traceID := strings.TrimSpace(c.GetString("traceId")) if traceID == "" { traceID = strings.TrimSpace(c.GetString("requestId")) } fields := []zap.Field{ zap.String("traceId", traceID), zap.String("method", c.Request.Method), zap.String("path", c.FullPath()), zap.String("rawPath", c.Request.URL.Path), zap.Int("status", c.Writer.Status()), zap.Duration("latency", latency), zap.String("clientIP", c.ClientIP()), zap.String("userAgent", c.Request.UserAgent()), } if len(c.Errors) > 0 { fields = append(fields, zap.String("errors", c.Errors.String())) } switch status := c.Writer.Status(); { case status >= http.StatusInternalServerError: zap.L().Error("http request finished", fields...) case status >= http.StatusBadRequest: zap.L().Warn("http request finished", fields...) default: zap.L().Info("http request finished", fields...) } } } // recoveryMiddleware 捕获 panic 并落盘错误日志,避免进程崩溃无痕。 func recoveryMiddleware() gin.HandlerFunc { return gin.CustomRecovery(func(c *gin.Context, recovered any) { traceID := strings.TrimSpace(c.GetString("traceId")) if traceID == "" { traceID = strings.TrimSpace(c.GetString("requestId")) } zap.L().Error( "panic recovered", zap.Any("panic", recovered), zap.String("traceId", traceID), zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path), zap.String("clientIP", c.ClientIP()), ) c.AbortWithStatus(http.StatusInternalServerError) }) } func configureProxySettings(router *gin.Engine, cfg config) error { if router == nil { return fmt.Errorf("router is nil") } router.ForwardedByClientIP = cfg.ForwardedByClientIP router.RemoteIPHeaders = append([]string(nil), cfg.RemoteIPHeaders...) if len(cfg.TrustedProxies) == 0 { if err := router.SetTrustedProxies(nil); err != nil { return err } zap.L().Warn( "trusted proxies disabled; using direct peer IP", zap.Strings("remoteIPHeaders", router.RemoteIPHeaders), zap.Bool("forwardedByClientIP", router.ForwardedByClientIP), ) return nil } if err := router.SetTrustedProxies(cfg.TrustedProxies); err != nil { return err } zap.L().Info( "trusted proxies configured", zap.Strings("trustedProxies", cfg.TrustedProxies), zap.Strings("remoteIPHeaders", router.RemoteIPHeaders), zap.Bool("forwardedByClientIP", router.ForwardedByClientIP), ) return nil }