From 33e49e1a00ec497a1ec58a34427d88482ccc6f1a Mon Sep 17 00:00:00 2001 From: Ruslan Popov Date: Wed, 19 Nov 2025 01:28:34 +0300 Subject: [PATCH] Sketch --- go.mod | 28 ++++++ go.sum | 72 ++++++++++++++++ src/internal/config/base.go | 63 ++++++++++++++ src/internal/config/database.go | 43 +++++++++ src/internal/config/environment.go | 96 +++++++++++++++++++++ src/internal/config/interface.go | 17 ++++ src/internal/gateway/handler.go | 134 +++++++++++++++++++++++++++++ src/internal/gateway/ws_queue.go | 65 ++++++++++++++ src/logic/business.go | 18 ++++ src/main.go | 89 +++++++++++++++++++ src/models/base.go | 7 ++ src/models/packets.go | 27 ++++++ 12 files changed, 659 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 src/internal/config/base.go create mode 100644 src/internal/config/database.go create mode 100644 src/internal/config/environment.go create mode 100644 src/internal/config/interface.go create mode 100644 src/internal/gateway/handler.go create mode 100644 src/internal/gateway/ws_queue.go create mode 100644 src/logic/business.go create mode 100644 src/main.go create mode 100644 src/models/base.go create mode 100644 src/models/packets.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0e847cd --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module backend + +go 1.24.9 + +require ( + entgo.io/ent v0.14.5 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/rs/cors v1.11.1 +) + +require ( + ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/go-openapi/inflect v0.19.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/hashicorp/hcl/v2 v2.18.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/zclconf/go-cty v1.14.4 // indirect + github.com/zclconf/go-cty-yaml v1.1.0 // indirect + golang.org/x/mod v0.23.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d332dff --- /dev/null +++ b/go.sum @@ -0,0 +1,72 @@ +ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= +ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= +entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +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/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= +github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= +github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= +github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= +github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= +golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +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/src/internal/config/base.go b/src/internal/config/base.go new file mode 100644 index 0000000..891df7e --- /dev/null +++ b/src/internal/config/base.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + "log/slog" + "os" + "reflect" + "text/tabwriter" + "time" + + "github.com/joho/godotenv" +) + +// LoadConfig загружает конфиг из .env (если есть) и окружения. +func LoadConfig(logger *slog.Logger) (*Config, error) { + _ = godotenv.Load() // необязательно фейлиться, если файла нет + + cfg := &Config{ + Timezone: GetEnvAs("TIMEZONE", "UTC", ParseString), + ServiceURL: GetEnvAs("SERVICE_URL", "http://localhost:8080", ParseString), + LoggingConfig: LoggingConfig{ + Instance: logger, + Level: GetEnvAs("LOG_LEVEL", "info", ParseString), + }, + DatabaseConfig: FillDatabaseConfig(), + } + + printConfig(cfg) + return cfg, nil +} + +// PrintConfig выводит конфигурацию (или любой другой struct) в виде таблички "KEY - VALUE". +// Функция использует рефлексию для перебора полей структуры. +func printConfig(cfg any) { + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "Loaded configuration:") + fmt.Fprintln(w, "KEY\tVALUE") + fmt.Fprintln(w, "----\t-----") + + // Получаем reflect.Value объекта. + v := reflect.ValueOf(cfg) + // Если передан указатель, получаем значение, на которое он указывает. + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + t := v.Type() + + // Перебираем все поля структуры. + for i := 0; i < v.NumField(); i++ { + fieldName := t.Field(i).Name + fieldValue := v.Field(i).Interface() + + // Если поле имеет тип time.Duration, выводим его в виде строки. + if d, ok := fieldValue.(time.Duration); ok { + fieldValue = d.String() + } + + fmt.Fprintf(w, "%s:\t%v\n", fieldName, fieldValue) + } + fmt.Fprintln(w, "----\t-----") + w.Flush() +} diff --git a/src/internal/config/database.go b/src/internal/config/database.go new file mode 100644 index 0000000..6ea959f --- /dev/null +++ b/src/internal/config/database.go @@ -0,0 +1,43 @@ +package config + +import ( + "fmt" + "time" +) + +type DatabaseConfig struct { + Kind string + Host string + Port string + User string + Password string + Name string + UseTLS bool + Timeout time.Duration +} + +func FillDatabaseConfig() DatabaseConfig { + return DatabaseConfig{ + Kind: GetEnvAs("DATABASE_KIND", "postgres", ParseString), + Host: GetEnvAs("DATABASE_HOST", "localhost", ParseString), + Port: GetEnvAs("DATABASE_PORT", "5432", ParseString), + User: GetEnvAs("DATABASE_USER", "chudovo", ParseString), + Password: GetEnvAs("DATABASE_PASS", "top_secret", ParseString), + Name: GetEnvAs("DATABASE_NAME", "chudovo", ParseString), + UseTLS: GetEnvAs("DATABASE_USETLS", false, ParseBool), + } +} + +func GetDatabaseDSN(cfg *DatabaseConfig) string { + dsn := fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name, + ) + + // Если TLS отключен, добавляем параметр sslmode=disable + if !cfg.UseTLS { + dsn += " sslmode=disable" + } + + return dsn +} diff --git a/src/internal/config/environment.go b/src/internal/config/environment.go new file mode 100644 index 0000000..6e6a215 --- /dev/null +++ b/src/internal/config/environment.go @@ -0,0 +1,96 @@ +package config + +import ( + "fmt" + "log/slog" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// Универсальная функция получения значения из окружения с парсером. +func GetEnvAs[T any](key string, defaultVal T, parse func(string) (T, error)) T { + raw, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(raw) == "" { + return defaultVal + } + v, err := parse(raw) + if err != nil { + // одна строка лога достаточно — не дублируем + slog.Warn(fmt.Sprintf("cannot parse env %q, using default", key), + "key", key, "raw", raw, "default", defaultVal, "error", err) + return defaultVal + } + return v +} + +/* ====== БАЗОВЫЕ АДАПТЕРЫ ====== */ + +func ParseString(s string) (string, error) { // просто возвращаем trimmed string + return strings.TrimSpace(s), nil +} + +func ParseBool(s string) (bool, error) { + return strconv.ParseBool(strings.TrimSpace(s)) +} + +func ParseInt(s string) (int, error) { + i64, err := strconv.ParseInt(strings.TrimSpace(s), 10, 0) + return int(i64), err +} + +func ParseInt64(s string) (int64, error) { + return strconv.ParseInt(strings.TrimSpace(s), 10, 64) +} + +func ParseUint(s string) (uint, error) { + u64, err := strconv.ParseUint(strings.TrimSpace(s), 10, 0) + return uint(u64), err +} + +func ParseFloat64(s string) (float64, error) { + return strconv.ParseFloat(strings.TrimSpace(s), 64) +} + +func ParseDuration(s string) (time.Duration, error) { + // поддерживает "150ms", "2s", "1m", "24h" + return time.ParseDuration(strings.TrimSpace(s)) +} + +func ParseTimeRFC3339(s string) (time.Time, error) { + return time.Parse(time.RFC3339, strings.TrimSpace(s)) +} + +func ParseURL(s string) (*url.URL, error) { + return url.Parse(strings.TrimSpace(s)) +} + +/* ====== СПИСКИ (CSV/SEPARATOR) ====== */ + +// Универсальный адаптер для списков с произвольным парсером элемента. +func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(string) ([]T, error) { + return func(s string) ([]T, error) { + s = strings.TrimSpace(s) + if s == "" { + var zero []T + return zero, nil + } + parts := strings.Split(s, sep) + out := make([]T, 0, len(parts)) + for _, p := range parts { + v, err := itemParser(p) + if err != nil { + return nil, fmt.Errorf("cannot parse list item %q: %w", p, err) + } + out = append(out, v) + } + return out, nil + } +} + +// Частые случаи: +var ParseCSVStrings = MakeListParser[string](",", ParseString) +var ParseCSVInts = MakeListParser[int](",", ParseInt) +var ParseCSVFloat64 = MakeListParser[float64](",", ParseFloat64) diff --git a/src/internal/config/interface.go b/src/internal/config/interface.go new file mode 100644 index 0000000..ab6d8e6 --- /dev/null +++ b/src/internal/config/interface.go @@ -0,0 +1,17 @@ +package config + +import "log/slog" + +type LoggingConfig struct { + Instance *slog.Logger + Level string + ShowCanDump bool +} + +type Config struct { + Timezone string + ServiceURL string + + LoggingConfig + DatabaseConfig +} diff --git a/src/internal/gateway/handler.go b/src/internal/gateway/handler.go new file mode 100644 index 0000000..6e55af7 --- /dev/null +++ b/src/internal/gateway/handler.go @@ -0,0 +1,134 @@ +package gateway + +import ( + "backend/src/internal/config" + "backend/src/logic" + "backend/src/models" + "context" + "encoding/json" + "log" + "net/http" + "sync" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// глобальный хаб соединений WebSocket +var hub sync.Map + +// Настройки для обновления соединения до WebSocket +var upgrader = websocket.Upgrader{ + // разрешаем соединения с любых источников + // (для продакшена стоит ограничить) + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// WebSocketHubResponse отправляет ответ отдельному приложению +func WebSocketHubResponse(packet models.ClientPacket) { + cid := packet.ClientID + msg := packet.Payload + value, ok := hub.Load(cid) + if ok { + conn := value.(*websocket.Conn) + log.Printf("[WS] Sending message: %s", msg) + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + log.Printf("[WS] Send error: %v", err) + } + } else { + log.Printf("[WS] Client <%s> unknown, skipping sending...", cid) + } +} + +// Worker обрабатывает канал подписки и отправляет результат в WebSocket +func Worker(ch models.ChannelOut) { + log.Printf("[WS] Response worker is ready") + for packet := range ch { + WebSocketHubResponse(packet) + } +} + +// Обработчик для WebSocket соединения +func WebSocketHandler(ctx context.Context, cfg *config.Config, + w http.ResponseWriter, r *http.Request, bl *logic.Business) { + logger := cfg.LoggingConfig.Instance + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error("[WS] Unable to restart connection", "error", err) + return + } + defer conn.Close() + + // рассчитываем, что клиент присылает свой идентификатор, иначе создаём + cid := r.URL.Query().Get("cid") + if cid == "" { + cid = uuid.New().String() // FIXME: или сбрасывать соединение + } + + // сохраняем соединение с клиентом в хабе + hub.Store(cid, conn) + logger.Info("[WS] Remote <%s> connected: %s", cid, conn.RemoteAddr()) + + // создаём канал для ответов в вебсокет + wsResponseChannel := make(chan models.ClientPacket, 10000) + + // Запускаем горутину, которая будет получать сообщения из канала и отправлять их клиенту. + go Worker(wsResponseChannel) + + // начинаем обработку сообщений от веб приложения + for { + messageType, message, err := conn.ReadMessage() + if err != nil { + logger.Info("[WS] Remote <%s> disconnected: %v:", cid, err) + hub.Delete(cid) + break + } + + // получаем общие поля + var commonFields models.BaseFieldT + if err := json.Unmarshal(message, &commonFields); err != nil { + log.Println("[WS] Unable unmarshal common fields:", err) + continue + } + // определяем тип сообщения + switch commonFields.Action { + case "COMMAND1": + logger.Info("[WS] COMMAND1 requested!") + /* + var request models.CoordinatorConfigurationRequestT + if err := json.Unmarshal(message, &request); err != nil { + logger.Error("[WS] Unable unmarshal CoordinatorConfigurationRequestT") + continue + } + + payload, err := bl.GetCoordinatorConfiguration(request.OrganizationID) + + var packet models.CoordinatorConfigurationResponseT + if err != nil { + packet = models.CoordinatorConfigurationResponseT{ + BaseFieldT: commonFields, + Status: 200, + Payload: *payload, + } + } else { + packet = models.CoordinatorConfigurationResponseT{ + BaseFieldT: commonFields, + Status: 400, + Payload: models.CoordinatorConfigurationT{}, + } + } + data, _ := json.Marshal(packet) + wsResponseChannel <- models.ClientPacket{ClientID: cid, Payload: data} + */ + default: + if messageType == websocket.TextMessage { + log.Printf("[WS] Got text message: %s", string(message)) + } else { + log.Printf("[WS] Got unknown message: %d", messageType) + } + } + } +} diff --git a/src/internal/gateway/ws_queue.go b/src/internal/gateway/ws_queue.go new file mode 100644 index 0000000..91c63f1 --- /dev/null +++ b/src/internal/gateway/ws_queue.go @@ -0,0 +1,65 @@ +package gateway + +import ( + "context" + "log" + + "github.com/gorilla/websocket" +) + +// ClientPacket используется для передачи пакетов через Go каналы +type ClientPacket struct { + ClientID string + Payload []byte +} + +type ChannelIn chan<- ClientPacket +type ChannelOut <-chan ClientPacket + +type WebSocketQueue struct { + ctx context.Context + ch chan ClientPacket +} + +func NewWebSocketQueue(ctx context.Context) *WebSocketQueue { + q := WebSocketQueue{ + ctx: ctx, + ch: make(chan ClientPacket, 10000), + } + + // Запускаем горутину, которая будет получать сообщения из канала + // и отправлять их клиенту. + go q.runWorker() + return &q +} + +// runWorker обрабатывает канал подписк и отправляет результат в WebSocket +func (q *WebSocketQueue) runWorker() { + // создаём канал для ответов в вебсокет + log.Printf("[WS] Response worker is ready") + for packet := range q.ch { + q.realSend(packet) + } +} + +// Send на самом деле помещает пакет в очередь отправки, которая затем +// разгребается с помощью WorkerOfQueuedResponsesToWS. +func (q *WebSocketQueue) Send(packet ClientPacket) { + q.ch <- packet +} + +// WebSocketHubResponse отправляет ответ отдельному приложению +func (q *WebSocketQueue) realSend(packet ClientPacket) { + cid := packet.ClientID + msg := packet.Payload + value, ok := hub.Load(cid) + if ok { + conn := value.(*websocket.Conn) + log.Printf("[WS] Sending message: %s", msg) + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + log.Printf("[WS] Send error: %v", err) + } + } else { + log.Printf("[WS] Client <%s> unknown, skipping sending...", cid) + } +} diff --git a/src/logic/business.go b/src/logic/business.go new file mode 100644 index 0000000..695f647 --- /dev/null +++ b/src/logic/business.go @@ -0,0 +1,18 @@ +package logic + +import ( + "backend/ent" + "context" +) + +type Business struct { + ctx context.Context + db *ent.Client +} + +func NewBusinessLogic(ctx context.Context, client *ent.Client) *Business { + return &Business{ + ctx: ctx, + db: client, + } +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..67dc0ac --- /dev/null +++ b/src/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + + "backend/ent" + "backend/src/internal/config" + "backend/src/internal/gateway" + "backend/src/logic" + + _ "github.com/lib/pq" // побочный импорт драйвера PostgreSQL + "github.com/rs/cors" +) + +const ( + AppName = "Backend" + AppVersion = "1.0.0" +) + +func main() { + var err error + logger := slog.Default() + // slog.SetLogLoggerLevel(slog.LevelDebug) + + logger.Info(fmt.Sprintf("Starting %s version %s\n", AppName, AppVersion)) + + cfg, err := config.LoadConfig(logger) + if err != nil { + logger.Error("Configuration loading error", "error", err) + return + } + + // создаем контекст с отменой для управления жизненным циклом сервиса. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // подключаемся к базе данных + dsn := config.GetDatabaseDSN(&cfg.DatabaseConfig) + db, err := ent.Open(cfg.DatabaseConfig.Kind, dsn) + if err != nil { + logger.Error("Failed opening connection to postgres", "error", err) + return + } + defer db.Close() + + // Применяем миграции + if err := db.Schema.Create(ctx); err != nil { + logger.Error("Failed creating schema resources", "error", err) + return + } + + // инициализируем бизнес логику + business := logic.NewBusinessLogic(ctx, db) + + // регистрируем обработчик WebSocket по адресу /ws + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + gateway.WebSocketHandler(ctx, cfg, w, r, business) + }) + + // создаём CORS middleware + corsMiddleware := cors.New(cors.Options{ + AllowedOrigins: []string{"http://localhost:4200"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "Authorization"}, + AllowCredentials: true, + }) + + // оборачиваем стандартный mux (DefaultServeMux) + handler := corsMiddleware.Handler(http.DefaultServeMux) + + // адрес сервера должен быть без схемы + clearURL := cfg.ServiceURL + if strings.Contains(clearURL, "://") { + if u, err := url.Parse(clearURL); err == nil && u.Host != "" { + clearURL = u.Host + } + } + + logger.Info("WebSocket server is on", cfg.ServiceURL) + if err := http.ListenAndServe(clearURL, handler); err != nil { + logger.Error("WebSocket server is unable to start", "error", err) + return + } +} diff --git a/src/models/base.go b/src/models/base.go new file mode 100644 index 0000000..2c2ea32 --- /dev/null +++ b/src/models/base.go @@ -0,0 +1,7 @@ +package models + +type BaseFieldT struct { + TaskID int `json:"taskId" binding:"required"` + Kind string `json:"kind" binding:"required"` + Action string `json:"action" binding:"required"` +} diff --git a/src/models/packets.go b/src/models/packets.go new file mode 100644 index 0000000..a46bbb2 --- /dev/null +++ b/src/models/packets.go @@ -0,0 +1,27 @@ +package models + +import "github.com/google/uuid" + +// ClientPacket используется для передачи пакетов через Go каналы +type ClientPacket struct { + ClientID string + Payload []byte +} + +// ClientRequest используется для передачи запросов от клиента +type ClientRequest struct { + TaskID uuid.UUID `json:"taskId" binding:"required"` + Kind string `json:"kind" binding:"required"` + Action string `json:"action" binding:"required"` +} + +// ClientResponse используется для передачи ответов клиенту +type ClientResponse struct { + ClientRequest + Status int `json:"status" binding:"required"` + Message string `json:"message,omitempty"` + Payload any `json:"payload,omitempty"` +} + +type ChannelIn chan<- ClientPacket +type ChannelOut <-chan ClientPacket