commit e348e845f258156135e377489a491659e7a45d0c Author: madaoxs Date: Tue Jan 13 17:55:36 2026 +0800 init diff --git a/xiangj-adapter/Dockerfile b/xiangj-adapter/Dockerfile new file mode 100644 index 0000000..280daae --- /dev/null +++ b/xiangj-adapter/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.25 AS builder +WORKDIR /app +ENV GOPROXY=https://goproxy.cn,direct + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o /app/server ./cmd/server +RUN mkdir -p /app/log + +FROM gcr.io/distroless/base-debian12 +WORKDIR /app +COPY --from=builder /app/server /app/server +COPY --from=builder /app/config.yaml /app/config.yaml +COPY --from=builder /app/log /app/log + +EXPOSE 21056 +ENTRYPOINT ["/app/server"] +CMD ["-config", "/app/config.yaml"] diff --git a/xiangj-adapter/cmd/server/main.go b/xiangj-adapter/cmd/server/main.go new file mode 100644 index 0000000..5f01f2a --- /dev/null +++ b/xiangj-adapter/cmd/server/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log/slog" + "os" + + "github.com/gofiber/fiber/v2" + fiblogger "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" + + "xiangj-adapter/internal/apperror" + "xiangj-adapter/internal/config" + "xiangj-adapter/internal/controller" + "xiangj-adapter/internal/db" + "xiangj-adapter/internal/logger" + "xiangj-adapter/internal/model" + "xiangj-adapter/internal/router" + "xiangj-adapter/internal/service" +) + +func main() { + configPath := flag.String("config", "config.yaml", "config file path") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + log, err := logger.New(logger.Options{ + Level: cfg.Log.Level, + Output: cfg.Log.Output, + FilePath: cfg.Log.FilePath, + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + pg, err := db.NewPostgres(db.PostgresConfig{ + Address: cfg.Database.Address, + Username: cfg.Database.Username, + Password: cfg.Database.Password, + Database: cfg.Database.Database, + SSLMode: cfg.Database.SSLMode, + MaxOpenConns: cfg.Database.MaxOpenConns, + MaxIdleConns: cfg.Database.MaxIdleConns, + ConnMaxLifetime: cfg.Database.ConnMaxLifetime, + ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime, + }) + if err != nil { + log.Error("database init failed", "err", err) + os.Exit(1) + } + defer pg.Close() + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + return handleError(c, err, log) + }, + }) + + app.Use(recover.New()) + app.Use(fiblogger.New()) + + dataService := service.NewDataService(pg, cfg.Database.Schema) + dataController := controller.NewDataController(dataService, log) + router.Register(app, dataController) + + log.Info("server starting", "address", cfg.Server.Address) + if err := app.Listen(cfg.Server.Address); err != nil { + log.Error("server stopped", "err", err) + os.Exit(1) + } +} + +func handleError(c *fiber.Ctx, err error, log *slog.Logger) error { + status := fiber.StatusInternalServerError + message := "internal error" + + var appErr *apperror.AppError + if errors.As(err, &appErr) { + status = appErr.Status + message = appErr.Message + } else if fiberErr, ok := err.(*fiber.Error); ok { + status = fiberErr.Code + message = fiberErr.Message + } + + log.Error("request failed", "status", status, "path", c.Path(), "err", err) + return c.Status(status).JSON(model.APIResponse{Data: nil, Error: message}) +} diff --git a/xiangj-adapter/config.yaml b/xiangj-adapter/config.yaml new file mode 100644 index 0000000..30452e3 --- /dev/null +++ b/xiangj-adapter/config.yaml @@ -0,0 +1,17 @@ +server: + address: "0.0.0.0:21056" +log: + level: "info" + output: "console" + file_path: "/app/log/app.log" +database: + address: "192.168.40.242:5432" + username: "postgres" + password: "root123" + database: "ser" + schema: "event" + sslmode: "disable" + max_open_conns: 10 + max_idle_conns: 5 + conn_max_lifetime: "30m" + conn_max_idle_time: "5m" diff --git a/xiangj-adapter/docker-compose.yml b/xiangj-adapter/docker-compose.yml new file mode 100644 index 0000000..9bee416 --- /dev/null +++ b/xiangj-adapter/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.8" + +services: + xiangj-adapter: + build: + context: . + dockerfile: Dockerfile + ports: + - "21056:21056" + volumes: + - ./config.yaml:/app/config.yaml:ro + - ./logs:/app/log + restart: unless-stopped diff --git a/xiangj-adapter/go.mod b/xiangj-adapter/go.mod new file mode 100644 index 0000000..143afd0 --- /dev/null +++ b/xiangj-adapter/go.mod @@ -0,0 +1,31 @@ +module xiangj-adapter + +go 1.25 + +require ( + github.com/gofiber/fiber/v2 v2.52.9 + github.com/jackc/pgx/v5 v5.6.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/xiangj-adapter/go.sum b/xiangj-adapter/go.sum new file mode 100644 index 0000000..0c4fd07 --- /dev/null +++ b/xiangj-adapter/go.sum @@ -0,0 +1,76 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= +github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.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= +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/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= diff --git a/xiangj-adapter/internal/apperror/apperror.go b/xiangj-adapter/internal/apperror/apperror.go new file mode 100644 index 0000000..42eba51 --- /dev/null +++ b/xiangj-adapter/internal/apperror/apperror.go @@ -0,0 +1,20 @@ +package apperror + +import "github.com/gofiber/fiber/v2" + +type AppError struct { + Status int + Message string +} + +func (e *AppError) Error() string { + return e.Message +} + +func BadRequest(message string) *AppError { + return &AppError{Status: fiber.StatusBadRequest, Message: message} +} + +func Internal(message string) *AppError { + return &AppError{Status: fiber.StatusInternalServerError, Message: message} +} diff --git a/xiangj-adapter/internal/config/config.go b/xiangj-adapter/internal/config/config.go new file mode 100644 index 0000000..f179b64 --- /dev/null +++ b/xiangj-adapter/internal/config/config.go @@ -0,0 +1,88 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Log LogConfig `yaml:"log"` +} + +type ServerConfig struct { + Address string `yaml:"address"` +} + +type DatabaseConfig struct { + Address string `yaml:"address"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Database string `yaml:"database"` + Schema string `yaml:"schema"` + SSLMode string `yaml:"sslmode"` + MaxOpenConns int `yaml:"max_open_conns"` + MaxIdleConns int `yaml:"max_idle_conns"` + ConnMaxLifetime string `yaml:"conn_max_lifetime"` + ConnMaxIdleTime string `yaml:"conn_max_idle_time"` +} + +type LogConfig struct { + Level string `yaml:"level"` + Output string `yaml:"output"` + FilePath string `yaml:"file_path"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + applyDefaults(&cfg) + return &cfg, nil +} + +func applyDefaults(cfg *Config) { + if cfg.Server.Address == "" { + cfg.Server.Address = ":21056" + } + if cfg.Log.Level == "" { + cfg.Log.Level = "info" + } + if cfg.Log.Output == "" { + cfg.Log.Output = "console" + } + if cfg.Log.FilePath == "" { + cfg.Log.FilePath = "/app/log/app.log" + } + if cfg.Database.MaxOpenConns == 0 { + cfg.Database.MaxOpenConns = 10 + } + if cfg.Database.MaxIdleConns == 0 { + cfg.Database.MaxIdleConns = 5 + } + if cfg.Database.Address == "" { + cfg.Database.Address = "localhost:5432" + } + if cfg.Database.Schema == "" { + cfg.Database.Schema = "public" + } + if cfg.Database.SSLMode == "" { + cfg.Database.SSLMode = "disable" + } + if cfg.Database.ConnMaxLifetime == "" { + cfg.Database.ConnMaxLifetime = "30m" + } + if cfg.Database.ConnMaxIdleTime == "" { + cfg.Database.ConnMaxIdleTime = "5m" + } +} diff --git a/xiangj-adapter/internal/controller/data_controller.go b/xiangj-adapter/internal/controller/data_controller.go new file mode 100644 index 0000000..7576bdc --- /dev/null +++ b/xiangj-adapter/internal/controller/data_controller.go @@ -0,0 +1,106 @@ +package controller + +import ( + "log/slog" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + + "xiangj-adapter/internal/apperror" + "xiangj-adapter/internal/model" + "xiangj-adapter/internal/service" +) + +type DataController struct { + service *service.DataService + log *slog.Logger +} + +func NewDataController(service *service.DataService, log *slog.Logger) *DataController { + return &DataController{service: service, log: log} +} + +func (c *DataController) GetData(ctx *fiber.Ctx) error { + op := strings.ToLower(ctx.Query("op")) + if op == "" { + op = "select" + } + switch op { + case "select": + return c.handleSelect(ctx) + default: + return apperror.BadRequest("op not supported") + } +} + +func (c *DataController) handleSelect(ctx *fiber.Ctx) error { + doid := ctx.Query("doid") + table := strings.ToLower(lastSegment(doid)) + if table == "" { + return apperror.BadRequest("doid is required") + } + + offset, err := parseNonNegativeIntDefault(ctx.Query("offset"), "offset", 0) + if err != nil { + return err + } + + count, err := parsePositiveIntDefault(ctx.Query("count"), "count", 100) + if err != nil { + return err + } + + data, err := c.service.Select(ctx.Context(), table, offset, count) + if err != nil { + c.log.Error("select failed", "table", table, "err", err) + return apperror.Internal("query failed") + } + + return ctx.JSON(model.APIResponse{Data: data, Error: ""}) +} + +func lastSegment(input string) string { + trimmed := strings.Trim(input, "/") + if trimmed == "" { + return "" + } + parts := strings.Split(trimmed, "/") + return parts[len(parts)-1] +} + +func parseNonNegativeInt(value, field string) (int, error) { + if value == "" { + return 0, apperror.BadRequest(field + " is required") + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 0 { + return 0, apperror.BadRequest(field + " must be a non-negative integer") + } + return parsed, nil +} + +func parsePositiveInt(value, field string) (int, error) { + if value == "" { + return 0, apperror.BadRequest(field + " is required") + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return 0, apperror.BadRequest(field + " must be a positive integer") + } + return parsed, nil +} + +func parseNonNegativeIntDefault(value, field string, defaultValue int) (int, error) { + if value == "" { + return defaultValue, nil + } + return parseNonNegativeInt(value, field) +} + +func parsePositiveIntDefault(value, field string, defaultValue int) (int, error) { + if value == "" { + return defaultValue, nil + } + return parsePositiveInt(value, field) +} diff --git a/xiangj-adapter/internal/db/postgres.go b/xiangj-adapter/internal/db/postgres.go new file mode 100644 index 0000000..58141a8 --- /dev/null +++ b/xiangj-adapter/internal/db/postgres.go @@ -0,0 +1,93 @@ +package db + +import ( + "database/sql" + "fmt" + "net" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +type PostgresConfig struct { + Address string + Username string + Password string + Database string + SSLMode string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime string + ConnMaxIdleTime string +} + +func NewPostgres(cfg PostgresConfig) (*sql.DB, error) { + if cfg.Address == "" { + return nil, fmt.Errorf("database address is required") + } + if cfg.Username == "" { + return nil, fmt.Errorf("database username is required") + } + if cfg.Database == "" { + return nil, fmt.Errorf("database name is required") + } + + host, port := parseAddress(cfg.Address) + if host == "" { + return nil, fmt.Errorf("database address is invalid") + } + + connMaxLifetime, err := parseDuration(cfg.ConnMaxLifetime) + if err != nil { + return nil, fmt.Errorf("conn_max_lifetime: %w", err) + } + connMaxIdleTime, err := parseDuration(cfg.ConnMaxIdleTime) + if err != nil { + return nil, fmt.Errorf("conn_max_idle_time: %w", err) + } + + dsn := fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + host, port, cfg.Username, cfg.Password, cfg.Database, cfg.SSLMode, + ) + + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, fmt.Errorf("open postgres: %w", err) + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(connMaxLifetime) + db.SetConnMaxIdleTime(connMaxIdleTime) + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping postgres: %w", err) + } + + return db, nil +} + +func parseAddress(address string) (string, string) { + address = strings.TrimSpace(address) + if address == "" { + return "", "" + } + + if strings.Contains(address, ":") { + host, port, err := net.SplitHostPort(address) + if err == nil && host != "" && port != "" { + return host, port + } + } + + return address, "5432" +} + +func parseDuration(value string) (time.Duration, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + return time.ParseDuration(value) +} diff --git a/xiangj-adapter/internal/logger/logger.go b/xiangj-adapter/internal/logger/logger.go new file mode 100644 index 0000000..791288d --- /dev/null +++ b/xiangj-adapter/internal/logger/logger.go @@ -0,0 +1,44 @@ +package logger + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" +) + +type Options struct { + Level string + Output string + FilePath string +} + +func New(opts Options) (*slog.Logger, error) { + var lvl slog.Level + if err := lvl.UnmarshalText([]byte(opts.Level)); err != nil { + lvl = slog.LevelInfo + } + + output := strings.ToLower(strings.TrimSpace(opts.Output)) + switch output { + case "", "console": + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + return slog.New(handler), nil + case "file": + if strings.TrimSpace(opts.FilePath) == "" { + return nil, fmt.Errorf("log file path is required") + } + if err := os.MkdirAll(filepath.Dir(opts.FilePath), 0o755); err != nil { + return nil, fmt.Errorf("create log directory: %w", err) + } + file, err := os.OpenFile(opts.FilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("open log file: %w", err) + } + handler := slog.NewTextHandler(file, &slog.HandlerOptions{Level: lvl}) + return slog.New(handler), nil + default: + return nil, fmt.Errorf("unsupported log output: %s", opts.Output) + } +} diff --git a/xiangj-adapter/internal/model/response.go b/xiangj-adapter/internal/model/response.go new file mode 100644 index 0000000..a349437 --- /dev/null +++ b/xiangj-adapter/internal/model/response.go @@ -0,0 +1,6 @@ +package model + +type APIResponse struct { + Data any `json:"data"` + Error string `json:"error"` +} diff --git a/xiangj-adapter/internal/router/router.go b/xiangj-adapter/internal/router/router.go new file mode 100644 index 0000000..ed30442 --- /dev/null +++ b/xiangj-adapter/internal/router/router.go @@ -0,0 +1,11 @@ +package router + +import ( + "github.com/gofiber/fiber/v2" + + "xiangj-adapter/internal/controller" +) + +func Register(app *fiber.App, dataController *controller.DataController) { + app.Get("/data", dataController.GetData) +} diff --git a/xiangj-adapter/internal/service/data_service.go b/xiangj-adapter/internal/service/data_service.go new file mode 100644 index 0000000..83d093f --- /dev/null +++ b/xiangj-adapter/internal/service/data_service.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "regexp" +) + +var tableNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +type DataService struct { + db *sql.DB + schema string +} + +func NewDataService(db *sql.DB, schema string) *DataService { + return &DataService{db: db, schema: schema} +} + +func (s *DataService) Select(ctx context.Context, table string, offset, count int) ([]map[string]any, error) { + if !tableNameRegex.MatchString(table) { + return nil, fmt.Errorf("invalid table name") + } + if s.schema != "" && !tableNameRegex.MatchString(s.schema) { + return nil, fmt.Errorf("invalid schema name") + } + + qualifiedTable := table + if s.schema != "" { + qualifiedTable = fmt.Sprintf("%s.%s", s.schema, table) + } + + query := fmt.Sprintf("SELECT * FROM %s LIMIT $2 OFFSET $1", qualifiedTable) + rows, err := s.db.QueryContext(ctx, query, offset, count) + if err != nil { + return nil, fmt.Errorf("query table %s: %w", table, err) + } + defer rows.Close() + + return scanRows(rows) +} + +func scanRows(rows *sql.Rows) ([]map[string]any, error) { + columns, err := rows.Columns() + if err != nil { + return nil, fmt.Errorf("read columns: %w", err) + } + + results := make([]map[string]any, 0) + values := make([]any, len(columns)) + valuePtrs := make([]any, len(columns)) + for i := range values { + valuePtrs[i] = &values[i] + } + + for rows.Next() { + if err := rows.Scan(valuePtrs...); err != nil { + return nil, fmt.Errorf("scan row: %w", err) + } + + row := make(map[string]any, len(columns)) + for i, col := range columns { + val := values[i] + if b, ok := val.([]byte); ok { + row[col] = string(b) + continue + } + row[col] = val + } + results = append(results, row) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } + + return results, nil +}