commit 607ff9a055c799d805fb58556c0fe5b9c4f77a24 Author: hujie Date: Mon Mar 2 02:01:08 2026 +0800 feat: 新增日志、配置文件和环境变量 diff --git a/app.go b/app.go new file mode 100644 index 0000000..cb47e9e --- /dev/null +++ b/app.go @@ -0,0 +1,41 @@ +package go_web_gin + +import ( + "log" + + "git.hujye.com/infrastructure/go-web-gin/config" + "git.hujye.com/infrastructure/go-web-gin/server" + "github.com/gin-gonic/gin" +) + +// App represents the application +type App struct { + server *server.Server +} + +// New creates a new App instance and loads config file +func New() *App { + // Load config file + if _, err := config.Load(); err != nil { + log.Printf("Warning: failed to load config file, using defaults: %v", err) + } + + return &App{ + server: server.New(), + } +} + +// UseMiddleware registers global middleware +func (a *App) UseMiddleware(middleware ...gin.HandlerFunc) { + a.server.Engine().Use(middleware...) +} + +// RegisterRoutes registers routes with the given handler function +func (a *App) RegisterRoutes(registerFunc func(*gin.Engine)) { + registerFunc(a.server.Engine()) +} + +// Run starts the application server +func (a *App) Run(addr ...string) error { + return a.server.Run(addr...) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d663338 --- /dev/null +++ b/config/config.go @@ -0,0 +1,161 @@ +package config + +import ( + "fmt" + "strings" + + "git.hujye.com/infrastructure/go-web-gin/env" + "github.com/spf13/viper" +) + +var ( + globalConfig *Config + viperInstance *viper.Viper +) + +// Config represents the application configuration +type Config struct { + Server ServerConfig `mapstructure:"server"` + App AppConfig `mapstructure:"app"` + Log LogConfig `mapstructure:"log"` +} + +// ServerConfig represents server configuration +type ServerConfig struct { + Port int `mapstructure:"port"` + Host string `mapstructure:"host"` + Mode string `mapstructure:"mode"` // debug, release, test +} + +// AppConfig represents application configuration +type AppConfig struct { + Name string `mapstructure:"name"` + Environment string `mapstructure:"environment"` + LogLevel string `mapstructure:"log_level"` +} + +// LogConfig represents log configuration +type LogConfig struct { + OutputToFile bool `mapstructure:"output_to_file"` + Filename string `mapstructure:"filename"` + MaxSize int `mapstructure:"max_size"` + MaxBackups int `mapstructure:"max_backups"` + MaxAge int `mapstructure:"max_age"` + Compress bool `mapstructure:"compress"` +} + +// Load loads configuration from environment variable CFG_PATH +// Default path is config/config.yml +func Load() (*Config, error) { + v := viper.New() + v.SetConfigFile(env.GetCfgPath()) + v.SetConfigType("yaml") + + // Read environment variables + v.AutomaticEnv() + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + // Read config file + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + globalConfig = &cfg + viperInstance = v + return &cfg, nil +} + +// Get returns the global configuration +// Must call Load first +func Get() *Config { + if globalConfig == nil { + // Return default config if not loaded + return &Config{ + Server: ServerConfig{ + Port: 8080, + Host: "0.0.0.0", + Mode: "debug", + }, + App: AppConfig{ + Name: "go-web-gin", + Environment: string(env.Local), + LogLevel: "info", + }, + Log: LogConfig{ + OutputToFile: true, + Filename: "logs/app.log", + MaxSize: 100, + MaxBackups: 3, + MaxAge: 28, + Compress: true, + }, + } + } + return globalConfig +} + +// GetAddr returns the server address (host:port) +func (c *Config) GetAddr() string { + return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) +} + +// IsDebug returns true if server mode is debug +func (c *Config) IsDebug() bool { + return c.Server.Mode == "debug" +} + +// IsRelease returns true if server mode is release +func (c *Config) IsRelease() bool { + return c.Server.Mode == "release" +} + +// GetString returns a custom string config value by key +// Supports dot notation for nested keys, e.g. "database.host" +func GetString(key string) string { + if viperInstance != nil { + return viperInstance.GetString(key) + } + return "" +} + +// GetInt returns a custom int config value by key +func GetInt(key string) int { + if viperInstance != nil { + return viperInstance.GetInt(key) + } + return 0 +} + +// GetBool returns a custom bool config value by key +func GetBool(key string) bool { + if viperInstance != nil { + return viperInstance.GetBool(key) + } + return false +} + +// GetStringSlice returns a custom string slice config value by key +func GetStringSlice(key string) []string { + if viperInstance != nil { + return viperInstance.GetStringSlice(key) + } + return nil +} + +// GetValue returns a custom config value of any type by key +func GetValue(key string) any { + if viperInstance != nil { + return viperInstance.Get(key) + } + return nil +} + +// Viper returns the underlying viper instance for advanced usage +func Viper() *viper.Viper { + return viperInstance +} diff --git a/env/env.go b/env/env.go new file mode 100644 index 0000000..e6a4ae2 --- /dev/null +++ b/env/env.go @@ -0,0 +1,77 @@ +package env + +import ( + "os" + "strings" +) + +// RunEnv represents the runtime environment +type RunEnv string + +const ( + // Local environment for local development + Local RunEnv = "LOCAL" + // Development environment for development/testing + Development RunEnv = "DEVELOPMENT" + // Production environment for production use + Production RunEnv = "PRODUCTION" + + // RunEnvKey is the environment variable key for run environment + RunEnvKey = "RUN_ENV" + + // CfgPathKey is the environment variable key for config file path + CfgPathKey = "CFG_PATH" + + // DefaultCfgPath is the default config file path + DefaultCfgPath = "config/config.yml" +) + +// GetRunEnv returns the current run environment from env variable +// Defaults to Local if not set or invalid +func GetRunEnv() RunEnv { + env := os.Getenv(RunEnvKey) + switch strings.ToUpper(env) { + case string(Development): + return Development + case string(Production): + return Production + default: + return Local + } +} + +// Get returns the environment variable value for the given key +// Returns empty string if not set +func Get(key string) string { + return os.Getenv(key) +} + +// GetWithDefault returns the environment variable value for the given key +// Returns defaultValue if not set +func GetWithDefault(key, defaultValue string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultValue +} + +// IsLocal returns true if current environment is Local +func IsLocal() bool { + return GetRunEnv() == Local +} + +// IsDevelopment returns true if current environment is Development +func IsDevelopment() bool { + return GetRunEnv() == Development +} + +// IsProduction returns true if current environment is Production +func IsProduction() bool { + return GetRunEnv() == Production +} + +// GetCfgPath returns the config file path from env variable +// Returns DefaultCfgPath if not set +func GetCfgPath() string { + return GetWithDefault(CfgPathKey, DefaultCfgPath) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ee6bac6 --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module git.hujye.com/infrastructure/go-web-gin + +go 1.24.0 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/spf13/viper v1.18.2 + github.com/stretchr/testify v1.9.0 + go.uber.org/zap v1.27.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/logger/encoder.go b/logger/encoder.go new file mode 100644 index 0000000..330de58 --- /dev/null +++ b/logger/encoder.go @@ -0,0 +1,92 @@ +package logger + +import ( + "go.uber.org/zap/zapcore" +) + +// EncoderType represents the log encoder type +type EncoderType string + +const ( + // JSONEncoder encodes logs as JSON + JSONEncoder EncoderType = "json" + // ConsoleEncoder encodes logs as human-readable text + ConsoleEncoder EncoderType = "console" +) + +// NewEncoderConfig returns a default encoder config +func NewEncoderConfig() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + FunctionKey: zapcore.OmitKey, + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.LowercaseLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.SecondsDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } +} + +// newConsoleEncoderConfig returns a colored encoder config for console output +func newConsoleEncoderConfig() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + TimeKey: "time", + LevelKey: "level", + NameKey: "logger", + CallerKey: "caller", + FunctionKey: zapcore.OmitKey, + MessageKey: "msg", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: CustomLevelEncoder, // 自定义带颜色的级别编码 + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.SecondsDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } +} + +// NewJSONEncoder creates a new JSON encoder +func NewJSONEncoder() zapcore.Encoder { + return zapcore.NewJSONEncoder(NewEncoderConfig()) +} + +// NewConsoleEncoder creates a new console encoder with colors +func NewConsoleEncoder() zapcore.Encoder { + return zapcore.NewConsoleEncoder(newConsoleEncoderConfig()) +} + +// NewEncoder creates an encoder based on the given type +// Defaults to console encoder if type is invalid +func NewEncoder(encoderType EncoderType) zapcore.Encoder { + switch encoderType { + case JSONEncoder: + return NewJSONEncoder() + case ConsoleEncoder: + return NewConsoleEncoder() + default: + return NewConsoleEncoder() + } +} + +// CustomLevelEncoder creates a custom level encoder with colors and styles +func CustomLevelEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) { + switch l { + case zapcore.DebugLevel: + enc.AppendString("\x1b[37m" + l.CapitalString() + "\x1b[0m") // 白色 + case zapcore.InfoLevel: + enc.AppendString("\x1b[32m" + l.CapitalString() + "\x1b[0m") // 绿色 + case zapcore.WarnLevel: + enc.AppendString("\x1b[33m" + l.CapitalString() + "\x1b[0m") // 黄色 + case zapcore.ErrorLevel: + enc.AppendString("\x1b[31m" + l.CapitalString() + "\x1b[0m") // 红色 + case zapcore.FatalLevel: + enc.AppendString("\x1b[35m" + l.CapitalString() + "\x1b[0m") // 紫色 + default: + enc.AppendString(l.CapitalString()) + } +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..6552176 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,180 @@ +package logger + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "git.hujye.com/infrastructure/go-web-gin/config" + "git.hujye.com/infrastructure/go-web-gin/env" + "git.hujye.com/infrastructure/go-web-gin/web" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +type Logger struct { + logger *zap.Logger +} + +var ( + instance *Logger + once sync.Once +) + +// parseLogLevel converts string log level to zapcore.Level +func parseLogLevel(level string) zapcore.Level { + switch strings.ToLower(level) { + case "debug": + return zapcore.DebugLevel + case "info": + return zapcore.InfoLevel + case "warn", "warning": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + case "fatal": + return zapcore.FatalLevel + default: + return zapcore.InfoLevel + } +} + +// GetLogger returns the singleton Logger instance +// Logger configuration is based on RUN_ENV environment variable and config file +func GetLogger() *Logger { + once.Do(func() { + runEnv := env.GetRunEnv() + cfg := config.Get() + + // Production and Development use JSON encoder + // Local uses console encoder for better readability + var encoderType EncoderType + if runEnv == env.Production || runEnv == env.Development { + encoderType = JSONEncoder + } else { + encoderType = ConsoleEncoder + } + + // Create encoder + encoder := NewEncoder(encoderType) + + // Create writer syncs (console + file if enabled) + var writerSyncs zapcore.WriteSyncer + if cfg.Log.OutputToFile { + // Create rotating file writer with lumberjack + fileWriter := &lumberjack.Logger{ + Filename: cfg.Log.Filename, + MaxSize: cfg.Log.MaxSize, + MaxBackups: cfg.Log.MaxBackups, + MaxAge: cfg.Log.MaxAge, + Compress: cfg.Log.Compress, + } + // Write to both console and file + writerSyncs = zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stderr), zapcore.AddSync(fileWriter)) + } else { + // Write to console only + writerSyncs = zapcore.AddSync(os.Stderr) + } + + // Parse log level from config + logLevel := parseLogLevel(cfg.App.LogLevel) + + // Create core with custom encoder + core := zapcore.NewCore( + encoder, + writerSyncs, + logLevel, + ) + + logger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1), zap.AddStacktrace(zapcore.ErrorLevel)) + + instance = &Logger{ + logger: logger, + } + }) + return instance +} + +// getUserFields extracts user information from context and returns zap fields +func getUserFields(ctx context.Context) []zap.Field { + if ctx == nil { + return nil + } + + fields := []zap.Field{} + if userID := web.GetUserID(ctx); userID != "" { + fields = append(fields, zap.String("user_id", userID)) + } + if userName := web.GetUserName(ctx); userName != "" { + fields = append(fields, zap.String("user_name", userName)) + } + if trace := web.GetTrace(ctx); trace != "" { + fields = append(fields, zap.String("trace", trace)) + } + if fromIP := web.GetFromIP(ctx); fromIP != "" { + fields = append(fields, zap.String("from_ip", fromIP)) + } + + if len(fields) == 0 { + return nil + } + return fields +} + +// Info logs an info message +func (l *Logger) Info(ctx context.Context, msg string, values ...interface{}) { + allFields := getUserFields(ctx) + formattedMsg := fmt.Sprintf(msg, values...) + if len(values) > 0 { + allFields = append(allFields, zap.String("msg", formattedMsg)) + } + l.logger.Info(formattedMsg, allFields...) +} + +// Error logs an error message +func (l *Logger) Error(ctx context.Context, msg string, values ...interface{}) { + allFields := getUserFields(ctx) + formattedMsg := fmt.Sprintf(msg, values...) + if len(values) > 0 { + allFields = append(allFields, zap.String("msg", formattedMsg)) + } + l.logger.Error(formattedMsg, allFields...) +} + +// Debug logs a debug message +func (l *Logger) Debug(ctx context.Context, msg string, values ...interface{}) { + allFields := getUserFields(ctx) + formattedMsg := fmt.Sprintf(msg, values...) + if len(values) > 0 { + allFields = append(allFields, zap.String("msg", formattedMsg)) + } + l.logger.Debug(formattedMsg, allFields...) +} + +// Warn logs a warning message +func (l *Logger) Warn(ctx context.Context, msg string, values ...interface{}) { + allFields := getUserFields(ctx) + formattedMsg := fmt.Sprintf(msg, values...) + if len(values) > 0 { + allFields = append(allFields, zap.String("msg", formattedMsg)) + } + l.logger.Warn(formattedMsg, allFields...) +} + +// Fatal logs a fatal message and exits +func (l *Logger) Fatal(ctx context.Context, msg string, values ...interface{}) { + allFields := getUserFields(ctx) + formattedMsg := fmt.Sprintf(msg, values...) + if len(values) > 0 { + allFields = append(allFields, zap.String("msg", formattedMsg)) + } + l.logger.Fatal(formattedMsg, allFields...) +} + +// Sync flushes any buffered log entries +func (l *Logger) Sync() error { + return l.logger.Sync() +} diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 0000000..9056be4 --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,166 @@ +package logger + +import ( + "context" + "testing" + + "git.hujye.com/infrastructure/go-web-gin/web" + "github.com/stretchr/testify/suite" +) + +// LoggerTestSuite is the test suite for Logger +type LoggerTestSuite struct { + suite.Suite + logger *Logger +} + +// SetupSuite runs once before all tests +func (s *LoggerTestSuite) SetupSuite() { + s.logger = GetLogger() +} + +// TearDownSuite runs once after all tests +func (s *LoggerTestSuite) TearDownSuite() { + s.logger.Sync() +} + +// SetupTest runs before each test +func (s *LoggerTestSuite) SetupTest() { + // Reset logger instance if needed +} + +// TearDownTest runs after each test +func (s *LoggerTestSuite) TearDownTest() { + // Cleanup after each test if needed +} + +// TestGetLogger tests the GetLogger function +func (s *LoggerTestSuite) TestGetLogger() { + logger := GetLogger() + s.NotNil(logger, "GetLogger should return a non-nil logger") + s.NotNil(logger.logger, "logger field should be initialized") +} + +// TestGetLoggerSingleton tests that GetLogger returns the same instance +func (s *LoggerTestSuite) TestGetLoggerSingleton() { + logger1 := GetLogger() + logger2 := GetLogger() + s.Equal(logger1, logger2, "GetLogger should return the same instance") +} + +// TestInfoWithNilContext tests Info method with nil context +func (s *LoggerTestSuite) TestInfoWithNilContext() { + s.NotPanics(func() { + s.logger.Info(nil, "Test info message") + }, "Info should not panic with nil context") +} + +// TestErrorWithNilContext tests Error method with nil context +func (s *LoggerTestSuite) TestErrorWithNilContext() { + s.NotPanics(func() { + s.logger.Error(nil, "Test error message") + }, "Error should not panic with nil context") +} + +// TestDebugWithNilContext tests Debug method with nil context +func (s *LoggerTestSuite) TestDebugWithNilContext() { + s.NotPanics(func() { + s.logger.Debug(nil, "Test debug message") + }, "Debug should not panic with nil context") +} + +// TestWarnWithNilContext tests Warn method with nil context +func (s *LoggerTestSuite) TestWarnWithNilContext() { + s.NotPanics(func() { + s.logger.Warn(nil, "Test warn message") + }, "Warn should not panic with nil context") +} + +// TestInfoWithContext tests Info method with context containing user info +func (s *LoggerTestSuite) TestInfoWithContext() { + ctx := context.Background() + ctx = web.SetUserID(ctx, "test-user-123") + ctx = web.SetUserName(ctx, "testuser") + ctx = web.SetTrace(ctx, "trace-test-456") + ctx = web.SetFromIP(ctx, "192.168.1.1") + + s.NotPanics(func() { + s.logger.Info(ctx, "Test info with context") + }, "Info should not panic with valid context") +} + +// TestErrorWithContext tests Error method with context containing user info +func (s *LoggerTestSuite) TestErrorWithContext() { + ctx := context.Background() + ctx = web.SetUserID(ctx, "error-user-789") + + s.NotPanics(func() { + s.logger.Error(ctx, "Test error with context") + }, "Error should not panic with valid context") +} + +// TestInfoWithFormattedMessage tests Info method with formatted message +func (s *LoggerTestSuite) TestInfoWithFormattedMessage() { + ctx := context.Background() + ctx = web.SetUserID(ctx, "format-user-001") + + s.NotPanics(func() { + s.logger.Info(ctx, "User %s logged in from %s", "admin", "10.0.0.1") + }, "Info should handle formatted messages correctly") +} + +// TestErrorWithFormattedMessage tests Error method with formatted message +func (s *LoggerTestSuite) TestErrorWithFormattedMessage() { + s.NotPanics(func() { + s.logger.Error(nil, "Error code: %d, message: %s", 500, "Internal Server Error") + }, "Error should handle formatted messages correctly") +} + +// TestAllLogLevels tests all log level methods +func (s *LoggerTestSuite) TestAllLogLevels() { + ctx := context.Background() + ctx = web.SetUserID(ctx, "level-test-user") + ctx = web.SetUserName(ctx, "leveltest") + ctx = web.SetTrace(ctx, "trace-level-test") + ctx = web.SetFromIP(ctx, "10.20.30.40") + + s.NotPanics(func() { + s.logger.Debug(ctx, "Debug message") + s.logger.Info(ctx, "Info message") + s.logger.Warn(ctx, "Warn message") + s.logger.Error(ctx, "Error message") + // Skip Fatal as it will exit the program + }, "All log levels should work correctly") +} + +// TestEmptyContext tests with empty context (no user info) +func (s *LoggerTestSuite) TestEmptyContext() { + ctx := context.Background() + + s.NotPanics(func() { + s.logger.Info(ctx, "Message with empty context") + }, "Should handle empty context without user info") +} + +// TestPartialUserInfo tests with partial user information +func (s *LoggerTestSuite) TestPartialUserInfo() { + ctx := context.Background() + // Only set user ID, not other fields + ctx = web.SetUserID(ctx, "partial-user") + + s.NotPanics(func() { + s.logger.Info(ctx, "Message with partial user info") + }, "Should handle partial user information") +} + +// TestLoggerSync tests the Sync method +func (s *LoggerTestSuite) TestLoggerSync() { + err := s.logger.Sync() + // Sync may return an error for stderr in test environment, which is acceptable + s.T().Logf("Sync returned error (may be expected for stderr): %v", err) +} + +// TestLoggerRunSuite runs the test suite +func TestLoggerRunSuite(t *testing.T) { + suite.Run(t, new(LoggerTestSuite)) +} diff --git a/logger/struct.go b/logger/struct.go new file mode 100644 index 0000000..1a6bf6b --- /dev/null +++ b/logger/struct.go @@ -0,0 +1,74 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// LogMsg represents the standard log message structure +type LogMsg struct { + UserID string `json:"user_id"` + UserName string `json:"user_name"` + TraceID string `json:"trace"` + IP string `json:"ip"` + Msg string `json:"msg"` +} + +// MarshalLogObject implements zapcore.ObjectMarshaler interface +// This allows the encoder to properly encode LogMsg struct +func (l LogMsg) MarshalLogObject(enc zapcore.ObjectEncoder) error { + enc.AddString("user_id", l.UserID) + enc.AddString("user_name", l.UserName) + enc.AddString("trace", l.TraceID) + enc.AddString("ip", l.IP) + enc.AddString("msg", l.Msg) + return nil +} + +// ToZapFields converts LogMsg to zap.Field slice for structured logging +func (l *LogMsg) ToZapFields() []zap.Field { + return []zap.Field{ + zap.String("user_id", l.UserID), + zap.String("user_name", l.UserName), + zap.String("trace", l.TraceID), + zap.String("ip", l.IP), + zap.String("msg", l.Msg), + } +} + +// NewLogMsg creates a new LogMsg instance +func NewLogMsg(msg string) *LogMsg { + return &LogMsg{ + Msg: msg, + } +} + +// WithUserID sets the user ID +func (l *LogMsg) WithUserID(userID string) *LogMsg { + l.UserID = userID + return l +} + +// WithUserName sets the user name +func (l *LogMsg) WithUserName(userName string) *LogMsg { + l.UserName = userName + return l +} + +// WithTraceID sets the trace ID +func (l *LogMsg) WithTraceID(traceID string) *LogMsg { + l.TraceID = traceID + return l +} + +// WithIP sets the IP address +func (l *LogMsg) WithIP(ip string) *LogMsg { + l.IP = ip + return l +} + +// WithMsg sets the message +func (l *LogMsg) WithMsg(msg string) *LogMsg { + l.Msg = msg + return l +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..943ef57 --- /dev/null +++ b/server/server.go @@ -0,0 +1,25 @@ +package server + +import "github.com/gin-gonic/gin" + +// Server represents the HTTP server +type Server struct { + engine *gin.Engine +} + +// New creates a new Server instance +func New() *Server { + return &Server{ + engine: gin.Default(), + } +} + +// Engine returns the gin engine +func (s *Server) Engine() *gin.Engine { + return s.engine +} + +// Run starts the server +func (s *Server) Run(addr ...string) error { + return s.engine.Run(addr...) +} diff --git a/web/user.go b/web/user.go new file mode 100644 index 0000000..a667a9f --- /dev/null +++ b/web/user.go @@ -0,0 +1,213 @@ +package web + +import ( + "context" + "time" +) + +// UserInfo represents user information with tracking details +type UserInfo struct { + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Trace string `json:"trace"` + VisitTime time.Time `json:"visit_time"` + FromIP string `json:"from_ip"` +} + +// contextKey is the type for context keys to prevent collisions +type contextKey string + +const ( + // Context keys for each field + UserIDKey contextKey = "userID" + UserNameKey contextKey = "userName" + TraceKey contextKey = "trace" + VisitTimeKey contextKey = "visitTime" + FromIPKey contextKey = "fromIP" +) + +// GetUserID returns user ID from context +func GetUserID(ctx context.Context) string { + if ctx == nil { + return "" + } + if val, ok := ctx.Value(UserIDKey).(string); ok { + return val + } + return "" +} + +// SetUserID sets user ID in context and returns new context +func SetUserID(ctx context.Context, userID string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, UserIDKey, userID) +} + +// GetUserName returns user name from context +func GetUserName(ctx context.Context) string { + if ctx == nil { + return "" + } + if val, ok := ctx.Value(UserNameKey).(string); ok { + return val + } + return "" +} + +// SetUserName sets user name in context and returns new context +func SetUserName(ctx context.Context, userName string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, UserNameKey, userName) +} + +// GetTrace returns trace ID from context +func GetTrace(ctx context.Context) string { + if ctx == nil { + return "" + } + if val, ok := ctx.Value(TraceKey).(string); ok { + return val + } + return "" +} + +// SetTrace sets trace ID in context and returns new context +func SetTrace(ctx context.Context, trace string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, TraceKey, trace) +} + +// GetVisitTime returns visit time from context +func GetVisitTime(ctx context.Context) time.Time { + if ctx == nil { + return time.Time{} + } + if val, ok := ctx.Value(VisitTimeKey).(time.Time); ok { + return val + } + return time.Time{} +} + +// SetVisitTime sets visit time in context and returns new context +func SetVisitTime(ctx context.Context, visitTime time.Time) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, VisitTimeKey, visitTime) +} + +// GetFromIP returns from IP from context +func GetFromIP(ctx context.Context) string { + if ctx == nil { + return "" + } + if val, ok := ctx.Value(FromIPKey).(string); ok { + return val + } + return "" +} + +// SetFromIP sets from IP in context and returns new context +func SetFromIP(ctx context.Context, fromIP string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, FromIPKey, fromIP) +} + +// FromContext builds UserInfo from individual context fields +// Returns nil if no fields are found +func FromContext(ctx context.Context) *UserInfo { + if ctx == nil { + return nil + } + info := &UserInfo{} + hasData := false + + if userID := GetUserID(ctx); userID != "" { + info.UserID = userID + hasData = true + } + if userName := GetUserName(ctx); userName != "" { + info.UserName = userName + hasData = true + } + if trace := GetTrace(ctx); trace != "" { + info.Trace = trace + hasData = true + } + if visitTime := GetVisitTime(ctx); !visitTime.IsZero() { + info.VisitTime = visitTime + hasData = true + } + if fromIP := GetFromIP(ctx); fromIP != "" { + info.FromIP = fromIP + hasData = true + } + + if !hasData { + return nil + } + return info +} + +// ToContext sets all UserInfo fields to context and returns new context +func ToContext(ctx context.Context, userInfo *UserInfo) context.Context { + if ctx == nil { + ctx = context.Background() + } + if userInfo == nil { + return ctx + } + + ctx = SetUserID(ctx, userInfo.UserID) + ctx = SetUserName(ctx, userInfo.UserName) + ctx = SetTrace(ctx, userInfo.Trace) + ctx = SetVisitTime(ctx, userInfo.VisitTime) + ctx = SetFromIP(ctx, userInfo.FromIP) + + return ctx +} + +// NewUserInfo creates a new UserInfo instance +func NewUserInfo() *UserInfo { + return &UserInfo{ + VisitTime: time.Now(), + } +} + +// WithUserID sets the user ID +func (u *UserInfo) WithUserID(userID string) *UserInfo { + u.UserID = userID + return u +} + +// WithUserName sets the user name +func (u *UserInfo) WithUserName(userName string) *UserInfo { + u.UserName = userName + return u +} + +// WithTrace sets the trace ID +func (u *UserInfo) WithTrace(trace string) *UserInfo { + u.Trace = trace + return u +} + +// WithFromIP sets the from IP address +func (u *UserInfo) WithFromIP(ip string) *UserInfo { + u.FromIP = ip + return u +} + +// WithVisitTime sets the visit time +func (u *UserInfo) WithVisitTime(visitTime time.Time) *UserInfo { + u.VisitTime = visitTime + return u +}