feat: add Docker support and logging enhancements
- Introduced Dockerfile and .dockerignore for containerization of the backend service. - Added logging configuration with support for daily rolling logs and customizable log levels. - Enhanced the server to utilize structured logging with zap, including detailed request and error logging. - Updated configuration to include logging parameters and proxy settings. - Implemented tests for logging configuration and proxy settings.
This commit is contained in:
13
backend/.dockerignore
Normal file
13
backend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.claude
|
||||||
|
|
||||||
|
docs
|
||||||
|
|
||||||
|
cmd/server.exe
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
|
||||||
|
config.json
|
||||||
|
|
||||||
|
**/*_test.go
|
||||||
23
backend/Dockerfile
Normal file
23
backend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /out/server /app/server
|
||||||
|
COPY config.example.json /app/config.example.json
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
USER app
|
||||||
|
ENTRYPOINT ["/app/server"]
|
||||||
@@ -72,6 +72,18 @@ CONFIG_FILE=./config.local.json go run ./cmd/server
|
|||||||
- `NACOS_IP`:实例注册 IP(建议注入 Pod/主机内网 IP)
|
- `NACOS_IP`:实例注册 IP(建议注入 Pod/主机内网 IP)
|
||||||
- `NACOS_PORT`:实例注册端口,默认取 `PORT`
|
- `NACOS_PORT`:实例注册端口,默认取 `PORT`
|
||||||
- `NACOS_EPHEMERAL`:是否临时实例,默认 `true`
|
- `NACOS_EPHEMERAL`:是否临时实例,默认 `true`
|
||||||
|
- `APP_NAME`:日志应用名,默认 `lsp-gateway`
|
||||||
|
- `APP_ENV`:运行环境,默认 `dev`
|
||||||
|
- `LOG_PATH`:日志根目录,默认 `./logs/${APP_NAME}`
|
||||||
|
- `LOG_LEVEL`:日志级别(`debug/info/warn/error`),默认 `info`
|
||||||
|
- `LOG_MAX_SIZE_MB`:单文件滚动阈值(MB),默认 `100`
|
||||||
|
- `LOG_MAX_BACKUPS`:滚动文件保留数量,默认 `31`
|
||||||
|
- `LOG_MAX_AGE_DAYS`:日志保留天数,默认 `31`
|
||||||
|
- `LOG_COMPRESS`:滚动文件是否压缩,默认 `true`
|
||||||
|
- `LOG_CONSOLE_ENABLED`:是否输出控制台,默认 `true`
|
||||||
|
- `TRUSTED_PROXIES`:受信代理列表(逗号分隔 IP/CIDR),默认空(不信任代理头)
|
||||||
|
- `REMOTE_IP_HEADERS`:客户端 IP 头列表(逗号分隔),默认 `X-Forwarded-For,X-Real-IP`
|
||||||
|
- `FORWARDED_BY_CLIENT_IP`:是否从代理头解析客户端 IP,默认 `true`
|
||||||
|
|
||||||
语言服务器命令(可替换为企业内部镜像/封装):
|
语言服务器命令(可替换为企业内部镜像/封装):
|
||||||
- `GO_LSP_COMMAND`,`GO_LSP_ARGS`
|
- `GO_LSP_COMMAND`,`GO_LSP_ARGS`
|
||||||
@@ -94,6 +106,14 @@ NACOS_IP=10.0.2.15
|
|||||||
NACOS_PORT=8080
|
NACOS_PORT=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
日志输出说明(参考 Logback):
|
||||||
|
- 访问日志、业务日志都会写入文件和控制台(可关)。
|
||||||
|
- 日志目录结构:`logs/<app>/<yyyy-MM>/<yyyy-MM-dd>/`
|
||||||
|
- `info.log`:记录 `INFO/WARN`
|
||||||
|
- `error.log`:记录 `ERROR` 及以上
|
||||||
|
- 请求链路字段:`traceId`(来自 `X-Request-Id`/`X-Trace-Id`)
|
||||||
|
- 如果服务部署在网关后,请务必配置 `TRUSTED_PROXIES` 为网关出口网段,否则会记录到网关 IP。
|
||||||
|
|
||||||
## 健康检查
|
## 健康检查
|
||||||
|
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -16,10 +17,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"monica-go-completion-backend/internal/api"
|
"monica-go-completion-backend/internal/api"
|
||||||
"monica-go-completion-backend/internal/cluster"
|
"monica-go-completion-backend/internal/cluster"
|
||||||
"monica-go-completion-backend/internal/completion"
|
"monica-go-completion-backend/internal/completion"
|
||||||
|
"monica-go-completion-backend/internal/logging"
|
||||||
"monica-go-completion-backend/internal/lsp"
|
"monica-go-completion-backend/internal/lsp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,6 +87,30 @@ type config struct {
|
|||||||
NacosRegisterPort int
|
NacosRegisterPort int
|
||||||
// NacosEphemeral 控制是否使用临时实例模式。
|
// NacosEphemeral 控制是否使用临时实例模式。
|
||||||
NacosEphemeral bool
|
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 定义各语言 LSP 服务的启动命令与参数。
|
||||||
Servers []completion.LanguageServerSpec
|
Servers []completion.LanguageServerSpec
|
||||||
}
|
}
|
||||||
@@ -119,6 +146,18 @@ type fileConfig struct {
|
|||||||
NacosRegisterIP *string `json:"nacosRegisterIP"`
|
NacosRegisterIP *string `json:"nacosRegisterIP"`
|
||||||
NacosRegisterPort *int `json:"nacosRegisterPort"`
|
NacosRegisterPort *int `json:"nacosRegisterPort"`
|
||||||
NacosEphemeral *bool `json:"nacosEphemeral"`
|
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"`
|
Servers []completion.LanguageServerSpec `json:"servers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +168,28 @@ func main() {
|
|||||||
log.Fatalf("load config failed: %v", err)
|
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
|
var registry completion.SessionRegistry
|
||||||
if cfg.EnableRedis {
|
if cfg.EnableRedis {
|
||||||
// Redis 注册中心用于多实例下的会话归属协调(粘性路由)。
|
// Redis 注册中心用于多实例下的会话归属协调(粘性路由)。
|
||||||
@@ -196,9 +257,12 @@ func main() {
|
|||||||
|
|
||||||
// 注册通用中间件与业务路由。
|
// 注册通用中间件与业务路由。
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.Logger())
|
if err := configureProxySettings(router, cfg); err != nil {
|
||||||
router.Use(gin.Recovery())
|
log.Fatalf("configure trusted proxies failed: %v", err)
|
||||||
|
}
|
||||||
router.Use(requestIDMiddleware())
|
router.Use(requestIDMiddleware())
|
||||||
|
router.Use(accessLogMiddleware())
|
||||||
|
router.Use(recoveryMiddleware())
|
||||||
router.Use(corsMiddleware(cfg.AllowOrigin))
|
router.Use(corsMiddleware(cfg.AllowOrigin))
|
||||||
router.Use(apiTokenMiddleware(cfg.APIToken))
|
router.Use(apiTokenMiddleware(cfg.APIToken))
|
||||||
|
|
||||||
@@ -277,6 +341,16 @@ func loadConfig() (config, error) {
|
|||||||
NacosGroup: "DEFAULT_GROUP",
|
NacosGroup: "DEFAULT_GROUP",
|
||||||
NacosServiceName: "lsp-gateway",
|
NacosServiceName: "lsp-gateway",
|
||||||
NacosEphemeral: true,
|
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(),
|
Servers: defaultLanguageServers(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,12 +370,15 @@ func loadConfig() (config, error) {
|
|||||||
applyEnvOverrides(&cfg)
|
applyEnvOverrides(&cfg)
|
||||||
finalizeConfig(&cfg, cwd)
|
finalizeConfig(&cfg, cwd)
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s",
|
"effective config: port=%s, workspace=%s, redis=%t, nacos=%t, nacosServer=%s, logPath=%s, logLevel=%s, trustedProxies=%d",
|
||||||
cfg.Port,
|
cfg.Port,
|
||||||
cfg.WorkspaceDir,
|
cfg.WorkspaceDir,
|
||||||
cfg.EnableRedis,
|
cfg.EnableRedis,
|
||||||
cfg.EnableNacosRegister,
|
cfg.EnableNacosRegister,
|
||||||
cfg.NacosServerAddr,
|
cfg.NacosServerAddr,
|
||||||
|
cfg.LogPath,
|
||||||
|
cfg.LogLevel,
|
||||||
|
len(cfg.TrustedProxies),
|
||||||
)
|
)
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
@@ -439,6 +516,42 @@ func applyFileConfig(cfg *config, fc fileConfig) error {
|
|||||||
if fc.NacosEphemeral != nil {
|
if fc.NacosEphemeral != nil {
|
||||||
cfg.NacosEphemeral = *fc.NacosEphemeral
|
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 {
|
if fc.Servers != nil {
|
||||||
cfg.Servers = normalizeServerSpecs(fc.Servers)
|
cfg.Servers = normalizeServerSpecs(fc.Servers)
|
||||||
}
|
}
|
||||||
@@ -475,6 +588,18 @@ func applyEnvOverrides(cfg *config) {
|
|||||||
cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP)
|
cfg.NacosRegisterIP = getenv("NACOS_IP", cfg.NacosRegisterIP)
|
||||||
cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort)
|
cfg.NacosRegisterPort = getenvInt("NACOS_PORT", cfg.NacosRegisterPort)
|
||||||
cfg.NacosEphemeral = getenvBool("NACOS_EPHEMERAL", cfg.NacosEphemeral)
|
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)
|
cfg.Servers = applyLanguageServerEnvOverrides(cfg.Servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,6 +659,31 @@ func finalizeConfig(cfg *config, cwd string) {
|
|||||||
cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080)
|
cfg.NacosRegisterPort = parsePortOrDefault(cfg.Port, 8080)
|
||||||
}
|
}
|
||||||
cfg.NacosRegisterIP = cluster.ResolveRegisterIP(cfg.NacosRegisterIP, cfg.InstanceURL)
|
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 {
|
if len(cfg.Servers) == 0 {
|
||||||
cfg.Servers = defaultLanguageServers()
|
cfg.Servers = defaultLanguageServers()
|
||||||
}
|
}
|
||||||
@@ -662,6 +812,40 @@ func lookupEnv(key string) (string, bool) {
|
|||||||
return v, ok
|
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 中区分节点。
|
// defaultInstanceID 生成默认实例 ID,便于在日志和 Redis 中区分节点。
|
||||||
func defaultInstanceID() string {
|
func defaultInstanceID() string {
|
||||||
host, err := os.Hostname()
|
host, err := os.Hostname()
|
||||||
@@ -786,11 +970,103 @@ func apiTokenMiddleware(token string) gin.HandlerFunc {
|
|||||||
func requestIDMiddleware() gin.HandlerFunc {
|
func requestIDMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))
|
rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))
|
||||||
|
if rid == "" {
|
||||||
|
rid = strings.TrimSpace(c.GetHeader("X-Trace-Id"))
|
||||||
|
}
|
||||||
if rid == "" {
|
if rid == "" {
|
||||||
rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1))
|
rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1))
|
||||||
}
|
}
|
||||||
c.Header("X-Request-Id", rid)
|
c.Header("X-Request-Id", rid)
|
||||||
|
c.Header("X-Trace-Id", rid)
|
||||||
c.Set("requestId", rid)
|
c.Set("requestId", rid)
|
||||||
|
c.Set("traceId", rid)
|
||||||
c.Next()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,139 @@ func TestLoadConfigNacosFromEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigLoggingDefaults(t *testing.T) {
|
||||||
|
withEnv(t, "APP_NAME", "")
|
||||||
|
withEnv(t, "APP_ENV", "")
|
||||||
|
withEnv(t, "LOG_PATH", "")
|
||||||
|
withEnv(t, "LOG_LEVEL", "")
|
||||||
|
withEnv(t, "LOG_MAX_SIZE_MB", "")
|
||||||
|
withEnv(t, "LOG_MAX_BACKUPS", "")
|
||||||
|
withEnv(t, "LOG_MAX_AGE_DAYS", "")
|
||||||
|
withEnv(t, "LOG_COMPRESS", "")
|
||||||
|
withEnv(t, "LOG_CONSOLE_ENABLED", "")
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AppName != "lsp-gateway" {
|
||||||
|
t.Fatalf("expected default AppName lsp-gateway, got %q", cfg.AppName)
|
||||||
|
}
|
||||||
|
if cfg.AppEnv != "dev" {
|
||||||
|
t.Fatalf("expected default AppEnv dev, got %q", cfg.AppEnv)
|
||||||
|
}
|
||||||
|
expectedPath := filepath.Join(".", "logs", "lsp-gateway")
|
||||||
|
if cfg.LogPath != expectedPath {
|
||||||
|
t.Fatalf("expected default LogPath %q, got %q", expectedPath, cfg.LogPath)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "info" {
|
||||||
|
t.Fatalf("expected default LogLevel info, got %q", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.LogMaxSizeMB != 100 {
|
||||||
|
t.Fatalf("expected default LogMaxSizeMB 100, got %d", cfg.LogMaxSizeMB)
|
||||||
|
}
|
||||||
|
if cfg.LogMaxBackups != 31 {
|
||||||
|
t.Fatalf("expected default LogMaxBackups 31, got %d", cfg.LogMaxBackups)
|
||||||
|
}
|
||||||
|
if cfg.LogMaxAgeDays != 31 {
|
||||||
|
t.Fatalf("expected default LogMaxAgeDays 31, got %d", cfg.LogMaxAgeDays)
|
||||||
|
}
|
||||||
|
if !cfg.LogCompress {
|
||||||
|
t.Fatalf("expected default LogCompress true")
|
||||||
|
}
|
||||||
|
if !cfg.LogConsoleEnabled {
|
||||||
|
t.Fatalf("expected default LogConsoleEnabled true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigLoggingFromEnv(t *testing.T) {
|
||||||
|
withEnv(t, "APP_NAME", "editor-bff")
|
||||||
|
withEnv(t, "APP_ENV", "prod")
|
||||||
|
withEnv(t, "LOG_PATH", "/data/logs/editor-bff")
|
||||||
|
withEnv(t, "LOG_LEVEL", "warn")
|
||||||
|
withEnv(t, "LOG_MAX_SIZE_MB", "200")
|
||||||
|
withEnv(t, "LOG_MAX_BACKUPS", "50")
|
||||||
|
withEnv(t, "LOG_MAX_AGE_DAYS", "15")
|
||||||
|
withEnv(t, "LOG_COMPRESS", "false")
|
||||||
|
withEnv(t, "LOG_CONSOLE_ENABLED", "false")
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AppName != "editor-bff" {
|
||||||
|
t.Fatalf("unexpected AppName %q", cfg.AppName)
|
||||||
|
}
|
||||||
|
if cfg.AppEnv != "prod" {
|
||||||
|
t.Fatalf("unexpected AppEnv %q", cfg.AppEnv)
|
||||||
|
}
|
||||||
|
if cfg.LogPath != "/data/logs/editor-bff" {
|
||||||
|
t.Fatalf("unexpected LogPath %q", cfg.LogPath)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "warn" {
|
||||||
|
t.Fatalf("unexpected LogLevel %q", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.LogMaxSizeMB != 200 {
|
||||||
|
t.Fatalf("unexpected LogMaxSizeMB %d", cfg.LogMaxSizeMB)
|
||||||
|
}
|
||||||
|
if cfg.LogMaxBackups != 50 {
|
||||||
|
t.Fatalf("unexpected LogMaxBackups %d", cfg.LogMaxBackups)
|
||||||
|
}
|
||||||
|
if cfg.LogMaxAgeDays != 15 {
|
||||||
|
t.Fatalf("unexpected LogMaxAgeDays %d", cfg.LogMaxAgeDays)
|
||||||
|
}
|
||||||
|
if cfg.LogCompress {
|
||||||
|
t.Fatalf("expected LogCompress false")
|
||||||
|
}
|
||||||
|
if cfg.LogConsoleEnabled {
|
||||||
|
t.Fatalf("expected LogConsoleEnabled false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigProxyDefaults(t *testing.T) {
|
||||||
|
withEnv(t, "TRUSTED_PROXIES", "")
|
||||||
|
withEnv(t, "REMOTE_IP_HEADERS", "")
|
||||||
|
withEnv(t, "FORWARDED_BY_CLIENT_IP", "")
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.TrustedProxies) != 0 {
|
||||||
|
t.Fatalf("expected default TrustedProxies empty, got %v", cfg.TrustedProxies)
|
||||||
|
}
|
||||||
|
if len(cfg.RemoteIPHeaders) != 2 || cfg.RemoteIPHeaders[0] != "X-Forwarded-For" || cfg.RemoteIPHeaders[1] != "X-Real-IP" {
|
||||||
|
t.Fatalf("unexpected default RemoteIPHeaders %v", cfg.RemoteIPHeaders)
|
||||||
|
}
|
||||||
|
if !cfg.ForwardedByClientIP {
|
||||||
|
t.Fatalf("expected default ForwardedByClientIP true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigProxyFromEnv(t *testing.T) {
|
||||||
|
withEnv(t, "TRUSTED_PROXIES", "10.0.0.0/8,172.16.0.0/12")
|
||||||
|
withEnv(t, "REMOTE_IP_HEADERS", "X-Forwarded-For,X-Real-IP")
|
||||||
|
withEnv(t, "FORWARDED_BY_CLIENT_IP", "false")
|
||||||
|
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[0] != "10.0.0.0/8" || cfg.TrustedProxies[1] != "172.16.0.0/12" {
|
||||||
|
t.Fatalf("unexpected TrustedProxies %v", cfg.TrustedProxies)
|
||||||
|
}
|
||||||
|
if len(cfg.RemoteIPHeaders) != 2 || cfg.RemoteIPHeaders[0] != "X-Forwarded-For" || cfg.RemoteIPHeaders[1] != "X-Real-IP" {
|
||||||
|
t.Fatalf("unexpected RemoteIPHeaders %v", cfg.RemoteIPHeaders)
|
||||||
|
}
|
||||||
|
if cfg.ForwardedByClientIP {
|
||||||
|
t.Fatalf("expected ForwardedByClientIP false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func withEnv(t *testing.T, key, value string) {
|
func withEnv(t *testing.T, key, value string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
old, existed := os.LookupEnv(key)
|
old, existed := os.LookupEnv(key)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/nacos-group/nacos-sdk-go/v2 v2.3.5
|
github.com/nacos-group/nacos-sdk-go/v2 v2.3.5
|
||||||
github.com/redis/go-redis/v9 v9.17.3
|
github.com/redis/go-redis/v9 v9.17.3
|
||||||
|
go.uber.org/zap v1.21.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -71,7 +73,6 @@ require (
|
|||||||
go.uber.org/atomic v1.7.0 // indirect
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.uber.org/multierr v1.6.0 // indirect
|
go.uber.org/multierr v1.6.0 // indirect
|
||||||
go.uber.org/zap v1.21.0 // indirect
|
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
@@ -85,5 +86,4 @@ require (
|
|||||||
google.golang.org/grpc v1.67.3 // indirect
|
google.golang.org/grpc v1.67.3 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"monica-go-completion-backend/internal/completion"
|
"monica-go-completion-backend/internal/completion"
|
||||||
)
|
)
|
||||||
@@ -67,6 +69,13 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
|
|||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, opts.MaxBodyBytes)
|
||||||
var req completion.Request
|
var req completion.Request
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
zap.L().Warn(
|
||||||
|
"invalid completion json payload",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("routeLanguage", c.Param("language")),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,19 +93,51 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
|
|||||||
resp, err := service.Complete(ctx, req)
|
resp, err := service.Complete(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, completion.ErrInvalidRequest) {
|
if errors.Is(err, completion.ErrInvalidRequest) {
|
||||||
|
zap.L().Warn(
|
||||||
|
"completion request invalid",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("uri", req.URI),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, completion.ErrUnsupportedLanguage) {
|
if errors.Is(err, completion.ErrUnsupportedLanguage) {
|
||||||
|
zap.L().Warn(
|
||||||
|
"completion language not supported",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, completion.ErrTooManySessions) {
|
if errors.Is(err, completion.ErrTooManySessions) {
|
||||||
|
zap.L().Warn(
|
||||||
|
"completion rejected due to session limit",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("sessionId", req.SessionID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var ownedErr *completion.ErrSessionOwnedByOtherInstance
|
var ownedErr *completion.ErrSessionOwnedByOtherInstance
|
||||||
if errors.As(err, &ownedErr) {
|
if errors.As(err, &ownedErr) {
|
||||||
|
zap.L().Info(
|
||||||
|
"completion routed to session owner",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("sessionId", req.SessionID),
|
||||||
|
zap.String("ownerId", ownedErr.OwnerID),
|
||||||
|
zap.String("routeTo", ownedErr.OwnerEndpoint),
|
||||||
|
)
|
||||||
if ownedErr.OwnerEndpoint != "" {
|
if ownedErr.OwnerEndpoint != "" {
|
||||||
c.Header("X-LSP-Route-To", ownedErr.OwnerEndpoint)
|
c.Header("X-LSP-Route-To", ownedErr.OwnerEndpoint)
|
||||||
}
|
}
|
||||||
@@ -107,9 +148,28 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
zap.L().Error(
|
||||||
|
"completion failed",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("sessionId", req.SessionID),
|
||||||
|
zap.String("uri", req.URI),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "completion failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "completion failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
zap.L().Info(
|
||||||
|
"completion success",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("sessionId", req.SessionID),
|
||||||
|
zap.String("uri", req.URI),
|
||||||
|
zap.Int("items", len(resp.Items)),
|
||||||
|
zap.Bool("isIncomplete", resp.IsIncomplete),
|
||||||
|
)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
@@ -118,3 +178,14 @@ func RegisterRoutes(router *gin.Engine, service CompletionService, options ...Ro
|
|||||||
handleCompletion(c)
|
handleCompletion(c)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func traceIDFromGin(c *gin.Context) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
traceID := strings.TrimSpace(c.GetString("traceId"))
|
||||||
|
if traceID != "" {
|
||||||
|
return traceID
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.GetString("requestId"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"monica-go-completion-backend/internal/completion"
|
"monica-go-completion-backend/internal/completion"
|
||||||
)
|
)
|
||||||
@@ -62,10 +63,35 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO
|
|||||||
handler := func(c *gin.Context, defaultLanguage string) {
|
handler := func(c *gin.Context, defaultLanguage string) {
|
||||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
zap.L().Warn(
|
||||||
|
"ws upgrade failed",
|
||||||
|
zap.String("traceId", traceIDFromGin(c)),
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
traceID := traceIDFromGin(c)
|
||||||
|
zap.L().Info(
|
||||||
|
"ws connection opened",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.String("language", defaultLanguage),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
)
|
||||||
|
defer func() {
|
||||||
|
zap.L().Info(
|
||||||
|
"ws connection closed",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.String("language", defaultLanguage),
|
||||||
|
zap.String("clientIP", c.ClientIP()),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
conn.SetReadLimit(opts.MaxBodyBytes)
|
conn.SetReadLimit(opts.MaxBodyBytes)
|
||||||
|
|
||||||
var writeMu sync.Mutex
|
var writeMu sync.Mutex
|
||||||
@@ -74,9 +100,15 @@ func registerWSRoutes(router *gin.Engine, service CompletionService, opts RouteO
|
|||||||
// 单连接串行读取消息,写操作通过 writeMu 保证并发安全。
|
// 单连接串行读取消息,写操作通过 writeMu 保证并发安全。
|
||||||
_, payload, err := conn.ReadMessage()
|
_, payload, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
zap.L().Info(
|
||||||
|
"ws read loop ended",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("path", c.Request.URL.Path),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
handleWSMessage(conn, &writeMu, service, payload, defaultLanguage, opts)
|
handleWSMessage(conn, &writeMu, service, payload, defaultLanguage, traceID, c.ClientIP(), opts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +127,22 @@ func handleWSMessage(
|
|||||||
service CompletionService,
|
service CompletionService,
|
||||||
payload []byte,
|
payload []byte,
|
||||||
defaultLanguage string,
|
defaultLanguage string,
|
||||||
|
traceID string,
|
||||||
|
clientIP string,
|
||||||
opts RouteOptions,
|
opts RouteOptions,
|
||||||
) {
|
) {
|
||||||
if tryHandleRPCMessage(conn, writeMu, service, payload, defaultLanguage, opts) {
|
if tryHandleRPCMessage(conn, writeMu, service, payload, defaultLanguage, traceID, clientIP, opts) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req wsCompletionRequest
|
var req wsCompletionRequest
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
|
zap.L().Warn(
|
||||||
|
"ws invalid json payload",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("clientIP", clientIP),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
sendWSResponse(conn, writeMu, wsCompletionResponse{
|
sendWSResponse(conn, writeMu, wsCompletionResponse{
|
||||||
ID: "",
|
ID: "",
|
||||||
Error: "invalid JSON payload",
|
Error: "invalid JSON payload",
|
||||||
@@ -113,7 +153,7 @@ func handleWSMessage(
|
|||||||
req.Language = defaultLanguage
|
req.Language = defaultLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
processWSCompletion(conn, writeMu, service, req, opts)
|
processWSCompletion(conn, writeMu, service, req, traceID, clientIP, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。
|
// tryHandleRPCMessage 处理 JSON-RPC 2.0 请求,返回 true 表示消息已消费。
|
||||||
@@ -123,6 +163,8 @@ func tryHandleRPCMessage(
|
|||||||
service CompletionService,
|
service CompletionService,
|
||||||
payload []byte,
|
payload []byte,
|
||||||
defaultLanguage string,
|
defaultLanguage string,
|
||||||
|
traceID string,
|
||||||
|
clientIP string,
|
||||||
opts RouteOptions,
|
opts RouteOptions,
|
||||||
) bool {
|
) bool {
|
||||||
var rpcReq wsRPCRequest
|
var rpcReq wsRPCRequest
|
||||||
@@ -148,6 +190,13 @@ func tryHandleRPCMessage(
|
|||||||
|
|
||||||
var req wsCompletionRequest
|
var req wsCompletionRequest
|
||||||
if err := json.Unmarshal(rpcReq.Params, &req); err != nil {
|
if err := json.Unmarshal(rpcReq.Params, &req); err != nil {
|
||||||
|
zap.L().Warn(
|
||||||
|
"ws rpc invalid params",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("clientIP", clientIP),
|
||||||
|
zap.String("method", rpcReq.Method),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
// 参数反序列化失败按 invalid params 处理。
|
// 参数反序列化失败按 invalid params 处理。
|
||||||
sendWSRPCResponse(conn, writeMu, wsRPCResponse{
|
sendWSRPCResponse(conn, writeMu, wsRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
@@ -179,6 +228,16 @@ func tryHandleRPCMessage(
|
|||||||
Character: req.Character,
|
Character: req.Character,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
zap.L().Warn(
|
||||||
|
"ws rpc completion failed",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("clientIP", clientIP),
|
||||||
|
zap.String("method", rpcReq.Method),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("sessionId", req.SessionID),
|
||||||
|
zap.String("uri", req.URI),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
sendWSRPCResponse(conn, writeMu, wsRPCResponse{
|
sendWSRPCResponse(conn, writeMu, wsRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
ID: rpcReq.ID,
|
ID: rpcReq.ID,
|
||||||
@@ -204,6 +263,8 @@ func processWSCompletion(
|
|||||||
writeMu *sync.Mutex,
|
writeMu *sync.Mutex,
|
||||||
service CompletionService,
|
service CompletionService,
|
||||||
req wsCompletionRequest,
|
req wsCompletionRequest,
|
||||||
|
traceID string,
|
||||||
|
clientIP string,
|
||||||
opts RouteOptions,
|
opts RouteOptions,
|
||||||
) {
|
) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), opts.RequestTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), opts.RequestTimeout)
|
||||||
@@ -237,6 +298,17 @@ func processWSCompletion(
|
|||||||
ownerID = ownedErr.OwnerID
|
ownerID = ownedErr.OwnerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
zap.L().Warn(
|
||||||
|
"ws completion failed",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("clientIP", clientIP),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("sessionId", req.SessionID),
|
||||||
|
zap.String("uri", req.URI),
|
||||||
|
zap.String("routeTo", routeTo),
|
||||||
|
zap.String("ownerId", ownerID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
sendWSResponse(conn, writeMu, wsCompletionResponse{
|
sendWSResponse(conn, writeMu, wsCompletionResponse{
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
RouteTo: routeTo,
|
RouteTo: routeTo,
|
||||||
@@ -245,6 +317,15 @@ func processWSCompletion(
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
zap.L().Info(
|
||||||
|
"ws completion success",
|
||||||
|
zap.String("traceId", traceID),
|
||||||
|
zap.String("clientIP", clientIP),
|
||||||
|
zap.String("language", req.Language),
|
||||||
|
zap.String("sessionId", req.SessionID),
|
||||||
|
zap.String("uri", req.URI),
|
||||||
|
zap.Int("items", len(resp.Items)),
|
||||||
|
)
|
||||||
|
|
||||||
sendWSResponse(conn, writeMu, wsCompletionResponse{
|
sendWSResponse(conn, writeMu, wsCompletionResponse{
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
|
|||||||
225
backend/internal/logging/logger.go
Normal file
225
backend/internal/logging/logger.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AppName string
|
||||||
|
Environment string
|
||||||
|
BasePath string
|
||||||
|
Level string
|
||||||
|
MaxSizeMB int
|
||||||
|
MaxBackups int
|
||||||
|
MaxAgeDays int
|
||||||
|
Compress bool
|
||||||
|
ConsoleEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) (*zap.Logger, func() error, error) {
|
||||||
|
cfg = finalizeConfig(cfg)
|
||||||
|
|
||||||
|
infoWriter, err := newDailyRollingWriter(cfg, "info.log")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
errorWriter, err := newDailyRollingWriter(cfg, "error.log")
|
||||||
|
if err != nil {
|
||||||
|
_ = infoWriter.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
level := parseLevel(cfg.Level)
|
||||||
|
encoderConfig := zapcore.EncoderConfig{
|
||||||
|
TimeKey: "time",
|
||||||
|
LevelKey: "level",
|
||||||
|
NameKey: "logger",
|
||||||
|
CallerKey: "caller",
|
||||||
|
MessageKey: "msg",
|
||||||
|
StacktraceKey: "stacktrace",
|
||||||
|
LineEnding: zapcore.DefaultLineEnding,
|
||||||
|
EncodeLevel: zapcore.CapitalLevelEncoder,
|
||||||
|
EncodeTime: zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05.000"),
|
||||||
|
EncodeDuration: zapcore.StringDurationEncoder,
|
||||||
|
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||||
|
}
|
||||||
|
jsonEncoder := zapcore.NewJSONEncoder(encoderConfig)
|
||||||
|
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||||
|
|
||||||
|
cores := make([]zapcore.Core, 0, 3)
|
||||||
|
cores = append(cores, zapcore.NewCore(
|
||||||
|
jsonEncoder,
|
||||||
|
zapcore.AddSync(io.Writer(infoWriter)),
|
||||||
|
zap.LevelEnablerFunc(func(l zapcore.Level) bool {
|
||||||
|
return l >= level && l < zapcore.ErrorLevel
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
cores = append(cores, zapcore.NewCore(
|
||||||
|
jsonEncoder,
|
||||||
|
zapcore.AddSync(io.Writer(errorWriter)),
|
||||||
|
zap.LevelEnablerFunc(func(l zapcore.Level) bool {
|
||||||
|
return l >= maxLevel(level, zapcore.ErrorLevel)
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
if cfg.ConsoleEnabled {
|
||||||
|
cores = append(cores, zapcore.NewCore(
|
||||||
|
consoleEncoder,
|
||||||
|
zapcore.AddSync(os.Stdout),
|
||||||
|
zap.LevelEnablerFunc(func(l zapcore.Level) bool {
|
||||||
|
return l >= level
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := zap.New(
|
||||||
|
zapcore.NewTee(cores...),
|
||||||
|
zap.AddCaller(),
|
||||||
|
zap.AddCallerSkip(1),
|
||||||
|
zap.AddStacktrace(zapcore.ErrorLevel),
|
||||||
|
zap.Fields(
|
||||||
|
zap.String("app", cfg.AppName),
|
||||||
|
zap.String("env", cfg.Environment),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup := func() error {
|
||||||
|
var finalErr error
|
||||||
|
if err := infoWriter.Close(); err != nil && finalErr == nil {
|
||||||
|
finalErr = err
|
||||||
|
}
|
||||||
|
if err := errorWriter.Close(); err != nil && finalErr == nil {
|
||||||
|
finalErr = err
|
||||||
|
}
|
||||||
|
if err := logger.Sync(); err != nil && finalErr == nil {
|
||||||
|
finalErr = err
|
||||||
|
}
|
||||||
|
return finalErr
|
||||||
|
}
|
||||||
|
return logger, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyRollingWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
cfg Config
|
||||||
|
fileName string
|
||||||
|
|
||||||
|
dayKey string
|
||||||
|
writer *lumberjack.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDailyRollingWriter(cfg Config, fileName string) (*dailyRollingWriter, error) {
|
||||||
|
w := &dailyRollingWriter{
|
||||||
|
cfg: cfg,
|
||||||
|
fileName: fileName,
|
||||||
|
}
|
||||||
|
if err := w.ensureWriter(time.Now()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dailyRollingWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if err := w.ensureWriter(time.Now()); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return w.writer.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dailyRollingWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.writer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := w.writer.Close()
|
||||||
|
w.writer = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *dailyRollingWriter) ensureWriter(now time.Time) error {
|
||||||
|
dayKey := now.Format("2006-01-02")
|
||||||
|
if w.writer != nil && w.dayKey == dayKey {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
monthDir := now.Format("2006-01")
|
||||||
|
logDir := filepath.Join(w.cfg.BasePath, monthDir, dayKey)
|
||||||
|
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir log dir failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
next := &lumberjack.Logger{
|
||||||
|
Filename: filepath.Join(logDir, w.fileName),
|
||||||
|
MaxSize: w.cfg.MaxSizeMB,
|
||||||
|
MaxBackups: w.cfg.MaxBackups,
|
||||||
|
MaxAge: w.cfg.MaxAgeDays,
|
||||||
|
Compress: w.cfg.Compress,
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.writer != nil {
|
||||||
|
_ = w.writer.Close()
|
||||||
|
}
|
||||||
|
w.writer = next
|
||||||
|
w.dayKey = dayKey
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeConfig(cfg Config) Config {
|
||||||
|
if strings.TrimSpace(cfg.AppName) == "" {
|
||||||
|
cfg.AppName = "lsp-gateway"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Environment) == "" {
|
||||||
|
cfg.Environment = "dev"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.BasePath) == "" {
|
||||||
|
cfg.BasePath = filepath.Join(".", "logs", cfg.AppName)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Level) == "" {
|
||||||
|
cfg.Level = "info"
|
||||||
|
}
|
||||||
|
if cfg.MaxSizeMB <= 0 {
|
||||||
|
cfg.MaxSizeMB = 100
|
||||||
|
}
|
||||||
|
if cfg.MaxBackups <= 0 {
|
||||||
|
cfg.MaxBackups = 31
|
||||||
|
}
|
||||||
|
if cfg.MaxAgeDays <= 0 {
|
||||||
|
cfg.MaxAgeDays = 31
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLevel(level string) zapcore.Level {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "debug":
|
||||||
|
return zapcore.DebugLevel
|
||||||
|
case "info":
|
||||||
|
return zapcore.InfoLevel
|
||||||
|
case "warn", "warning":
|
||||||
|
return zapcore.WarnLevel
|
||||||
|
case "error":
|
||||||
|
return zapcore.ErrorLevel
|
||||||
|
default:
|
||||||
|
return zapcore.InfoLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxLevel(a, b zapcore.Level) zapcore.Level {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user