feat(go-backend): complete all Go models

- Add scan-related models: ScanLog, ScanInputTarget, ScheduledScan, SubfinderProviderSettings
- Add engine models: Wordlist, NucleiTemplateRepo
- Add notification models: Notification, NotificationSettings
- Add config model: BlacklistRule
- Add statistics models: AssetStatistics, StatisticsHistory
- Add auth models: User (auth_user), Session (django_session)
- Add shopspring/decimal dependency for Vulnerability model
- Update model_test.go with all 33 model table name tests
- All tests passing
This commit is contained in:
yyhuni
2026-01-11 20:29:11 +08:00
parent 5345a34cbd
commit 5b0416972a
47 changed files with 2542 additions and 0 deletions

24
go-backend/.env.example Normal file
View File

@@ -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

40
go-backend/Makefile Normal file
View File

@@ -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 ./...

BIN
go-backend/bin/server Executable file

Binary file not shown.

View File

@@ -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")
}

66
go-backend/go.mod Normal file
View File

@@ -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
)

187
go-backend/go.sum Normal file
View File

@@ -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=

View File

@@ -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",
},
}
}

View File

@@ -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())
}
}

View File

@@ -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
}

View File

@@ -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",
})
}

View File

@@ -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 ""
}

View File

@@ -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()
}
}

View File

@@ -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"
}

View File

@@ -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"
)

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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)
}
}

View File

@@ -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"
)

View File

@@ -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,
}
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
)

View File

@@ -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"
}

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": ""},
}
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
)

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
)

View File

@@ -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"),
})
}

View File

@@ -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
}