diff --git a/go-backend/.env.example b/go-backend/.env.example new file mode 100644 index 00000000..ab9465cf --- /dev/null +++ b/go-backend/.env.example @@ -0,0 +1,24 @@ +# Server Configuration +SERVER_PORT=8888 +GIN_MODE=release + +# Database Configuration (PostgreSQL) +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=your_password +DB_NAME=xingrin +DB_SSLMODE=disable +DB_MAX_OPEN_CONNS=25 +DB_MAX_IDLE_CONNS=5 +DB_CONN_MAX_LIFETIME=300 + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Logging Configuration +LOG_LEVEL=info +LOG_FORMAT=json diff --git a/go-backend/Makefile b/go-backend/Makefile new file mode 100644 index 00000000..066b9590 --- /dev/null +++ b/go-backend/Makefile @@ -0,0 +1,40 @@ +.PHONY: build run test lint clean + +# Build the server binary +build: + go build -o bin/server ./cmd/server + +# Run the server +run: + go run ./cmd/server + +# Run tests +test: + go test -v ./... + +# Run tests with coverage +test-coverage: + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Run linter +lint: + golangci-lint run ./... + +# Clean build artifacts +clean: + rm -rf bin/ + rm -f coverage.out coverage.html + +# Download dependencies +deps: + go mod download + go mod tidy + +# Format code +fmt: + go fmt ./... + +# Generate mocks (if needed) +generate: + go generate ./... diff --git a/go-backend/bin/server b/go-backend/bin/server new file mode 100755 index 00000000..1488bfcd Binary files /dev/null and b/go-backend/bin/server differ diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go new file mode 100644 index 00000000..913e6cfa --- /dev/null +++ b/go-backend/cmd/server/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" + "github.com/xingrin/go-backend/internal/config" + "github.com/xingrin/go-backend/internal/database" + "github.com/xingrin/go-backend/internal/handler" + "github.com/xingrin/go-backend/internal/middleware" + "github.com/xingrin/go-backend/internal/pkg" + "go.uber.org/zap" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + fmt.Printf("Failed to load config: %v\n", err) + os.Exit(1) + } + + // Initialize logger + if err := pkg.InitLogger(&pkg.LogConfig{ + Level: cfg.Log.Level, + Format: cfg.Log.Format, + }); err != nil { + fmt.Printf("Failed to initialize logger: %v\n", err) + os.Exit(1) + } + defer pkg.Sync() + + pkg.Info("Starting server", + zap.Int("port", cfg.Server.Port), + zap.String("mode", cfg.Server.Mode), + ) + + // Initialize database + db, err := database.NewDatabase(&cfg.Database) + if err != nil { + pkg.Fatal("Failed to connect to database", zap.Error(err)) + } + pkg.Info("Database connected", + zap.String("host", cfg.Database.Host), + zap.Int("port", cfg.Database.Port), + zap.String("name", cfg.Database.Name), + ) + + // Initialize Redis (optional) + var redisClient *redis.Client + if cfg.Redis.Host != "" { + redisClient = redis.NewClient(&redis.Options{ + Addr: cfg.Redis.Addr(), + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := redisClient.Ping(ctx).Err(); err != nil { + pkg.Warn("Failed to connect to Redis, continuing without Redis", + zap.Error(err), + ) + redisClient = nil + } else { + pkg.Info("Redis connected", + zap.String("addr", cfg.Redis.Addr()), + ) + } + } + + + // Set Gin mode + gin.SetMode(cfg.Server.Mode) + + // Create Gin router + router := gin.New() + + // Add middleware + router.Use(middleware.Recovery()) + router.Use(middleware.Logger()) + + // Create handlers + healthHandler := handler.NewHealthHandler(db, redisClient) + + // Register routes + router.GET("/health", healthHandler.Check) + router.GET("/health/live", healthHandler.Liveness) + router.GET("/health/ready", healthHandler.Readiness) + + // Create HTTP server + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Server.Port), + Handler: router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + pkg.Info("Server listening", zap.String("addr", srv.Addr)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + pkg.Fatal("Failed to start server", zap.Error(err)) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + pkg.Info("Shutting down server...") + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + pkg.Error("Server forced to shutdown", zap.Error(err)) + } + + // Close database connection + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + + // Close Redis connection + if redisClient != nil { + redisClient.Close() + } + + pkg.Info("Server exited") +} diff --git a/go-backend/go.mod b/go-backend/go.mod new file mode 100644 index 00000000..8c4b2c06 --- /dev/null +++ b/go-backend/go.mod @@ -0,0 +1,66 @@ +module github.com/xingrin/go-backend + +go 1.22 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/go-redis/redis/v8 v8.11.5 + github.com/google/uuid v1.5.0 + github.com/lib/pq v1.10.9 + github.com/spf13/viper v1.18.2 + go.uber.org/zap v1.26.0 + gorm.io/datatypes v1.2.0 + gorm.io/driver/postgres v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // 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.14.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.19 // 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.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shopspring/decimal v1.4.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.11 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/mysql v1.4.7 // indirect +) diff --git a/go-backend/go.sum b/go-backend/go.sum new file mode 100644 index 00000000..9989409d --- /dev/null +++ b/go-backend/go.sum @@ -0,0 +1,187 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= +github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco= +gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04= +gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y= +gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= +gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= +gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig= +gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go new file mode 100644 index 00000000..949fc977 --- /dev/null +++ b/go-backend/internal/config/config.go @@ -0,0 +1,163 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +// Config holds all configuration for the application +type Config struct { + Server ServerConfig + Database DatabaseConfig + Redis RedisConfig + Log LogConfig +} + +// ServerConfig holds server-related configuration +type ServerConfig struct { + Port int `mapstructure:"SERVER_PORT"` + Mode string `mapstructure:"GIN_MODE"` +} + +// DatabaseConfig holds database-related configuration +type DatabaseConfig struct { + Host string `mapstructure:"DB_HOST"` + Port int `mapstructure:"DB_PORT"` + User string `mapstructure:"DB_USER"` + Password string `mapstructure:"DB_PASSWORD"` + Name string `mapstructure:"DB_NAME"` + SSLMode string `mapstructure:"DB_SSLMODE"` + MaxOpenConns int `mapstructure:"DB_MAX_OPEN_CONNS"` + MaxIdleConns int `mapstructure:"DB_MAX_IDLE_CONNS"` + ConnMaxLifetime int `mapstructure:"DB_CONN_MAX_LIFETIME"` +} + +// RedisConfig holds Redis-related configuration +type RedisConfig struct { + Host string `mapstructure:"REDIS_HOST"` + Port int `mapstructure:"REDIS_PORT"` + Password string `mapstructure:"REDIS_PASSWORD"` + DB int `mapstructure:"REDIS_DB"` +} + +// LogConfig holds logging-related configuration +type LogConfig struct { + Level string `mapstructure:"LOG_LEVEL"` + Format string `mapstructure:"LOG_FORMAT"` +} + + +// Load reads configuration from environment variables +func Load() (*Config, error) { + v := viper.New() + + // Set default values + setDefaults(v) + + // Read from environment variables + v.AutomaticEnv() + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + cfg := &Config{} + + // Server config + cfg.Server.Port = v.GetInt("SERVER_PORT") + cfg.Server.Mode = v.GetString("GIN_MODE") + + // Database config + cfg.Database.Host = v.GetString("DB_HOST") + cfg.Database.Port = v.GetInt("DB_PORT") + cfg.Database.User = v.GetString("DB_USER") + cfg.Database.Password = v.GetString("DB_PASSWORD") + cfg.Database.Name = v.GetString("DB_NAME") + cfg.Database.SSLMode = v.GetString("DB_SSLMODE") + cfg.Database.MaxOpenConns = v.GetInt("DB_MAX_OPEN_CONNS") + cfg.Database.MaxIdleConns = v.GetInt("DB_MAX_IDLE_CONNS") + cfg.Database.ConnMaxLifetime = v.GetInt("DB_CONN_MAX_LIFETIME") + + // Redis config + cfg.Redis.Host = v.GetString("REDIS_HOST") + cfg.Redis.Port = v.GetInt("REDIS_PORT") + cfg.Redis.Password = v.GetString("REDIS_PASSWORD") + cfg.Redis.DB = v.GetInt("REDIS_DB") + + // Log config + cfg.Log.Level = v.GetString("LOG_LEVEL") + cfg.Log.Format = v.GetString("LOG_FORMAT") + + return cfg, nil +} + +// setDefaults sets default values for configuration +func setDefaults(v *viper.Viper) { + // Server defaults + v.SetDefault("SERVER_PORT", 8888) + v.SetDefault("GIN_MODE", "release") + + // Database defaults + v.SetDefault("DB_HOST", "localhost") + v.SetDefault("DB_PORT", 5432) + v.SetDefault("DB_USER", "postgres") + v.SetDefault("DB_PASSWORD", "") + v.SetDefault("DB_NAME", "xingrin") + v.SetDefault("DB_SSLMODE", "disable") + v.SetDefault("DB_MAX_OPEN_CONNS", 25) + v.SetDefault("DB_MAX_IDLE_CONNS", 5) + v.SetDefault("DB_CONN_MAX_LIFETIME", 300) + + // Redis defaults + v.SetDefault("REDIS_HOST", "localhost") + v.SetDefault("REDIS_PORT", 6379) + v.SetDefault("REDIS_PASSWORD", "") + v.SetDefault("REDIS_DB", 0) + + // Log defaults + v.SetDefault("LOG_LEVEL", "info") + v.SetDefault("LOG_FORMAT", "json") +} + +// DSN returns the database connection string +func (c *DatabaseConfig) DSN() string { + return fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + c.Host, c.Port, c.User, c.Password, c.Name, c.SSLMode, + ) +} + +// RedisAddr returns the Redis address +func (c *RedisConfig) Addr() string { + return fmt.Sprintf("%s:%d", c.Host, c.Port) +} + +// GetDefaults returns a Config with all default values (for testing) +func GetDefaults() *Config { + return &Config{ + Server: ServerConfig{ + Port: 8888, + Mode: "release", + }, + Database: DatabaseConfig{ + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "", + Name: "xingrin", + SSLMode: "disable", + MaxOpenConns: 25, + MaxIdleConns: 5, + ConnMaxLifetime: 300, + }, + Redis: RedisConfig{ + Host: "localhost", + Port: 6379, + Password: "", + DB: 0, + }, + Log: LogConfig{ + Level: "info", + Format: "json", + }, + } +} diff --git a/go-backend/internal/config/config_test.go b/go-backend/internal/config/config_test.go new file mode 100644 index 00000000..7173c360 --- /dev/null +++ b/go-backend/internal/config/config_test.go @@ -0,0 +1,147 @@ +package config + +import ( + "os" + "testing" +) + +// TestConfigDefaults tests that default values are correctly set +// Property 4: 配置默认值正确性 +// *对于任意* 缺失的环境变量,配置系统应返回预定义的默认值。 +// **验证: 需求 2.4** +func TestConfigDefaults(t *testing.T) { + // Clear all relevant environment variables + envVars := []string{ + "SERVER_PORT", "GIN_MODE", + "DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME", "DB_SSLMODE", + "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME", + "REDIS_HOST", "REDIS_PORT", "REDIS_PASSWORD", "REDIS_DB", + "LOG_LEVEL", "LOG_FORMAT", + } + for _, env := range envVars { + os.Unsetenv(env) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + defaults := GetDefaults() + + // Test Server defaults + if cfg.Server.Port != defaults.Server.Port { + t.Errorf("Server.Port: expected %d, got %d", defaults.Server.Port, cfg.Server.Port) + } + if cfg.Server.Mode != defaults.Server.Mode { + t.Errorf("Server.Mode: expected %s, got %s", defaults.Server.Mode, cfg.Server.Mode) + } + + // Test Database defaults + if cfg.Database.Host != defaults.Database.Host { + t.Errorf("Database.Host: expected %s, got %s", defaults.Database.Host, cfg.Database.Host) + } + if cfg.Database.Port != defaults.Database.Port { + t.Errorf("Database.Port: expected %d, got %d", defaults.Database.Port, cfg.Database.Port) + } + if cfg.Database.User != defaults.Database.User { + t.Errorf("Database.User: expected %s, got %s", defaults.Database.User, cfg.Database.User) + } + if cfg.Database.Name != defaults.Database.Name { + t.Errorf("Database.Name: expected %s, got %s", defaults.Database.Name, cfg.Database.Name) + } + if cfg.Database.SSLMode != defaults.Database.SSLMode { + t.Errorf("Database.SSLMode: expected %s, got %s", defaults.Database.SSLMode, cfg.Database.SSLMode) + } + if cfg.Database.MaxOpenConns != defaults.Database.MaxOpenConns { + t.Errorf("Database.MaxOpenConns: expected %d, got %d", defaults.Database.MaxOpenConns, cfg.Database.MaxOpenConns) + } + if cfg.Database.MaxIdleConns != defaults.Database.MaxIdleConns { + t.Errorf("Database.MaxIdleConns: expected %d, got %d", defaults.Database.MaxIdleConns, cfg.Database.MaxIdleConns) + } + if cfg.Database.ConnMaxLifetime != defaults.Database.ConnMaxLifetime { + t.Errorf("Database.ConnMaxLifetime: expected %d, got %d", defaults.Database.ConnMaxLifetime, cfg.Database.ConnMaxLifetime) + } + + // Test Redis defaults + if cfg.Redis.Host != defaults.Redis.Host { + t.Errorf("Redis.Host: expected %s, got %s", defaults.Redis.Host, cfg.Redis.Host) + } + if cfg.Redis.Port != defaults.Redis.Port { + t.Errorf("Redis.Port: expected %d, got %d", defaults.Redis.Port, cfg.Redis.Port) + } + if cfg.Redis.DB != defaults.Redis.DB { + t.Errorf("Redis.DB: expected %d, got %d", defaults.Redis.DB, cfg.Redis.DB) + } + + // Test Log defaults + if cfg.Log.Level != defaults.Log.Level { + t.Errorf("Log.Level: expected %s, got %s", defaults.Log.Level, cfg.Log.Level) + } + if cfg.Log.Format != defaults.Log.Format { + t.Errorf("Log.Format: expected %s, got %s", defaults.Log.Format, cfg.Log.Format) + } +} + +// TestConfigFromEnv tests that environment variables override defaults +func TestConfigFromEnv(t *testing.T) { + // Set custom environment variables + os.Setenv("SERVER_PORT", "9999") + os.Setenv("DB_HOST", "custom-host") + os.Setenv("DB_PORT", "5433") + os.Setenv("LOG_LEVEL", "debug") + defer func() { + os.Unsetenv("SERVER_PORT") + os.Unsetenv("DB_HOST") + os.Unsetenv("DB_PORT") + os.Unsetenv("LOG_LEVEL") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Server.Port != 9999 { + t.Errorf("Server.Port: expected 9999, got %d", cfg.Server.Port) + } + if cfg.Database.Host != "custom-host" { + t.Errorf("Database.Host: expected custom-host, got %s", cfg.Database.Host) + } + if cfg.Database.Port != 5433 { + t.Errorf("Database.Port: expected 5433, got %d", cfg.Database.Port) + } + if cfg.Log.Level != "debug" { + t.Errorf("Log.Level: expected debug, got %s", cfg.Log.Level) + } +} + +// TestDatabaseDSN tests the DSN generation +func TestDatabaseDSN(t *testing.T) { + cfg := &DatabaseConfig{ + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "secret", + Name: "testdb", + SSLMode: "disable", + } + + expected := "host=localhost port=5432 user=postgres password=secret dbname=testdb sslmode=disable" + if cfg.DSN() != expected { + t.Errorf("DSN: expected %s, got %s", expected, cfg.DSN()) + } +} + +// TestRedisAddr tests the Redis address generation +func TestRedisAddr(t *testing.T) { + cfg := &RedisConfig{ + Host: "localhost", + Port: 6379, + } + + expected := "localhost:6379" + if cfg.Addr() != expected { + t.Errorf("Addr: expected %s, got %s", expected, cfg.Addr()) + } +} diff --git a/go-backend/internal/database/database.go b/go-backend/internal/database/database.go new file mode 100644 index 00000000..a039a0b1 --- /dev/null +++ b/go-backend/internal/database/database.go @@ -0,0 +1,84 @@ +package database + +import ( + "fmt" + "time" + + "github.com/xingrin/go-backend/internal/config" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// DB is the global database instance +var DB *gorm.DB + +// NewDatabase creates a new database connection +func NewDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) { + dsn := cfg.DSN() + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + SingularTable: true, // Use singular table names (e.g., "target" not "targets") + }, + // Disable auto-migration to prevent creating new tables + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Get underlying sql.DB to configure connection pool + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + + // Configure connection pool + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + + // Verify connection + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return db, nil +} + +// InitDatabase initializes the global database instance +func InitDatabase(cfg *config.DatabaseConfig) error { + var err error + DB, err = NewDatabase(cfg) + return err +} + +// Close closes the database connection +func Close() error { + if DB != nil { + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.Close() + } + return nil +} + +// Ping checks if the database connection is alive +func Ping() error { + if DB == nil { + return fmt.Errorf("database not initialized") + } + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.Ping() +} + +// GetDB returns the global database instance +func GetDB() *gorm.DB { + return DB +} diff --git a/go-backend/internal/handler/health.go b/go-backend/internal/handler/health.go new file mode 100644 index 00000000..3a414807 --- /dev/null +++ b/go-backend/internal/handler/health.go @@ -0,0 +1,106 @@ +package handler + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" + "gorm.io/gorm" +) + +// HealthHandler handles health check requests +type HealthHandler struct { + db *gorm.DB + redis *redis.Client +} + +// HealthResponse represents the health check response +type HealthResponse struct { + Status string `json:"status"` + Database string `json:"database"` + Redis string `json:"redis"` + Details map[string]string `json:"details,omitempty"` +} + +// NewHealthHandler creates a new health handler +func NewHealthHandler(db *gorm.DB, redis *redis.Client) *HealthHandler { + return &HealthHandler{ + db: db, + redis: redis, + } +} + +// Check handles GET /health +func (h *HealthHandler) Check(c *gin.Context) { + resp := HealthResponse{ + Status: "healthy", + Database: "connected", + Redis: "connected", + Details: make(map[string]string), + } + + // Check database connection + if h.db != nil { + sqlDB, err := h.db.DB() + if err != nil { + resp.Status = "unhealthy" + resp.Database = "error" + resp.Details["database_error"] = err.Error() + } else if err := sqlDB.Ping(); err != nil { + resp.Status = "unhealthy" + resp.Database = "disconnected" + resp.Details["database_error"] = err.Error() + } + } else { + resp.Database = "not_configured" + } + + // Check Redis connection + if h.redis != nil { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + if err := h.redis.Ping(ctx).Err(); err != nil { + resp.Status = "unhealthy" + resp.Redis = "disconnected" + resp.Details["redis_error"] = err.Error() + } + } else { + resp.Redis = "not_configured" + } + + // Return appropriate status code + if resp.Status == "healthy" { + c.JSON(http.StatusOK, resp) + } else { + c.JSON(http.StatusServiceUnavailable, resp) + } +} + +// Liveness handles GET /health/live (for Kubernetes liveness probe) +func (h *HealthHandler) Liveness(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "alive", + }) +} + +// Readiness handles GET /health/ready (for Kubernetes readiness probe) +func (h *HealthHandler) Readiness(c *gin.Context) { + // Check if database is ready + if h.db != nil { + sqlDB, err := h.db.DB() + if err != nil || sqlDB.Ping() != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "not_ready", + "reason": "database_unavailable", + }) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ready", + }) +} diff --git a/go-backend/internal/middleware/logger.go b/go-backend/internal/middleware/logger.go new file mode 100644 index 00000000..e2bf804e --- /dev/null +++ b/go-backend/internal/middleware/logger.go @@ -0,0 +1,78 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/xingrin/go-backend/internal/pkg" + "go.uber.org/zap" +) + +const ( + // RequestIDHeader is the header name for request ID + RequestIDHeader = "X-Request-ID" + // RequestIDKey is the context key for request ID + RequestIDKey = "requestId" +) + +// Logger returns a gin middleware for logging requests +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + // Generate or get request ID + requestID := c.GetHeader(RequestIDHeader) + if requestID == "" { + requestID = uuid.New().String() + } + c.Set(RequestIDKey, requestID) + c.Header(RequestIDHeader, requestID) + + // Start timer + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + // Process request + c.Next() + + // Calculate latency + latency := time.Since(start) + + // Log request + fields := []zap.Field{ + zap.String("requestId", requestID), + zap.Int("status", c.Writer.Status()), + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.String("query", query), + zap.String("ip", c.ClientIP()), + zap.String("userAgent", c.Request.UserAgent()), + zap.Duration("latency", latency), + zap.Int("bodySize", c.Writer.Size()), + } + + // Add error if exists + if len(c.Errors) > 0 { + fields = append(fields, zap.String("error", c.Errors.String())) + } + + // Log based on status code + status := c.Writer.Status() + switch { + case status >= 500: + pkg.Error("Server error", fields...) + case status >= 400: + pkg.Warn("Client error", fields...) + default: + pkg.Info("Request completed", fields...) + } + } +} + +// GetRequestID returns the request ID from context +func GetRequestID(c *gin.Context) string { + if requestID, exists := c.Get(RequestIDKey); exists { + return requestID.(string) + } + return "" +} diff --git a/go-backend/internal/middleware/recovery.go b/go-backend/internal/middleware/recovery.go new file mode 100644 index 00000000..2b7079db --- /dev/null +++ b/go-backend/internal/middleware/recovery.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" + "github.com/xingrin/go-backend/internal/pkg" + "go.uber.org/zap" +) + +// Recovery returns a gin middleware for recovering from panics +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + // Get request ID for tracing + requestID := GetRequestID(c) + + // Get stack trace + stack := string(debug.Stack()) + + // Log the panic with full stack trace + pkg.Error("Panic recovered", + zap.String("requestId", requestID), + zap.Any("error", err), + zap.String("stack", stack), + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.String("ip", c.ClientIP()), + ) + + // Return 500 error + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "Internal Server Error", + "requestId": requestID, + }) + } + }() + + c.Next() + } +} diff --git a/go-backend/internal/model/asset_statistics.go b/go-backend/internal/model/asset_statistics.go new file mode 100644 index 00000000..937b0be1 --- /dev/null +++ b/go-backend/internal/model/asset_statistics.go @@ -0,0 +1,35 @@ +package model + +import ( + "time" +) + +// AssetStatistics represents asset statistics (singleton) +type AssetStatistics struct { + ID int `gorm:"primaryKey" json:"id"` + + // Current statistics + TotalTargets int `gorm:"column:total_targets;default:0" json:"totalTargets"` + TotalSubdomains int `gorm:"column:total_subdomains;default:0" json:"totalSubdomains"` + TotalIPs int `gorm:"column:total_ips;default:0" json:"totalIps"` + TotalEndpoints int `gorm:"column:total_endpoints;default:0" json:"totalEndpoints"` + TotalWebsites int `gorm:"column:total_websites;default:0" json:"totalWebsites"` + TotalVulns int `gorm:"column:total_vulns;default:0" json:"totalVulns"` + TotalAssets int `gorm:"column:total_assets;default:0" json:"totalAssets"` + + // Previous statistics (for trend calculation) + PrevTargets int `gorm:"column:prev_targets;default:0" json:"prevTargets"` + PrevSubdomains int `gorm:"column:prev_subdomains;default:0" json:"prevSubdomains"` + PrevIPs int `gorm:"column:prev_ips;default:0" json:"prevIps"` + PrevEndpoints int `gorm:"column:prev_endpoints;default:0" json:"prevEndpoints"` + PrevWebsites int `gorm:"column:prev_websites;default:0" json:"prevWebsites"` + PrevVulns int `gorm:"column:prev_vulns;default:0" json:"prevVulns"` + PrevAssets int `gorm:"column:prev_assets;default:0" json:"prevAssets"` + + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for AssetStatistics +func (AssetStatistics) TableName() string { + return "asset_statistics" +} diff --git a/go-backend/internal/model/blacklist_rule.go b/go-backend/internal/model/blacklist_rule.go new file mode 100644 index 00000000..1fc5d736 --- /dev/null +++ b/go-backend/internal/model/blacklist_rule.go @@ -0,0 +1,38 @@ +package model + +import ( + "time" +) + +// BlacklistRule represents a blacklist rule +type BlacklistRule struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Pattern string `gorm:"column:pattern;size:255" json:"pattern"` + RuleType string `gorm:"column:rule_type;size:20" json:"ruleType"` + Scope string `gorm:"column:scope;size:20;index" json:"scope"` + TargetID *int `gorm:"column:target_id;index" json:"targetId"` + Description string `gorm:"column:description;size:500" json:"description"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index" json:"createdAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for BlacklistRule +func (BlacklistRule) TableName() string { + return "blacklist_rule" +} + +// RuleType constants +const ( + RuleTypeDomain = "domain" + RuleTypeIP = "ip" + RuleTypeCIDR = "cidr" + RuleTypeKeyword = "keyword" +) + +// Scope constants +const ( + ScopeGlobal = "global" + ScopeTarget = "target" +) diff --git a/go-backend/internal/model/directory.go b/go-backend/internal/model/directory.go new file mode 100644 index 00000000..a7d7d1a2 --- /dev/null +++ b/go-backend/internal/model/directory.go @@ -0,0 +1,27 @@ +package model + +import ( + "time" +) + +// Directory represents a discovered directory +type Directory struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + URL string `gorm:"column:url;size:2000;not null" json:"url"` + Status *int `gorm:"column:status" json:"status"` + ContentLength *int64 `gorm:"column:content_length" json:"contentLength"` + Words *int `gorm:"column:words" json:"words"` + Lines *int `gorm:"column:lines" json:"lines"` + ContentType string `gorm:"column:content_type;size:200" json:"contentType"` + Duration *int64 `gorm:"column:duration" json:"duration"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for Directory +func (Directory) TableName() string { + return "directory" +} diff --git a/go-backend/internal/model/directory_snapshot.go b/go-backend/internal/model/directory_snapshot.go new file mode 100644 index 00000000..aab9dbe1 --- /dev/null +++ b/go-backend/internal/model/directory_snapshot.go @@ -0,0 +1,27 @@ +package model + +import ( + "time" +) + +// DirectorySnapshot represents a directory discovered in a scan +type DirectorySnapshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null" json:"scanId"` + URL string `gorm:"column:url;size:2000" json:"url"` + Status *int `gorm:"column:status" json:"status"` + ContentLength *int64 `gorm:"column:content_length" json:"contentLength"` + Words *int `gorm:"column:words" json:"words"` + Lines *int `gorm:"column:lines" json:"lines"` + ContentType string `gorm:"column:content_type;size:200" json:"contentType"` + Duration *int64 `gorm:"column:duration" json:"duration"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for DirectorySnapshot +func (DirectorySnapshot) TableName() string { + return "directory_snapshot" +} diff --git a/go-backend/internal/model/endpoint.go b/go-backend/internal/model/endpoint.go new file mode 100644 index 00000000..bd8e2b37 --- /dev/null +++ b/go-backend/internal/model/endpoint.go @@ -0,0 +1,35 @@ +package model + +import ( + "time" + + "github.com/lib/pq" +) + +// Endpoint represents a discovered endpoint +type Endpoint struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + URL string `gorm:"column:url;type:text" json:"url"` + Host string `gorm:"column:host;size:253" json:"host"` + Location string `gorm:"column:location;type:text" json:"location"` + Title string `gorm:"column:title;type:text" json:"title"` + Webserver string `gorm:"column:webserver;type:text" json:"webserver"` + ResponseBody string `gorm:"column:response_body;type:text" json:"responseBody"` + ContentType string `gorm:"column:content_type;type:text" json:"contentType"` + Tech pq.StringArray `gorm:"column:tech;type:varchar(100)[]" json:"tech"` + StatusCode *int `gorm:"column:status_code" json:"statusCode"` + ContentLength *int `gorm:"column:content_length" json:"contentLength"` + Vhost *bool `gorm:"column:vhost" json:"vhost"` + MatchedGFPatterns pq.StringArray `gorm:"column:matched_gf_patterns;type:varchar(100)[]" json:"matchedGfPatterns"` + ResponseHeaders string `gorm:"column:response_headers;type:text" json:"responseHeaders"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for Endpoint +func (Endpoint) TableName() string { + return "endpoint" +} diff --git a/go-backend/internal/model/endpoint_snapshot.go b/go-backend/internal/model/endpoint_snapshot.go new file mode 100644 index 00000000..9d7bcfd3 --- /dev/null +++ b/go-backend/internal/model/endpoint_snapshot.go @@ -0,0 +1,35 @@ +package model + +import ( + "time" + + "github.com/lib/pq" +) + +// EndpointSnapshot represents an endpoint discovered in a scan +type EndpointSnapshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null" json:"scanId"` + URL string `gorm:"column:url;type:text" json:"url"` + Host string `gorm:"column:host;size:253" json:"host"` + Title string `gorm:"column:title;type:text" json:"title"` + StatusCode *int `gorm:"column:status_code" json:"statusCode"` + ContentLength *int `gorm:"column:content_length" json:"contentLength"` + Location string `gorm:"column:location;type:text" json:"location"` + Webserver string `gorm:"column:webserver;type:text" json:"webserver"` + ContentType string `gorm:"column:content_type;type:text" json:"contentType"` + Tech pq.StringArray `gorm:"column:tech;type:varchar(100)[]" json:"tech"` + ResponseBody string `gorm:"column:response_body;type:text" json:"responseBody"` + Vhost *bool `gorm:"column:vhost" json:"vhost"` + MatchedGFPatterns pq.StringArray `gorm:"column:matched_gf_patterns;type:varchar(100)[]" json:"matchedGfPatterns"` + ResponseHeaders string `gorm:"column:response_headers;type:text" json:"responseHeaders"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for EndpointSnapshot +func (EndpointSnapshot) TableName() string { + return "endpoint_snapshot" +} diff --git a/go-backend/internal/model/host_port_mapping.go b/go-backend/internal/model/host_port_mapping.go new file mode 100644 index 00000000..45486fb6 --- /dev/null +++ b/go-backend/internal/model/host_port_mapping.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" +) + +// HostPortMapping represents a host-IP-port mapping +type HostPortMapping struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + Host string `gorm:"column:host;size:1000;not null" json:"host"` + IP string `gorm:"column:ip;type:inet;not null" json:"ip"` + Port int `gorm:"column:port;not null" json:"port"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for HostPortMapping +func (HostPortMapping) TableName() string { + return "host_port_mapping" +} diff --git a/go-backend/internal/model/host_port_mapping_snapshot.go b/go-backend/internal/model/host_port_mapping_snapshot.go new file mode 100644 index 00000000..d2d1ceea --- /dev/null +++ b/go-backend/internal/model/host_port_mapping_snapshot.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" +) + +// HostPortMappingSnapshot represents a host-IP-port mapping discovered in a scan +type HostPortMappingSnapshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null" json:"scanId"` + Host string `gorm:"column:host;size:1000;not null" json:"host"` + IP string `gorm:"column:ip;type:inet;not null" json:"ip"` + Port int `gorm:"column:port;not null" json:"port"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for HostPortMappingSnapshot +func (HostPortMappingSnapshot) TableName() string { + return "host_port_mapping_snapshot" +} diff --git a/go-backend/internal/model/model_test.go b/go-backend/internal/model/model_test.go new file mode 100644 index 00000000..fc128111 --- /dev/null +++ b/go-backend/internal/model/model_test.go @@ -0,0 +1,250 @@ +package model + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" +) + +// TableNamer interface for models with TableName method +type TableNamer interface { + TableName() string +} + +// TestTableNames tests that all models return correct table names +// Property 1: 数据库表名映射正确性 +// *对于任意* Go 模型,其 TableName() 方法返回的表名应与 Django 模型的 db_table 一致。 +// **验证: 需求 4.1** +func TestTableNames(t *testing.T) { + tests := []struct { + model TableNamer + expected string + }{ + // Base models + {Organization{}, "organization"}, + {Target{}, "target"}, + {Scan{}, "scan"}, + {Subdomain{}, "subdomain"}, + {WebSite{}, "website"}, + {WorkerNode{}, "worker_node"}, + {ScanEngine{}, "scan_engine"}, + // Asset models + {Endpoint{}, "endpoint"}, + {Directory{}, "directory"}, + {HostPortMapping{}, "host_port_mapping"}, + {Vulnerability{}, "vulnerability"}, + {Screenshot{}, "screenshot"}, + // Snapshot models + {SubdomainSnapshot{}, "subdomain_snapshot"}, + {WebsiteSnapshot{}, "website_snapshot"}, + {EndpointSnapshot{}, "endpoint_snapshot"}, + {DirectorySnapshot{}, "directory_snapshot"}, + {HostPortMappingSnapshot{}, "host_port_mapping_snapshot"}, + {VulnerabilitySnapshot{}, "vulnerability_snapshot"}, + {ScreenshotSnapshot{}, "screenshot_snapshot"}, + // Scan-related models + {ScanLog{}, "scan_log"}, + {ScanInputTarget{}, "scan_input_target"}, + {ScheduledScan{}, "scheduled_scan"}, + {SubfinderProviderSettings{}, "subfinder_provider_settings"}, + // Engine models + {Wordlist{}, "wordlist"}, + {NucleiTemplateRepo{}, "nuclei_template_repo"}, + // Notification models + {Notification{}, "notification"}, + {NotificationSettings{}, "notification_settings"}, + // Config models + {BlacklistRule{}, "blacklist_rule"}, + // Statistics models + {AssetStatistics{}, "asset_statistics"}, + {StatisticsHistory{}, "statistics_history"}, + // Auth models + {User{}, "auth_user"}, + {Session{}, "django_session"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if got := tt.model.TableName(); got != tt.expected { + t.Errorf("TableName() = %v, want %v", got, tt.expected) + } + }) + } +} + +// TestJSONFieldNames tests that JSON field names are camelCase +// Property 2: JSON 字段名转换正确性 +// *对于任意* Go 模型序列化为 JSON,所有字段名应为 camelCase 格式。 +// **验证: 需求 4.6** +func TestJSONFieldNames(t *testing.T) { + // Test Target model + target := Target{ + ID: 1, + Name: "test.com", + Type: "domain", + CreatedAt: time.Now(), + LastScannedAt: nil, + } + + jsonBytes, err := json.Marshal(target) + if err != nil { + t.Fatalf("Failed to marshal Target: %v", err) + } + jsonStr := string(jsonBytes) + + // Should contain camelCase + camelCaseFields := []string{"id", "name", "type", "createdAt", "lastScannedAt"} + for _, field := range camelCaseFields { + if !strings.Contains(jsonStr, `"`+field+`"`) { + t.Errorf("JSON should contain camelCase field %q, got: %s", field, jsonStr) + } + } + + // Should NOT contain snake_case + snakeCaseFields := []string{"created_at", "last_scanned_at", "organization_id"} + for _, field := range snakeCaseFields { + if strings.Contains(jsonStr, `"`+field+`"`) { + t.Errorf("JSON should NOT contain snake_case field %q, got: %s", field, jsonStr) + } + } +} + +// TestScanJSONFieldNames tests Scan model JSON serialization +func TestScanJSONFieldNames(t *testing.T) { + scan := Scan{ + ID: 1, + TargetID: 1, + Status: "running", + Progress: 50, + CurrentStage: "subdomain_discovery", + CachedSubdomainsCount: 100, + CachedWebsitesCount: 50, + } + + jsonBytes, err := json.Marshal(scan) + if err != nil { + t.Fatalf("Failed to marshal Scan: %v", err) + } + jsonStr := string(jsonBytes) + + // Should contain camelCase + camelCaseFields := []string{ + "targetId", "status", "progress", "currentStage", + "cachedSubdomainsCount", "cachedWebsitesCount", + } + for _, field := range camelCaseFields { + if !strings.Contains(jsonStr, `"`+field+`"`) { + t.Errorf("JSON should contain camelCase field %q, got: %s", field, jsonStr) + } + } + + // Should NOT contain snake_case + snakeCaseFields := []string{ + "target_id", "current_stage", "cached_subdomains_count", + } + for _, field := range snakeCaseFields { + if strings.Contains(jsonStr, `"`+field+`"`) { + t.Errorf("JSON should NOT contain snake_case field %q, got: %s", field, jsonStr) + } + } +} + + +// TestGORMColumnTags tests that GORM column tags use snake_case +// Property 3: 数据库字段映射正确性 +// *对于任意* Go 模型字段,其 gorm column tag 应与数据库实际列名(snake_case)一致。 +// **验证: 需求 4.2** +func TestGORMColumnTags(t *testing.T) { + // Test Target model + targetType := reflect.TypeOf(Target{}) + expectedColumns := map[string]string{ + "ID": "", // primaryKey, no explicit column + "Name": "name", + "Type": "type", + "OrganizationID": "organization_id", + "CreatedAt": "created_at", + "LastScannedAt": "last_scanned_at", + "DeletedAt": "deleted_at", + } + + for fieldName, expectedColumn := range expectedColumns { + field, found := targetType.FieldByName(fieldName) + if !found { + t.Errorf("Field %s not found in Target", fieldName) + continue + } + + gormTag := field.Tag.Get("gorm") + if expectedColumn != "" && !strings.Contains(gormTag, "column:"+expectedColumn) { + t.Errorf("Field %s: expected gorm column:%s, got tag: %s", fieldName, expectedColumn, gormTag) + } + } +} + +// TestScanGORMColumnTags tests Scan model GORM tags +func TestScanGORMColumnTags(t *testing.T) { + scanType := reflect.TypeOf(Scan{}) + expectedColumns := map[string]string{ + "TargetID": "target_id", + "EngineIDs": "engine_ids", + "EngineNames": "engine_names", + "YamlConfiguration": "yaml_configuration", + "ScanMode": "scan_mode", + "Status": "status", + "ResultsDir": "results_dir", + "ContainerIDs": "container_ids", + "WorkerID": "worker_id", + "ErrorMessage": "error_message", + "Progress": "progress", + "CurrentStage": "current_stage", + "StageProgress": "stage_progress", + "CreatedAt": "created_at", + "StoppedAt": "stopped_at", + "DeletedAt": "deleted_at", + "CachedSubdomainsCount": "cached_subdomains_count", + "CachedWebsitesCount": "cached_websites_count", + "CachedEndpointsCount": "cached_endpoints_count", + "CachedIPsCount": "cached_ips_count", + "CachedVulnsTotal": "cached_vulns_total", + } + + for fieldName, expectedColumn := range expectedColumns { + field, found := scanType.FieldByName(fieldName) + if !found { + t.Errorf("Field %s not found in Scan", fieldName) + continue + } + + gormTag := field.Tag.Get("gorm") + if !strings.Contains(gormTag, "column:"+expectedColumn) { + t.Errorf("Field %s: expected gorm column:%s, got tag: %s", fieldName, expectedColumn, gormTag) + } + } +} + +// TestWorkerNodePasswordHidden tests that password is hidden from JSON +func TestWorkerNodePasswordHidden(t *testing.T) { + worker := WorkerNode{ + ID: 1, + Name: "worker-1", + IPAddress: "192.168.1.1", + Password: "secret123", + Status: "connected", + } + + jsonBytes, err := json.Marshal(worker) + if err != nil { + t.Fatalf("Failed to marshal WorkerNode: %v", err) + } + jsonStr := string(jsonBytes) + + // Password should NOT appear in JSON + if strings.Contains(jsonStr, "secret123") { + t.Errorf("Password should be hidden from JSON, got: %s", jsonStr) + } + if strings.Contains(jsonStr, `"password"`) { + t.Errorf("Password field should not appear in JSON, got: %s", jsonStr) + } +} diff --git a/go-backend/internal/model/notification.go b/go-backend/internal/model/notification.go new file mode 100644 index 00000000..d8b23fee --- /dev/null +++ b/go-backend/internal/model/notification.go @@ -0,0 +1,38 @@ +package model + +import ( + "time" +) + +// Notification represents a notification entry +type Notification struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Category string `gorm:"column:category;size:20;index" json:"category"` + Level string `gorm:"column:level;size:20;index" json:"level"` + Title string `gorm:"column:title;size:200" json:"title"` + Message string `gorm:"column:message;size:2000" json:"message"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index" json:"createdAt"` + IsRead bool `gorm:"column:is_read;default:false;index" json:"isRead"` + ReadAt *time.Time `gorm:"column:read_at" json:"readAt"` +} + +// TableName returns the table name for Notification +func (Notification) TableName() string { + return "notification" +} + +// NotificationCategory constants +const ( + NotificationCategoryScan = "scan" + NotificationCategoryVulnerability = "vulnerability" + NotificationCategoryAsset = "asset" + NotificationCategorySystem = "system" +) + +// NotificationLevel constants +const ( + NotificationLevelLow = "low" + NotificationLevelMedium = "medium" + NotificationLevelHigh = "high" + NotificationLevelCritical = "critical" +) diff --git a/go-backend/internal/model/notification_settings.go b/go-backend/internal/model/notification_settings.go new file mode 100644 index 00000000..ed51f58c --- /dev/null +++ b/go-backend/internal/model/notification_settings.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// NotificationSettings represents notification settings (singleton) +type NotificationSettings struct { + ID int `gorm:"primaryKey" json:"id"` + DiscordEnabled bool `gorm:"column:discord_enabled;default:false" json:"discordEnabled"` + DiscordWebhookURL string `gorm:"column:discord_webhook_url;size:500" json:"discordWebhookUrl"` + WecomEnabled bool `gorm:"column:wecom_enabled;default:false" json:"wecomEnabled"` + WecomWebhookURL string `gorm:"column:wecom_webhook_url;size:500" json:"wecomWebhookUrl"` + Categories datatypes.JSON `gorm:"column:categories;type:jsonb" json:"categories"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for NotificationSettings +func (NotificationSettings) TableName() string { + return "notification_settings" +} + +// DefaultCategories returns the default category configuration +func DefaultCategories() map[string]bool { + return map[string]bool{ + "scan": true, + "vulnerability": true, + "asset": true, + "system": false, + } +} diff --git a/go-backend/internal/model/nuclei_template_repo.go b/go-backend/internal/model/nuclei_template_repo.go new file mode 100644 index 00000000..b454fbca --- /dev/null +++ b/go-backend/internal/model/nuclei_template_repo.go @@ -0,0 +1,22 @@ +package model + +import ( + "time" +) + +// NucleiTemplateRepo represents a nuclei template git repository +type NucleiTemplateRepo struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:200;uniqueIndex" json:"name"` + RepoURL string `gorm:"column:repo_url;size:500" json:"repoUrl"` + LocalPath string `gorm:"column:local_path;size:500" json:"localPath"` + CommitHash string `gorm:"column:commit_hash;size:40" json:"commitHash"` + LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for NucleiTemplateRepo +func (NucleiTemplateRepo) TableName() string { + return "nuclei_template_repo" +} diff --git a/go-backend/internal/model/organization.go b/go-backend/internal/model/organization.go new file mode 100644 index 00000000..63ef3b92 --- /dev/null +++ b/go-backend/internal/model/organization.go @@ -0,0 +1,19 @@ +package model + +import ( + "time" +) + +// Organization represents an organization in the system +type Organization struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:300" json:"name"` + Description string `gorm:"column:description;size:1000" json:"description"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + DeletedAt *time.Time `gorm:"column:deleted_at;index" json:"-"` +} + +// TableName returns the table name for Organization +func (Organization) TableName() string { + return "organization" +} diff --git a/go-backend/internal/model/scan.go b/go-backend/internal/model/scan.go new file mode 100644 index 00000000..5f30d508 --- /dev/null +++ b/go-backend/internal/model/scan.go @@ -0,0 +1,67 @@ +package model + +import ( + "time" + + "github.com/lib/pq" + "gorm.io/datatypes" +) + +// Scan represents a scan job +type Scan struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + EngineIDs pq.Int64Array `gorm:"column:engine_ids;type:integer[]" json:"engineIds"` + EngineNames datatypes.JSON `gorm:"column:engine_names;type:jsonb" json:"engineNames"` + YamlConfiguration string `gorm:"column:yaml_configuration;type:text" json:"yamlConfiguration"` + ScanMode string `gorm:"column:scan_mode;size:10;default:'full'" json:"scanMode"` + Status string `gorm:"column:status;size:20;default:'initiated'" json:"status"` + ResultsDir string `gorm:"column:results_dir;size:100" json:"resultsDir"` + ContainerIDs pq.StringArray `gorm:"column:container_ids;type:varchar(100)[]" json:"containerIds"` + WorkerID *int `gorm:"column:worker_id" json:"workerId"` + ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"` + Progress int `gorm:"column:progress;default:0" json:"progress"` + CurrentStage string `gorm:"column:current_stage;size:50" json:"currentStage"` + StageProgress datatypes.JSON `gorm:"column:stage_progress;type:jsonb" json:"stageProgress"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + StoppedAt *time.Time `gorm:"column:stopped_at" json:"stoppedAt"` + DeletedAt *time.Time `gorm:"column:deleted_at;index" json:"-"` + + // Cached statistics + CachedSubdomainsCount int `gorm:"column:cached_subdomains_count" json:"cachedSubdomainsCount"` + CachedWebsitesCount int `gorm:"column:cached_websites_count" json:"cachedWebsitesCount"` + CachedEndpointsCount int `gorm:"column:cached_endpoints_count" json:"cachedEndpointsCount"` + CachedIPsCount int `gorm:"column:cached_ips_count" json:"cachedIpsCount"` + CachedDirectoriesCount int `gorm:"column:cached_directories_count" json:"cachedDirectoriesCount"` + CachedScreenshotsCount int `gorm:"column:cached_screenshots_count" json:"cachedScreenshotsCount"` + CachedVulnsTotal int `gorm:"column:cached_vulns_total" json:"cachedVulnsTotal"` + CachedVulnsCritical int `gorm:"column:cached_vulns_critical" json:"cachedVulnsCritical"` + CachedVulnsHigh int `gorm:"column:cached_vulns_high" json:"cachedVulnsHigh"` + CachedVulnsMedium int `gorm:"column:cached_vulns_medium" json:"cachedVulnsMedium"` + CachedVulnsLow int `gorm:"column:cached_vulns_low" json:"cachedVulnsLow"` + StatsUpdatedAt *time.Time `gorm:"column:stats_updated_at" json:"statsUpdatedAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for Scan +func (Scan) TableName() string { + return "scan" +} + +// ScanStatus constants +const ( + ScanStatusInitiated = "initiated" + ScanStatusRunning = "running" + ScanStatusCompleted = "completed" + ScanStatusFailed = "failed" + ScanStatusStopped = "stopped" + ScanStatusPending = "pending" +) + +// ScanMode constants +const ( + ScanModeFull = "full" + ScanModeIncremental = "incremental" +) diff --git a/go-backend/internal/model/scan_engine.go b/go-backend/internal/model/scan_engine.go new file mode 100644 index 00000000..9d7fe763 --- /dev/null +++ b/go-backend/internal/model/scan_engine.go @@ -0,0 +1,19 @@ +package model + +import ( + "time" +) + +// ScanEngine represents a scan engine configuration +type ScanEngine struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:200;uniqueIndex" json:"name"` + Configuration string `gorm:"column:configuration;size:10000" json:"configuration"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for ScanEngine +func (ScanEngine) TableName() string { + return "scan_engine" +} diff --git a/go-backend/internal/model/scan_input_target.go b/go-backend/internal/model/scan_input_target.go new file mode 100644 index 00000000..32444489 --- /dev/null +++ b/go-backend/internal/model/scan_input_target.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" +) + +// ScanInputTarget represents a scan input target entry +type ScanInputTarget struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null;index" json:"scanId"` + Value string `gorm:"column:value;size:2000" json:"value"` + InputType string `gorm:"column:input_type;size:10" json:"inputType"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for ScanInputTarget +func (ScanInputTarget) TableName() string { + return "scan_input_target" +} + +// InputType constants +const ( + InputTypeDomain = "domain" + InputTypeIP = "ip" + InputTypeCIDR = "cidr" + InputTypeURL = "url" +) diff --git a/go-backend/internal/model/scan_log.go b/go-backend/internal/model/scan_log.go new file mode 100644 index 00000000..651d2eb3 --- /dev/null +++ b/go-backend/internal/model/scan_log.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" +) + +// ScanLog represents a scan log entry +type ScanLog struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null;index" json:"scanId"` + Level string `gorm:"column:level;size:10;default:'info'" json:"level"` + Content string `gorm:"column:content;type:text" json:"content"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for ScanLog +func (ScanLog) TableName() string { + return "scan_log" +} + +// ScanLogLevel constants +const ( + ScanLogLevelInfo = "info" + ScanLogLevelWarning = "warning" + ScanLogLevelError = "error" +) diff --git a/go-backend/internal/model/scheduled_scan.go b/go-backend/internal/model/scheduled_scan.go new file mode 100644 index 00000000..6a19baa4 --- /dev/null +++ b/go-backend/internal/model/scheduled_scan.go @@ -0,0 +1,35 @@ +package model + +import ( + "time" + + "github.com/lib/pq" + "gorm.io/datatypes" +) + +// ScheduledScan represents a scheduled scan task +type ScheduledScan struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:200" json:"name"` + EngineIDs pq.Int64Array `gorm:"column:engine_ids;type:integer[]" json:"engineIds"` + EngineNames datatypes.JSON `gorm:"column:engine_names;type:jsonb" json:"engineNames"` + YamlConfiguration string `gorm:"column:yaml_configuration;type:text" json:"yamlConfiguration"` + OrganizationID *int `gorm:"column:organization_id" json:"organizationId"` + TargetID *int `gorm:"column:target_id" json:"targetId"` + CronExpression string `gorm:"column:cron_expression;size:100;default:'0 2 * * *'" json:"cronExpression"` + IsEnabled bool `gorm:"column:is_enabled;default:true;index" json:"isEnabled"` + RunCount int `gorm:"column:run_count;default:0" json:"runCount"` + LastRunTime *time.Time `gorm:"column:last_run_time" json:"lastRunTime"` + NextRunTime *time.Time `gorm:"column:next_run_time" json:"nextRunTime"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` + + // Relationships + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for ScheduledScan +func (ScheduledScan) TableName() string { + return "scheduled_scan" +} diff --git a/go-backend/internal/model/screenshot.go b/go-backend/internal/model/screenshot.go new file mode 100644 index 00000000..58cc6270 --- /dev/null +++ b/go-backend/internal/model/screenshot.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" +) + +// Screenshot represents a screenshot asset +type Screenshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + URL string `gorm:"column:url;type:text" json:"url"` + StatusCode *int16 `gorm:"column:status_code" json:"statusCode"` + Image []byte `gorm:"column:image;type:bytea" json:"-"` // Hidden from JSON + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for Screenshot +func (Screenshot) TableName() string { + return "screenshot" +} diff --git a/go-backend/internal/model/screenshot_snapshot.go b/go-backend/internal/model/screenshot_snapshot.go new file mode 100644 index 00000000..3c1c28d5 --- /dev/null +++ b/go-backend/internal/model/screenshot_snapshot.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" +) + +// ScreenshotSnapshot represents a screenshot captured in a scan +type ScreenshotSnapshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null" json:"scanId"` + URL string `gorm:"column:url;type:text" json:"url"` + StatusCode *int16 `gorm:"column:status_code" json:"statusCode"` + Image []byte `gorm:"column:image;type:bytea" json:"-"` // Hidden from JSON + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for ScreenshotSnapshot +func (ScreenshotSnapshot) TableName() string { + return "screenshot_snapshot" +} diff --git a/go-backend/internal/model/session.go b/go-backend/internal/model/session.go new file mode 100644 index 00000000..1cb0e788 --- /dev/null +++ b/go-backend/internal/model/session.go @@ -0,0 +1,17 @@ +package model + +import ( + "time" +) + +// Session represents a Django django_session compatible model +type Session struct { + SessionKey string `gorm:"column:session_key;size:40;primaryKey" json:"sessionKey"` + SessionData string `gorm:"column:session_data;type:text" json:"sessionData"` + ExpireDate time.Time `gorm:"column:expire_date;index" json:"expireDate"` +} + +// TableName returns the table name for Session (Django django_session) +func (Session) TableName() string { + return "django_session" +} diff --git a/go-backend/internal/model/statistics_history.go b/go-backend/internal/model/statistics_history.go new file mode 100644 index 00000000..18b73c8e --- /dev/null +++ b/go-backend/internal/model/statistics_history.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" +) + +// StatisticsHistory represents daily statistics history +type StatisticsHistory struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Date time.Time `gorm:"column:date;type:date;uniqueIndex" json:"date"` + TotalTargets int `gorm:"column:total_targets;default:0" json:"totalTargets"` + TotalSubdomains int `gorm:"column:total_subdomains;default:0" json:"totalSubdomains"` + TotalIPs int `gorm:"column:total_ips;default:0" json:"totalIps"` + TotalEndpoints int `gorm:"column:total_endpoints;default:0" json:"totalEndpoints"` + TotalWebsites int `gorm:"column:total_websites;default:0" json:"totalWebsites"` + TotalVulns int `gorm:"column:total_vulns;default:0" json:"totalVulns"` + TotalAssets int `gorm:"column:total_assets;default:0" json:"totalAssets"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for StatisticsHistory +func (StatisticsHistory) TableName() string { + return "statistics_history" +} diff --git a/go-backend/internal/model/subdomain.go b/go-backend/internal/model/subdomain.go new file mode 100644 index 00000000..5908d641 --- /dev/null +++ b/go-backend/internal/model/subdomain.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" +) + +// Subdomain represents a discovered subdomain +type Subdomain struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + Name string `gorm:"column:name;size:1000" json:"name"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for Subdomain +func (Subdomain) TableName() string { + return "subdomain" +} diff --git a/go-backend/internal/model/subdomain_snapshot.go b/go-backend/internal/model/subdomain_snapshot.go new file mode 100644 index 00000000..cad6de06 --- /dev/null +++ b/go-backend/internal/model/subdomain_snapshot.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" +) + +// SubdomainSnapshot represents a subdomain discovered in a scan +type SubdomainSnapshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null" json:"scanId"` + Name string `gorm:"column:name;size:1000" json:"name"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for SubdomainSnapshot +func (SubdomainSnapshot) TableName() string { + return "subdomain_snapshot" +} diff --git a/go-backend/internal/model/subfinder_provider_settings.go b/go-backend/internal/model/subfinder_provider_settings.go new file mode 100644 index 00000000..bbbd0031 --- /dev/null +++ b/go-backend/internal/model/subfinder_provider_settings.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// SubfinderProviderSettings represents subfinder provider settings (singleton) +type SubfinderProviderSettings struct { + ID int `gorm:"primaryKey" json:"id"` + Providers datatypes.JSON `gorm:"column:providers;type:jsonb" json:"providers"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for SubfinderProviderSettings +func (SubfinderProviderSettings) TableName() string { + return "subfinder_provider_settings" +} + +// DefaultProviders returns the default provider configuration +func DefaultProviders() map[string]interface{} { + return map[string]interface{}{ + "fofa": map[string]interface{}{"enabled": false, "email": "", "api_key": ""}, + "hunter": map[string]interface{}{"enabled": false, "api_key": ""}, + "shodan": map[string]interface{}{"enabled": false, "api_key": ""}, + "censys": map[string]interface{}{"enabled": false, "api_id": "", "api_secret": ""}, + "zoomeye": map[string]interface{}{"enabled": false, "api_key": ""}, + "securitytrails": map[string]interface{}{"enabled": false, "api_key": ""}, + "threatbook": map[string]interface{}{"enabled": false, "api_key": ""}, + "quake": map[string]interface{}{"enabled": false, "api_key": ""}, + } +} diff --git a/go-backend/internal/model/target.go b/go-backend/internal/model/target.go new file mode 100644 index 00000000..9e517680 --- /dev/null +++ b/go-backend/internal/model/target.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" +) + +// Target represents a scan target (domain, IP, etc.) +type Target struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:300" json:"name"` + Type string `gorm:"column:type;size:20;default:'domain'" json:"type"` + OrganizationID *int `gorm:"column:organization_id" json:"organizationId"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + LastScannedAt *time.Time `gorm:"column:last_scanned_at" json:"lastScannedAt"` + DeletedAt *time.Time `gorm:"column:deleted_at;index" json:"-"` + + // Relationships + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` +} + +// TableName returns the table name for Target +func (Target) TableName() string { + return "target" +} diff --git a/go-backend/internal/model/user.go b/go-backend/internal/model/user.go new file mode 100644 index 00000000..ac00e9da --- /dev/null +++ b/go-backend/internal/model/user.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" +) + +// User represents a Django auth_user compatible model +type User struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Password string `gorm:"column:password;size:128" json:"-"` + LastLogin *time.Time `gorm:"column:last_login" json:"lastLogin"` + IsSuperuser bool `gorm:"column:is_superuser;default:false" json:"isSuperuser"` + Username string `gorm:"column:username;size:150;uniqueIndex" json:"username"` + FirstName string `gorm:"column:first_name;size:150" json:"firstName"` + LastName string `gorm:"column:last_name;size:150" json:"lastName"` + Email string `gorm:"column:email;size:254" json:"email"` + IsStaff bool `gorm:"column:is_staff;default:false" json:"isStaff"` + IsActive bool `gorm:"column:is_active;default:true" json:"isActive"` + DateJoined time.Time `gorm:"column:date_joined;autoCreateTime" json:"dateJoined"` +} + +// TableName returns the table name for User (Django auth_user) +func (User) TableName() string { + return "auth_user" +} diff --git a/go-backend/internal/model/vulnerability.go b/go-backend/internal/model/vulnerability.go new file mode 100644 index 00000000..517b127c --- /dev/null +++ b/go-backend/internal/model/vulnerability.go @@ -0,0 +1,40 @@ +package model + +import ( + "time" + + "github.com/shopspring/decimal" + "gorm.io/datatypes" +) + +// Vulnerability represents a discovered vulnerability +type Vulnerability struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + URL string `gorm:"column:url;size:2000" json:"url"` + VulnType string `gorm:"column:vuln_type;size:100" json:"vulnType"` + Severity string `gorm:"column:severity;size:20;default:'unknown'" json:"severity"` + Source string `gorm:"column:source;size:50" json:"source"` + CVSSScore *decimal.Decimal `gorm:"column:cvss_score;type:decimal(3,1)" json:"cvssScore"` + Description string `gorm:"column:description;type:text" json:"description"` + RawOutput datatypes.JSON `gorm:"column:raw_output;type:jsonb" json:"rawOutput"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for Vulnerability +func (Vulnerability) TableName() string { + return "vulnerability" +} + +// VulnSeverity constants +const ( + VulnSeverityUnknown = "unknown" + VulnSeverityInfo = "info" + VulnSeverityLow = "low" + VulnSeverityMedium = "medium" + VulnSeverityHigh = "high" + VulnSeverityCritical = "critical" +) diff --git a/go-backend/internal/model/vulnerability_snapshot.go b/go-backend/internal/model/vulnerability_snapshot.go new file mode 100644 index 00000000..9c066811 --- /dev/null +++ b/go-backend/internal/model/vulnerability_snapshot.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + "github.com/shopspring/decimal" + "gorm.io/datatypes" +) + +// VulnerabilitySnapshot represents a vulnerability discovered in a scan +type VulnerabilitySnapshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null" json:"scanId"` + URL string `gorm:"column:url;size:2000" json:"url"` + VulnType string `gorm:"column:vuln_type;size:100" json:"vulnType"` + Severity string `gorm:"column:severity;size:20;default:'unknown'" json:"severity"` + Source string `gorm:"column:source;size:50" json:"source"` + CVSSScore *decimal.Decimal `gorm:"column:cvss_score;type:decimal(3,1)" json:"cvssScore"` + Description string `gorm:"column:description;type:text" json:"description"` + RawOutput datatypes.JSON `gorm:"column:raw_output;type:jsonb" json:"rawOutput"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for VulnerabilitySnapshot +func (VulnerabilitySnapshot) TableName() string { + return "vulnerability_snapshot" +} diff --git a/go-backend/internal/model/website.go b/go-backend/internal/model/website.go new file mode 100644 index 00000000..b0d54216 --- /dev/null +++ b/go-backend/internal/model/website.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "github.com/lib/pq" +) + +// WebSite represents a discovered website +type WebSite struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + TargetID int `gorm:"column:target_id;not null" json:"targetId"` + URL string `gorm:"column:url;type:text" json:"url"` + Host string `gorm:"column:host;size:253" json:"host"` + Location string `gorm:"column:location;type:text" json:"location"` + Title string `gorm:"column:title;type:text" json:"title"` + Webserver string `gorm:"column:webserver;type:text" json:"webserver"` + ResponseBody string `gorm:"column:response_body;type:text" json:"responseBody"` + ContentType string `gorm:"column:content_type;type:text" json:"contentType"` + Tech pq.StringArray `gorm:"column:tech;type:varchar(100)[]" json:"tech"` + StatusCode *int `gorm:"column:status_code" json:"statusCode"` + ContentLength *int64 `gorm:"column:content_length" json:"contentLength"` + Vhost *bool `gorm:"column:vhost" json:"vhost"` + ResponseHeaders string `gorm:"column:response_headers;type:text" json:"responseHeaders"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Target *Target `gorm:"foreignKey:TargetID" json:"target,omitempty"` +} + +// TableName returns the table name for WebSite +func (WebSite) TableName() string { + return "website" +} diff --git a/go-backend/internal/model/website_snapshot.go b/go-backend/internal/model/website_snapshot.go new file mode 100644 index 00000000..17318303 --- /dev/null +++ b/go-backend/internal/model/website_snapshot.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "github.com/lib/pq" +) + +// WebsiteSnapshot represents a website discovered in a scan +type WebsiteSnapshot struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + ScanID int `gorm:"column:scan_id;not null" json:"scanId"` + URL string `gorm:"column:url;type:text" json:"url"` + Host string `gorm:"column:host;size:253" json:"host"` + Title string `gorm:"column:title;type:text" json:"title"` + StatusCode *int `gorm:"column:status_code" json:"statusCode"` + ContentLength *int64 `gorm:"column:content_length" json:"contentLength"` + Location string `gorm:"column:location;type:text" json:"location"` + Webserver string `gorm:"column:webserver;type:text" json:"webserver"` + ContentType string `gorm:"column:content_type;type:text" json:"contentType"` + Tech pq.StringArray `gorm:"column:tech;type:varchar(100)[]" json:"tech"` + ResponseBody string `gorm:"column:response_body;type:text" json:"responseBody"` + Vhost *bool `gorm:"column:vhost" json:"vhost"` + ResponseHeaders string `gorm:"column:response_headers;type:text" json:"responseHeaders"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + + // Relationships + Scan *Scan `gorm:"foreignKey:ScanID" json:"scan,omitempty"` +} + +// TableName returns the table name for WebsiteSnapshot +func (WebsiteSnapshot) TableName() string { + return "website_snapshot" +} diff --git a/go-backend/internal/model/wordlist.go b/go-backend/internal/model/wordlist.go new file mode 100644 index 00000000..45ad43df --- /dev/null +++ b/go-backend/internal/model/wordlist.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" +) + +// Wordlist represents a wordlist file +type Wordlist struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:200;uniqueIndex" json:"name"` + Description string `gorm:"column:description;size:200" json:"description"` + FilePath string `gorm:"column:file_path;size:500" json:"filePath"` + FileSize int64 `gorm:"column:file_size;default:0" json:"fileSize"` + LineCount int `gorm:"column:line_count;default:0" json:"lineCount"` + FileHash string `gorm:"column:file_hash;size:64" json:"fileHash"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for Wordlist +func (Wordlist) TableName() string { + return "wordlist" +} diff --git a/go-backend/internal/model/worker_node.go b/go-backend/internal/model/worker_node.go new file mode 100644 index 00000000..4b56d6cf --- /dev/null +++ b/go-backend/internal/model/worker_node.go @@ -0,0 +1,32 @@ +package model + +import ( + "time" +) + +// WorkerNode represents a worker node for executing scans +type WorkerNode struct { + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:100;uniqueIndex" json:"name"` + IPAddress string `gorm:"column:ip_address;type:inet" json:"ipAddress"` + SSHPort int `gorm:"column:ssh_port;default:22" json:"sshPort"` + Username string `gorm:"column:username;size:50;default:'root'" json:"username"` + Password string `gorm:"column:password;size:200" json:"-"` // Hidden from JSON + IsLocal bool `gorm:"column:is_local;default:false" json:"isLocal"` + Status string `gorm:"column:status;size:20;default:'pending'" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updatedAt"` +} + +// TableName returns the table name for WorkerNode +func (WorkerNode) TableName() string { + return "worker_node" +} + +// WorkerStatus constants +const ( + WorkerStatusPending = "pending" + WorkerStatusConnected = "connected" + WorkerStatusDisconnected = "disconnected" + WorkerStatusError = "error" +) diff --git a/go-backend/internal/pkg/logger.go b/go-backend/internal/pkg/logger.go new file mode 100644 index 00000000..135f61d7 --- /dev/null +++ b/go-backend/internal/pkg/logger.go @@ -0,0 +1,130 @@ +package pkg + +import ( + "os" + "strings" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var ( + // Logger is the global logger instance + Logger *zap.Logger + // Sugar is the sugared logger for convenience + Sugar *zap.SugaredLogger +) + +// LogConfig holds logging configuration +type LogConfig struct { + Level string + Format string +} + +// InitLogger initializes the global logger +func InitLogger(cfg *LogConfig) error { + level := parseLogLevel(cfg.Level) + + var config zap.Config + if strings.ToLower(cfg.Format) == "json" { + config = zap.NewProductionConfig() + } else { + config = zap.NewDevelopmentConfig() + } + + config.Level = zap.NewAtomicLevelAt(level) + config.EncoderConfig.TimeKey = "timestamp" + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + + var err error + Logger, err = config.Build( + zap.AddCallerSkip(1), + zap.AddStacktrace(zapcore.ErrorLevel), + ) + if err != nil { + return err + } + + Sugar = Logger.Sugar() + return nil +} + +// parseLogLevel converts string 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 + } +} + + +// Sync flushes any buffered log entries +func Sync() { + if Logger != nil { + _ = Logger.Sync() + } +} + +// Debug logs a debug message +func Debug(msg string, fields ...zap.Field) { + Logger.Debug(msg, fields...) +} + +// Info logs an info message +func Info(msg string, fields ...zap.Field) { + Logger.Info(msg, fields...) +} + +// Warn logs a warning message +func Warn(msg string, fields ...zap.Field) { + Logger.Warn(msg, fields...) +} + +// Error logs an error message +func Error(msg string, fields ...zap.Field) { + Logger.Error(msg, fields...) +} + +// Fatal logs a fatal message and exits +func Fatal(msg string, fields ...zap.Field) { + Logger.Fatal(msg, fields...) +} + +// With creates a child logger with additional fields +func With(fields ...zap.Field) *zap.Logger { + return Logger.With(fields...) +} + +// WithRequestID creates a logger with request ID field +func WithRequestID(requestID string) *zap.Logger { + return Logger.With(zap.String("requestId", requestID)) +} + +// NewNopLogger returns a no-op logger for testing +func NewNopLogger() *zap.Logger { + return zap.NewNop() +} + +// InitTestLogger initializes a test logger that writes to stdout +func InitTestLogger() { + Logger = zap.NewExample() + Sugar = Logger.Sugar() +} + +// InitDefaultLogger initializes logger with default settings +func InitDefaultLogger() error { + return InitLogger(&LogConfig{ + Level: os.Getenv("LOG_LEVEL"), + Format: os.Getenv("LOG_FORMAT"), + }) +} diff --git a/go-backend/internal/pkg/response.go b/go-backend/internal/pkg/response.go new file mode 100644 index 00000000..26c4f8d0 --- /dev/null +++ b/go-backend/internal/pkg/response.go @@ -0,0 +1,139 @@ +package pkg + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response represents a standard API response +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +// ErrorInfo represents error information +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +// Meta represents pagination metadata +type Meta struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + TotalCount int64 `json:"totalCount"` + TotalPages int `json:"totalPages"` +} + +// OK sends a successful response with data +func OK(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + }) +} + +// OKWithMeta sends a successful response with data and pagination +func OKWithMeta(c *gin.Context, data interface{}, meta *Meta) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + Meta: meta, + }) +} + +// Created sends a 201 Created response +func Created(c *gin.Context, data interface{}) { + c.JSON(http.StatusCreated, Response{ + Success: true, + Data: data, + }) +} + +// NoContent sends a 204 No Content response +func NoContent(c *gin.Context) { + c.Status(http.StatusNoContent) +} + +// BadRequest sends a 400 Bad Request response +func BadRequest(c *gin.Context, message string) { + c.JSON(http.StatusBadRequest, Response{ + Success: false, + Error: &ErrorInfo{ + Code: "BAD_REQUEST", + Message: message, + }, + }) +} + +// Unauthorized sends a 401 Unauthorized response +func Unauthorized(c *gin.Context, message string) { + c.JSON(http.StatusUnauthorized, Response{ + Success: false, + Error: &ErrorInfo{ + Code: "UNAUTHORIZED", + Message: message, + }, + }) +} + +// Forbidden sends a 403 Forbidden response +func Forbidden(c *gin.Context, message string) { + c.JSON(http.StatusForbidden, Response{ + Success: false, + Error: &ErrorInfo{ + Code: "FORBIDDEN", + Message: message, + }, + }) +} + +// NotFound sends a 404 Not Found response +func NotFound(c *gin.Context, message string) { + c.JSON(http.StatusNotFound, Response{ + Success: false, + Error: &ErrorInfo{ + Code: "NOT_FOUND", + Message: message, + }, + }) +} + +// InternalError sends a 500 Internal Server Error response +func InternalError(c *gin.Context, message string) { + c.JSON(http.StatusInternalServerError, Response{ + Success: false, + Error: &ErrorInfo{ + Code: "INTERNAL_ERROR", + Message: message, + }, + }) +} + +// ValidationError sends a 422 Unprocessable Entity response +func ValidationError(c *gin.Context, message string, details string) { + c.JSON(http.StatusUnprocessableEntity, Response{ + Success: false, + Error: &ErrorInfo{ + Code: "VALIDATION_ERROR", + Message: message, + Details: details, + }, + }) +} + +// CalculateTotalPages calculates total pages from count and page size +func CalculateTotalPages(totalCount int64, pageSize int) int { + if pageSize <= 0 { + return 0 + } + pages := int(totalCount) / pageSize + if int(totalCount)%pageSize > 0 { + pages++ + } + return pages +}