Compare commits

...

2 Commits

Author SHA1 Message Date
1252b7a0d1 CICD preparation 2025-11-19 01:29:36 +03:00
1b9bb09ca4 Sketch 2025-11-19 01:29:21 +03:00
17 changed files with 842 additions and 0 deletions

14
.bumpversion.cfg Normal file
View File

@@ -0,0 +1,14 @@
[bumpversion]
current_version = 1.0.0
commit = True
tag = True
tag_name = {new_version}
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
serialize = {major}.{minor}.{patch}
message = [skip ci] Bump version: {current_version} → {new_version}
[bumpversion:file:VERSION]
[bumpversion:file:README.md]
[bumpversion:file:src/main.go]

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git/
.git*
docs/
.bumpversion.cfg
.editorconfig
.drone.yml
Dockerfile
Makefile
*.md
*.txt

145
.drone.yml Normal file
View File

@@ -0,0 +1,145 @@
---
kind: pipeline
type: docker
name: backend-service
platform:
os: linux
arch: amd64
steps:
- name: "[branch] bump version"
image: registry.halfakop.ru/golang/bumpversion:0.1.3
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_EMAIL:
from_secret: GIT_EMAIL
entrypoint: ["/bumpversion"]
command: ["--no-commit", "--no-tag", "patch"]
load: false # do not use /bin/sh -c here
when:
branch:
- task-*
event:
- push
- name: "[master] bump version"
image: registry.halfakop.ru/golang/bumpversion:0.1.3
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_EMAIL:
from_secret: GIT_EMAIL
entrypoint: ["/bumpversion"]
command: ["patch"]
load: false # do not use /bin/sh -c here
when:
branch:
- master
event:
- push
- name: "[master] build"
image: plugins/docker
volumes:
- name: docker-sock
path: /var/run/docker.sock
environment:
DOCKER_USERNAME:
from_secret: DOCKER_USERNAME
DOCKER_PASSWORD:
from_secret: DOCKER_PASSWORD
commands:
- apk add make git bash
- make login build DOCKER_OPTS="--network devtools"
when:
branch:
- master
status:
- success
- name: "[master] tagging"
image: alpine/git
commands:
- git push origin master
- git push origin master --tags
when:
branch:
- master
status:
- success
- name: "[master] push"
image: plugins/docker
volumes:
- name: docker-sock
path: /var/run/docker.sock
environment:
DOCKER_USERNAME:
from_secret: DOCKER_USERNAME
DOCKER_PASSWORD:
from_secret: DOCKER_PASSWORD
commands:
- apk add make git bash
- make login push DOCKER_OPTS="--network devtools"
when:
branch:
- master
status:
- success
- name: "[release] get deployment repo"
image: plugins/ansible:1
commands:
- git clone https://git.halfakop.ru/3dthis.ru/deployment.git
when:
branch:
- release
event:
- push
- name: "[release] check deployment playbook"
image: plugins/ansible:1
environment:
SERVICE_NAME: backend
settings:
playbook: deployment/deployment.yml
inventory: deployment/inventory_service.yml
syntax_check: true
when:
branch:
- release
status:
- success
- name: "[release] deploy service"
image: plugins/ansible:1
environment:
SERVICE_NAME: backend
DATABASE_URL:
from_secret: DATABASE_URL
settings:
playbook: deployment/deployment.yml
inventory: deployment/inventory_service.yml
private_key:
from_secret: SSHKEY
verbose: 1 # 0 .. 4
when:
branch:
- release
status:
- success
trigger:
event:
exclude:
- tag
volumes:
- name: docker-sock
host:
path: /var/run/docker.sock
image_pull_secrets:
- dockerconfigjson

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM golang:1.24.9-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o service ./src
FROM gcr.io/distroless/static
WORKDIR /
COPY --from=builder /app/service .
ENTRYPOINT [ "/service"]

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.0.0

28
go.mod Normal file
View File

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

72
go.sum Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.bumpversion.cfg
}
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 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)
}
}

18
src/logic/business.go Normal file
View File

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

89
src/main.go Normal file
View File

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

7
src/models/base.go Normal file
View File

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

27
src/models/packets.go Normal file
View File

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