diff --git a/README.md b/README.md new file mode 100644 index 0000000..acd3799 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Logiflow + +Бэкенд для логистической платформы. Управление заказами на перевозку, водителями, транспортом и складами. При создании заказа строится реальный маршрут через OSRM, считается стоимость перевозки, трекинг водителя в реальном времени через WebSocket. + +## Стек + +- **Go 1.25** — Chi v5, oapi-codegen, pgx/v5, go-redis +- **PostgreSQL** — миграции через Goose +- **Redis** — хранение JWT access/refresh токенов +- **Nominatim** — геокодинг адресов (OpenStreetMap, без ключа) +- **OSRM** — построение маршрутов и расчёт дистанции +- **Prometheus + Grafana** — мониторинг +- **Podman** — контейнеризация + +## Запуск + +```bash +# поднять все сервисы +make up + +# пересобрать и поднять +make build-up + +# остановить +make down + +# для деплоя (pull + build + up) +make deploy +``` + +Перед запуском создать сеть: +```bash +podman network create LogiflowNetwork +``` + +Скопировать `.env` и заполнить секреты: +```bash +cp .env.example .env +``` + +Обязательные переменные: +``` +LOGIFLOW_DATABASE_USER= +LOGIFLOW_DATABASE_PASSWORD= +LOGIFLOW_DATABASE_NAME= +LOGIFLOW_REDIS_PASSWORD= +LOGIFLOW_JWT_KEY= +``` + +## Конфигурация + +Конфиг читается из `configs/config.toml`, переменные окружения с префиксом `LOGIFLOW_` перекрывают файл. + +```toml +[pricing] +baseFee = 5000.0 # базовая ставка, руб +perKm = 100.0 # руб/км +perKg = 15.0 # руб/кг +perM3 = 600.0 # руб/м³ +``` + +Цена заказа считается по формуле: +``` +total = baseFee + distance_km * perKm + weight_kg * perKg + volume_m3 * perM3 +``` + +## Роли + +| Роль | Возможности | +|---|---| +| `client` | Создаёт заказы, следит за своими заявками | +| `driver` | Меняет свой статус, видит назначенные маршруты | +| `manager` | Назначает водителей на заказы | +| `admin` | Создаёт профили водителей и менеджеров, видит всё | + +Клиенты регистрируются сами через `POST /auth/register`. Водителей и менеджеров создаёт только администратор. + +## Авторизация + +JWT (HS256) + refresh токены. Access токен живёт 24 часа, refresh — 7 дней в HTTP-only cookie. Оба хранятся в Redis — при логауте удаляются. + +``` +Authorization: +``` + +## Флоу заказа + +``` +Клиент создаёт заказ (адреса → Nominatim → координаты → OSRM → маршрут) + ↓ +Менеджер назначает водителя (pending → assigned) + ↓ +Водитель начинает поездку (assigned → in_transit) + ↓ +Трекинг по WebSocket — current_index двигается по массиву координат + ↓ +Водитель завершает (in_transit → delivered) +``` + +## Структура БД + +``` +users + ├── drivers (user_id) → vehicles + └── managers (user_id) → warehouses + +orders (created_by_id → users, driver_id → drivers, manager_id → managers) + └── routes (order_id) — JSONB координаты маршрута, current_index + +notifications (user_id → users) +``` + +Сервер поднимается на `localhost:3001`, Grafana на `localhost:3000`, Prometheus на `localhost:9090`. diff --git a/go.mod b/go.mod index b3ee56c..325da92 100644 --- a/go.mod +++ b/go.mod @@ -22,13 +22,13 @@ require ( github.com/rs/xid v1.6.0 github.com/samber/slog-chi v1.19.0 golang.org/x/crypto v0.48.0 + golang.org/x/sync v0.19.0 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/doppiogancio/go-nominatim v2.0.1+incompatible // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect @@ -47,7 +47,6 @@ require ( go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 387ca8f..7aa4e93 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,6 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/doppiogancio/go-nominatim v2.0.1+incompatible h1:S0PYXIVKwV6vF+JNBSO6T7BdvH4Ktuaf5HVL51i0lyQ= -github.com/doppiogancio/go-nominatim v2.0.1+incompatible/go.mod h1:lePiHgediF5zQ6qRyynsDoilUOBVFSpOpsWiDd21Dic= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/internal/handler/order.go b/internal/handler/order.go index cf87963..6317cf5 100644 --- a/internal/handler/order.go +++ b/internal/handler/order.go @@ -12,6 +12,7 @@ import ( storage "github.com/anxi0uz/logiflow/pkg" "github.com/google/uuid" openapi_types "github.com/oapi-codegen/runtime/types" + "golang.org/x/sync/errgroup" ) func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.ListOrdersParams) {} @@ -31,16 +32,26 @@ func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) { return } - originLat, originLon, err := s.geocode(ctx, *req.OriginAddress) - if err != nil { - slog.ErrorContext(ctx, "Failed to geocode origin", slog.String("error", err.Error())) - s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты адреса отправки", RespError) - return - } - destLat, destLon, err := s.geocode(ctx, req.DestinationAddress) - if err != nil { - slog.ErrorContext(ctx, "Failed to geocode destination", slog.String("error", err.Error())) - s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты адреса назначения", RespError) + var ( + originLat, originLon float64 + destLat, destLon float64 + ) + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + originLat, originLon, err = s.geocode(gctx, *req.OriginAddress) + return err + }) + + g.Go(func() error { + var err error + destLat, destLon, err = s.geocode(gctx, req.DestinationAddress) + return err + }) + + if err := g.Wait(); err != nil { + s.JSON(w, r, http.StatusBadRequest, "Не удалось определить координаты", RespError) return } @@ -75,14 +86,14 @@ func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) { route := osrmResult.Routes[0] distanceKm := route.Distance / 1000 - var weightKm, volumeM3 float64 + var weightKg, volumeM3 float64 if req.WeightKg != nil { - weightKm = float64(*req.WeightKg) + weightKg = float64(*req.WeightKg) } if req.VolumeM3 != nil { volumeM3 = float64(*req.VolumeM3) } - price := s.Config.Pricing.BaseFee + distanceKm*s.Config.Pricing.PerKg + weightKm*s.Config.Pricing.PerKg + volumeM3*s.Config.Pricing.PerM3 + price := s.Config.Pricing.BaseFee + distanceKm*s.Config.Pricing.PerKm + weightKg*s.Config.Pricing.PerKg + volumeM3*s.Config.Pricing.PerM3 now := time.Now orderID := uuid.New()