This commit is contained in:
madaoxs
2026-01-13 17:55:36 +08:00
commit e348e845f2
14 changed files with 700 additions and 0 deletions

20
xiangj-adapter/Dockerfile Normal file
View File

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

View File

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

View File

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

View File

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

31
xiangj-adapter/go.mod Normal file
View File

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

76
xiangj-adapter/go.sum Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package model
type APIResponse struct {
Data any `json:"data"`
Error string `json:"error"`
}

View File

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

View File

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