From 030af7ca839a9bd3aa7af4efde32a9d11f6ae758 Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Thu, 18 Dec 2025 21:13:49 +0300 Subject: [PATCH] init --- transport/README.md | 256 ++++++++++ transport/backend/Dockerfile | 18 + transport/backend/alembic.ini | 42 ++ transport/backend/alembic/env.py | 61 +++ transport/backend/alembic/script.py.mako | 26 + .../backend/alembic/versions/001_initial.py | 75 +++ transport/backend/app/__init__.py | 0 transport/backend/app/config.py | 16 + transport/backend/app/database.py | 23 + transport/backend/app/main.py | 50 ++ transport/backend/app/models/__init__.py | 5 + transport/backend/app/models/event.py | 24 + transport/backend/app/models/position.py | 24 + transport/backend/app/models/vehicle.py | 18 + transport/backend/app/routers/__init__.py | 0 transport/backend/app/routers/events.py | 61 +++ transport/backend/app/routers/positions.py | 103 ++++ transport/backend/app/routers/vehicles.py | 139 +++++ transport/backend/app/schemas/__init__.py | 9 + transport/backend/app/schemas/event.py | 14 + transport/backend/app/schemas/position.py | 29 ++ transport/backend/app/schemas/vehicle.py | 38 ++ transport/backend/app/services/__init__.py | 0 .../app/services/connection_checker.py | 83 +++ .../backend/app/services/event_detector.py | 55 ++ .../backend/app/services/websocket_manager.py | 63 +++ transport/backend/app/websocket.py | 18 + transport/backend/requirements.txt | 11 + transport/backend/simulator/__init__.py | 0 transport/backend/simulator/run.py | 121 +++++ transport/docker-compose.yml | 57 +++ transport/docs/TECHNICAL_SPECIFICATION.md | 477 ++++++++++++++++++ transport/frontend/Dockerfile | 16 + transport/frontend/index.html | 14 + transport/frontend/package.json | 23 + transport/frontend/src/App.vue | 289 +++++++++++ .../frontend/src/components/EventFeed.vue | 153 ++++++ transport/frontend/src/components/MapView.vue | 204 ++++++++ .../frontend/src/components/TrackHistory.vue | 155 ++++++ .../frontend/src/components/VehicleCard.vue | 111 ++++ .../frontend/src/components/VehicleList.vue | 121 +++++ transport/frontend/src/main.js | 11 + transport/frontend/vite.config.js | 13 + transport/nginx/conf.d/default.conf | 51 ++ transport/nginx/nginx.conf | 29 ++ 45 files changed, 3106 insertions(+) create mode 100644 transport/README.md create mode 100644 transport/backend/Dockerfile create mode 100644 transport/backend/alembic.ini create mode 100644 transport/backend/alembic/env.py create mode 100644 transport/backend/alembic/script.py.mako create mode 100644 transport/backend/alembic/versions/001_initial.py create mode 100644 transport/backend/app/__init__.py create mode 100644 transport/backend/app/config.py create mode 100644 transport/backend/app/database.py create mode 100644 transport/backend/app/main.py create mode 100644 transport/backend/app/models/__init__.py create mode 100644 transport/backend/app/models/event.py create mode 100644 transport/backend/app/models/position.py create mode 100644 transport/backend/app/models/vehicle.py create mode 100644 transport/backend/app/routers/__init__.py create mode 100644 transport/backend/app/routers/events.py create mode 100644 transport/backend/app/routers/positions.py create mode 100644 transport/backend/app/routers/vehicles.py create mode 100644 transport/backend/app/schemas/__init__.py create mode 100644 transport/backend/app/schemas/event.py create mode 100644 transport/backend/app/schemas/position.py create mode 100644 transport/backend/app/schemas/vehicle.py create mode 100644 transport/backend/app/services/__init__.py create mode 100644 transport/backend/app/services/connection_checker.py create mode 100644 transport/backend/app/services/event_detector.py create mode 100644 transport/backend/app/services/websocket_manager.py create mode 100644 transport/backend/app/websocket.py create mode 100644 transport/backend/requirements.txt create mode 100644 transport/backend/simulator/__init__.py create mode 100644 transport/backend/simulator/run.py create mode 100644 transport/docker-compose.yml create mode 100644 transport/docs/TECHNICAL_SPECIFICATION.md create mode 100644 transport/frontend/Dockerfile create mode 100644 transport/frontend/index.html create mode 100644 transport/frontend/package.json create mode 100644 transport/frontend/src/App.vue create mode 100644 transport/frontend/src/components/EventFeed.vue create mode 100644 transport/frontend/src/components/MapView.vue create mode 100644 transport/frontend/src/components/TrackHistory.vue create mode 100644 transport/frontend/src/components/VehicleCard.vue create mode 100644 transport/frontend/src/components/VehicleList.vue create mode 100644 transport/frontend/src/main.js create mode 100644 transport/frontend/vite.config.js create mode 100644 transport/nginx/conf.d/default.conf create mode 100644 transport/nginx/nginx.conf diff --git a/transport/README.md b/transport/README.md new file mode 100644 index 0000000..2d54236 --- /dev/null +++ b/transport/README.md @@ -0,0 +1,256 @@ +# 🚗 Система мониторинга транспорта + +Веб-система для мониторинга местоположения транспортных средств в реальном времени. + +## 🛠 Технологии + +- **Backend**: FastAPI, SQLAlchemy, PostgreSQL +- **Frontend**: Vue 3, Leaflet, Naive UI +- **Инфраструктура**: Docker, Nginx + +## 🚀 Быстрый старт + +### Требования +- Docker 20.10+ +- Docker Compose 2.0+ + +### Запуск (пошагово) + +```bash +# 1. Перейти в папку проекта +cd transport + +# 2. Собрать и запустить все сервисы +docker-compose up --build -d + +# 3. Проверить что все сервисы запущены +docker-compose ps + +# Должно быть 4 сервиса в статусе "Up": +# - transport-nginx-1 +# - transport-backend-1 +# - transport-frontend-1 +# - transport-postgres-1 + +# 4. Проверить что API работает +curl http://localhost/api/vehicles + +# Должен вернуть JSON со списком транспорта + +# 5. Открыть приложение в браузере +open http://localhost +``` + +### Запуск симулятора + +В **отдельном терминале** запустить симулятор данных: + +```bash +docker-compose exec backend python -m simulator.run +``` + +Симулятор будет: +- Генерировать координаты для 5 транспортных средств +- Отправлять данные каждые 2 секунды +- Имитировать движение, остановки, изменение скорости + +Для остановки: `Ctrl+C` + +### Доступ + +| Сервис | URL | +|--------|-----| +| Приложение | http://localhost | +| API | http://localhost/api/ | +| API Docs (Swagger) | http://localhost/api/docs | +| WebSocket | ws://localhost/ws/positions | + +## 🔧 Полезные команды + +```bash +# Посмотреть логи всех сервисов +docker-compose logs -f + +# Посмотреть логи только backend +docker-compose logs -f backend + +# Перезапустить сервис +docker-compose restart backend + +# Остановить все сервисы +docker-compose down + +# Остановить и удалить данные (включая БД) +docker-compose down -v + +# Пересобрать конкретный сервис +docker-compose up --build backend -d +``` + +## 📡 Работа с API + +### Создать транспорт +```bash +curl -X POST http://localhost/api/vehicles \ + -H "Content-Type: application/json" \ + -d '{"name": "Автобус А-999", "type": "bus"}' +``` + +### Отправить позицию +```bash +curl -X POST http://localhost/api/ingest/position \ + -H "Content-Type: application/json" \ + -d '{ + "vehicle_id": 1, + "lat": 55.0304, + "lon": 82.9204, + "speed": 45.5, + "heading": 180 + }' +``` + +### Получить список транспорта +```bash +curl http://localhost/api/vehicles +``` + +### Получить историю позиций +```bash +curl "http://localhost/api/vehicles/1/positions?from=2025-12-18T00:00:00" +``` + +### Получить события +```bash +curl http://localhost/api/events +``` + +## 📁 Структура проекта + +``` +transport/ +├── docker-compose.yml # Конфигурация Docker +├── nginx/ # Nginx конфигурация +│ ├── nginx.conf +│ └── conf.d/default.conf +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── app/ # FastAPI приложение +│ │ ├── main.py # Точка входа +│ │ ├── models/ # SQLAlchemy модели +│ │ ├── schemas/ # Pydantic схемы +│ │ ├── routers/ # API эндпоинты +│ │ └── services/ # Бизнес-логика +│ ├── alembic/ # Миграции БД +│ └── simulator/ # Симулятор данных +│ └── run.py +└── frontend/ + ├── Dockerfile + ├── package.json + └── src/ + ├── main.js + ├── App.vue # Главный компонент + └── components/ # Vue компоненты + ├── MapView.vue + ├── VehicleList.vue + ├── VehicleCard.vue + ├── TrackHistory.vue + └── EventFeed.vue +``` + +## 🔌 REST API + +| Метод | URL | Описание | +|-------|-----|----------| +| GET | /api/vehicles | Список транспорта с последними позициями | +| GET | /api/vehicles/{id} | Информация о конкретном ТС | +| POST | /api/vehicles | Создать новое ТС | +| PUT | /api/vehicles/{id} | Обновить ТС | +| DELETE | /api/vehicles/{id} | Удалить ТС | +| GET | /api/vehicles/{id}/positions | История позиций ТС | +| POST | /api/ingest/position | Принять новую позицию | +| GET | /api/events | Список событий | + +### Типы транспорта + +| type | Иконка | +|------|--------| +| `bus` | 🚌 | +| `truck` | 🚚 | +| `car` | 🚗 | + +## 📨 WebSocket API + +Подключение: `ws://localhost/ws/positions` + +### Формат сообщений + +**Обновление позиции:** +```json +{ + "type": "position_update", + "data": { + "vehicle_id": 1, + "lat": 55.0304, + "lon": 82.9204, + "speed": 45.5, + "heading": 180, + "timestamp": "2025-12-18T12:00:00Z" + } +} +``` + +**Событие:** +```json +{ + "type": "event", + "data": { + "id": 1, + "vehicle_id": 1, + "type": "OVERSPEED", + "payload": {"speed": 95, "limit": 60}, + "timestamp": "2025-12-18T12:00:00Z" + } +} +``` + +### Типы событий + +| Тип | Описание | +|-----|----------| +| `OVERSPEED` | Превышение скорости (> 60 км/ч) | +| `LONG_STOP` | Остановка более 5 минут | +| `CONNECTION_LOST` | Нет данных более 5 минут | + +## 🐛 Troubleshooting + +### API возвращает 404 +```bash +# Проверить что backend запущен +docker-compose ps + +# Перезапустить nginx +docker-compose restart nginx +``` + +### Нет данных на карте +```bash +# Проверить что симулятор запущен +docker-compose exec backend python -m simulator.run + +# Проверить логи backend +docker-compose logs -f backend +``` + +### Ошибка подключения к БД +```bash +# Проверить что postgres запущен +docker-compose ps postgres + +# Посмотреть логи postgres +docker-compose logs postgres +``` + +## 📝 Лицензия + +MIT diff --git a/transport/backend/Dockerfile b/transport/backend/Dockerfile new file mode 100644 index 0000000..fd0774a --- /dev/null +++ b/transport/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Run migrations and start server +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] diff --git a/transport/backend/alembic.ini b/transport/backend/alembic.ini new file mode 100644 index 0000000..133e28a --- /dev/null +++ b/transport/backend/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = postgresql://postgres:postgres@postgres:5432/transport + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/transport/backend/alembic/env.py b/transport/backend/alembic/env.py new file mode 100644 index 0000000..e8f78ad --- /dev/null +++ b/transport/backend/alembic/env.py @@ -0,0 +1,61 @@ +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Import models to ensure they are registered with Base +from app.database import Base +from app.models import Vehicle, Position, Event + +config = context.config + +# Override sqlalchemy.url from environment if available +database_url = os.getenv("DATABASE_URL") +if database_url: + config.set_main_option("sqlalchemy.url", database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/transport/backend/alembic/script.py.mako b/transport/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/transport/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/transport/backend/alembic/versions/001_initial.py b/transport/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..85c7d5a --- /dev/null +++ b/transport/backend/alembic/versions/001_initial.py @@ -0,0 +1,75 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2025-12-18 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '001' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create vehicles table + op.create_table( + 'vehicles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('type', sa.String(50), nullable=True, default='car'), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + # Create positions table + op.create_table( + 'positions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('vehicle_id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('lat', sa.Float(), nullable=False), + sa.Column('lon', sa.Float(), nullable=False), + sa.Column('speed', sa.Float(), nullable=True, default=0.0), + sa.Column('heading', sa.Float(), nullable=True, default=0.0), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_positions_vehicle_ts', 'positions', ['vehicle_id', 'timestamp']) + + # Create events table + op.create_table( + 'events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('vehicle_id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('type', sa.String(50), nullable=False), + sa.Column('payload', postgresql.JSONB(), nullable=True, default={}), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_events_vehicle_ts', 'events', ['vehicle_id', 'timestamp']) + op.create_index('idx_events_type', 'events', ['type']) + + # Insert demo vehicles + op.execute(""" + INSERT INTO vehicles (name, type, created_at) VALUES + ('Автобус А-101', 'bus', NOW()), + ('Автобус А-102', 'bus', NOW()), + ('Грузовик Г-201', 'truck', NOW()), + ('Легковой Л-301', 'car', NOW()), + ('Легковой Л-302', 'car', NOW()) + """) + + +def downgrade() -> None: + op.drop_table('events') + op.drop_table('positions') + op.drop_table('vehicles') diff --git a/transport/backend/app/__init__.py b/transport/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/transport/backend/app/config.py b/transport/backend/app/config.py new file mode 100644 index 0000000..97086e3 --- /dev/null +++ b/transport/backend/app/config.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str = "postgresql://postgres:postgres@localhost:5432/transport" + + # Event detection settings + long_stop_minutes: int = 5 + overspeed_limit: float = 60.0 # km/h + connection_lost_minutes: int = 5 + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/transport/backend/app/database.py b/transport/backend/app/database.py new file mode 100644 index 0000000..5da9b0c --- /dev/null +++ b/transport/backend/app/database.py @@ -0,0 +1,23 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +# Convert postgresql:// to postgresql+asyncpg:// +database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://") + +engine = create_async_engine(database_url, echo=False) + +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with async_session_maker() as session: + try: + yield session + finally: + await session.close() diff --git a/transport/backend/app/main.py b/transport/backend/app/main.py new file mode 100644 index 0000000..9f12a16 --- /dev/null +++ b/transport/backend/app/main.py @@ -0,0 +1,50 @@ +import asyncio +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routers import vehicles, positions, events +from app.websocket import router as ws_router +from app.services.connection_checker import connection_checker_loop + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: запуск фоновых задач + task = asyncio.create_task(connection_checker_loop()) + yield + # Shutdown: остановка фоновых задач + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +app = FastAPI( + title="Transport Monitoring API", + description="API для системы мониторинга транспорта", + version="1.0.0", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"]) +app.include_router(positions.router, tags=["positions"]) +app.include_router(events.router, prefix="/events", tags=["events"]) +app.include_router(ws_router) + + +@app.get("/health") +async def health_check(): + return {"status": "ok"} diff --git a/transport/backend/app/models/__init__.py b/transport/backend/app/models/__init__.py new file mode 100644 index 0000000..998a6e6 --- /dev/null +++ b/transport/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.vehicle import Vehicle +from app.models.position import Position +from app.models.event import Event + +__all__ = ["Vehicle", "Position", "Event"] diff --git a/transport/backend/app/models/event.py b/transport/backend/app/models/event.py new file mode 100644 index 0000000..ae88f92 --- /dev/null +++ b/transport/backend/app/models/event.py @@ -0,0 +1,24 @@ +from datetime import datetime +from sqlalchemy import DateTime, String, ForeignKey, Index +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Event(Base): + __tablename__ = "events" + + id: Mapped[int] = mapped_column(primary_key=True) + vehicle_id: Mapped[int] = mapped_column(ForeignKey("vehicles.id", ondelete="CASCADE")) + timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + type: Mapped[str] = mapped_column(String(50), nullable=False) # LONG_STOP, OVERSPEED, CONNECTION_LOST + payload: Mapped[dict] = mapped_column(JSONB, default=dict) + + # Relationships + vehicle: Mapped["Vehicle"] = relationship(back_populates="events") + + __table_args__ = ( + Index("idx_events_vehicle_ts", "vehicle_id", "timestamp"), + Index("idx_events_type", "type"), + ) diff --git a/transport/backend/app/models/position.py b/transport/backend/app/models/position.py new file mode 100644 index 0000000..912cdd2 --- /dev/null +++ b/transport/backend/app/models/position.py @@ -0,0 +1,24 @@ +from datetime import datetime +from sqlalchemy import DateTime, Float, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Position(Base): + __tablename__ = "positions" + + id: Mapped[int] = mapped_column(primary_key=True) + vehicle_id: Mapped[int] = mapped_column(ForeignKey("vehicles.id", ondelete="CASCADE")) + timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + lat: Mapped[float] = mapped_column(Float, nullable=False) + lon: Mapped[float] = mapped_column(Float, nullable=False) + speed: Mapped[float] = mapped_column(Float, default=0.0) # km/h + heading: Mapped[float] = mapped_column(Float, default=0.0) # 0-360 degrees + + # Relationships + vehicle: Mapped["Vehicle"] = relationship(back_populates="positions") + + __table_args__ = ( + Index("idx_positions_vehicle_ts", "vehicle_id", "timestamp"), + ) diff --git a/transport/backend/app/models/vehicle.py b/transport/backend/app/models/vehicle.py new file mode 100644 index 0000000..0c314ef --- /dev/null +++ b/transport/backend/app/models/vehicle.py @@ -0,0 +1,18 @@ +from datetime import datetime +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Vehicle(Base): + __tablename__ = "vehicles" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + type: Mapped[str] = mapped_column(String(50), default="car") # car, bus, truck + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + positions: Mapped[list["Position"]] = relationship(back_populates="vehicle", cascade="all, delete-orphan") + events: Mapped[list["Event"]] = relationship(back_populates="vehicle", cascade="all, delete-orphan") diff --git a/transport/backend/app/routers/__init__.py b/transport/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/transport/backend/app/routers/events.py b/transport/backend/app/routers/events.py new file mode 100644 index 0000000..95cc5ce --- /dev/null +++ b/transport/backend/app/routers/events.py @@ -0,0 +1,61 @@ +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models import Event +from app.schemas import EventResponse + +router = APIRouter() + + +@router.get("", response_model=list[EventResponse]) +async def get_events( + type: Optional[str] = None, + vehicle_id: Optional[int] = None, + from_time: Optional[datetime] = Query(None, alias="from"), + to_time: Optional[datetime] = Query(None, alias="to"), + limit: int = Query(100, le=1000), + db: AsyncSession = Depends(get_db) +): + """Получить список событий""" + query = select(Event) + + if type: + query = query.where(Event.type == type) + if vehicle_id: + query = query.where(Event.vehicle_id == vehicle_id) + if from_time: + query = query.where(Event.timestamp >= from_time) + if to_time: + query = query.where(Event.timestamp <= to_time) + + query = query.order_by(desc(Event.timestamp)).limit(limit) + + result = await db.execute(query) + events = result.scalars().all() + + return events + + +@router.get("/vehicles/{vehicle_id}/events", response_model=list[EventResponse]) +async def get_vehicle_events( + vehicle_id: int, + type: Optional[str] = None, + limit: int = Query(100, le=1000), + db: AsyncSession = Depends(get_db) +): + """Получить события конкретного транспортного средства""" + query = select(Event).where(Event.vehicle_id == vehicle_id) + + if type: + query = query.where(Event.type == type) + + query = query.order_by(desc(Event.timestamp)).limit(limit) + + result = await db.execute(query) + events = result.scalars().all() + + return events diff --git a/transport/backend/app/routers/positions.py b/transport/backend/app/routers/positions.py new file mode 100644 index 0000000..796b707 --- /dev/null +++ b/transport/backend/app/routers/positions.py @@ -0,0 +1,103 @@ +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, desc, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models import Vehicle, Position +from app.schemas import PositionResponse, PositionIngest +from app.services.websocket_manager import manager +from app.services.event_detector import detect_events + +router = APIRouter() + + +@router.get("/vehicles/{vehicle_id}/positions", response_model=list[PositionResponse]) +async def get_vehicle_positions( + vehicle_id: int, + from_time: Optional[datetime] = Query(None, alias="from"), + to_time: Optional[datetime] = Query(None, alias="to"), + limit: int = Query(1000, le=10000), + db: AsyncSession = Depends(get_db) +): + """Получить историю позиций транспортного средства""" + # Check vehicle exists + result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id)) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Vehicle not found") + + # Build query + query = select(Position).where(Position.vehicle_id == vehicle_id) + + if from_time: + query = query.where(Position.timestamp >= from_time) + if to_time: + query = query.where(Position.timestamp <= to_time) + + query = query.order_by(desc(Position.timestamp)).limit(limit) + + result = await db.execute(query) + positions = result.scalars().all() + + return positions + + +@router.get("/vehicles/{vehicle_id}/last-position", response_model=Optional[PositionResponse]) +async def get_last_position(vehicle_id: int, db: AsyncSession = Depends(get_db)): + """Получить последнюю позицию транспортного средства""" + result = await db.execute( + select(Position) + .where(Position.vehicle_id == vehicle_id) + .order_by(desc(Position.timestamp)) + .limit(1) + ) + position = result.scalar_one_or_none() + + if not position: + return None + + return position + + +@router.post("/ingest/position", response_model=PositionResponse, status_code=201) +async def ingest_position(position: PositionIngest, db: AsyncSession = Depends(get_db)): + """Принять новую позицию от трекера/симулятора""" + # Check vehicle exists + result = await db.execute(select(Vehicle).where(Vehicle.id == position.vehicle_id)) + vehicle = result.scalar_one_or_none() + + if not vehicle: + raise HTTPException(status_code=404, detail="Vehicle not found") + + # Get previous position for event detection + prev_result = await db.execute( + select(Position) + .where(Position.vehicle_id == position.vehicle_id) + .order_by(desc(Position.timestamp)) + .limit(1) + ) + prev_position = prev_result.scalar_one_or_none() + + # Create new position + db_position = Position( + vehicle_id=position.vehicle_id, + timestamp=position.timestamp or datetime.utcnow(), + lat=position.lat, + lon=position.lon, + speed=position.speed, + heading=position.heading + ) + db.add(db_position) + await db.commit() + await db.refresh(db_position) + + # Detect events + events = await detect_events(db, vehicle, db_position, prev_position) + + # Broadcast to WebSocket clients + await manager.broadcast_position(db_position) + for event in events: + await manager.broadcast_event(event) + + return db_position diff --git a/transport/backend/app/routers/vehicles.py b/transport/backend/app/routers/vehicles.py new file mode 100644 index 0000000..e36b104 --- /dev/null +++ b/transport/backend/app/routers/vehicles.py @@ -0,0 +1,139 @@ +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models import Vehicle, Position +from app.schemas import VehicleCreate, VehicleUpdate, VehicleResponse, VehicleWithPosition +from app.schemas.vehicle import LastPosition + +router = APIRouter() + + +def get_vehicle_status(last_position: Optional[Position], now: datetime) -> str: + if not last_position: + return "offline" + + time_diff = now - last_position.timestamp + if time_diff > timedelta(minutes=5): + return "offline" + elif last_position.speed < 2: + return "stopped" + else: + return "moving" + + +@router.get("", response_model=list[VehicleWithPosition]) +async def get_vehicles(db: AsyncSession = Depends(get_db)): + """Получить список всех транспортных средств с последними позициями""" + result = await db.execute(select(Vehicle)) + vehicles = result.scalars().all() + + response = [] + now = datetime.utcnow() + + for vehicle in vehicles: + # Get last position + pos_result = await db.execute( + select(Position) + .where(Position.vehicle_id == vehicle.id) + .order_by(desc(Position.timestamp)) + .limit(1) + ) + last_pos = pos_result.scalar_one_or_none() + + vehicle_data = VehicleWithPosition( + id=vehicle.id, + name=vehicle.name, + type=vehicle.type, + created_at=vehicle.created_at, + last_position=LastPosition( + lat=last_pos.lat, + lon=last_pos.lon, + speed=last_pos.speed, + heading=last_pos.heading, + timestamp=last_pos.timestamp + ) if last_pos else None, + status=get_vehicle_status(last_pos, now) + ) + response.append(vehicle_data) + + return response + + +@router.get("/{vehicle_id}", response_model=VehicleWithPosition) +async def get_vehicle(vehicle_id: int, db: AsyncSession = Depends(get_db)): + """Получить информацию о транспортном средстве""" + result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id)) + vehicle = result.scalar_one_or_none() + + if not vehicle: + raise HTTPException(status_code=404, detail="Vehicle not found") + + # Get last position + pos_result = await db.execute( + select(Position) + .where(Position.vehicle_id == vehicle.id) + .order_by(desc(Position.timestamp)) + .limit(1) + ) + last_pos = pos_result.scalar_one_or_none() + now = datetime.utcnow() + + return VehicleWithPosition( + id=vehicle.id, + name=vehicle.name, + type=vehicle.type, + created_at=vehicle.created_at, + last_position=LastPosition( + lat=last_pos.lat, + lon=last_pos.lon, + speed=last_pos.speed, + heading=last_pos.heading, + timestamp=last_pos.timestamp + ) if last_pos else None, + status=get_vehicle_status(last_pos, now) + ) + + +@router.post("", response_model=VehicleResponse, status_code=201) +async def create_vehicle(vehicle: VehicleCreate, db: AsyncSession = Depends(get_db)): + """Создать новое транспортное средство""" + db_vehicle = Vehicle(**vehicle.model_dump()) + db.add(db_vehicle) + await db.commit() + await db.refresh(db_vehicle) + return db_vehicle + + +@router.put("/{vehicle_id}", response_model=VehicleResponse) +async def update_vehicle(vehicle_id: int, vehicle: VehicleUpdate, db: AsyncSession = Depends(get_db)): + """Обновить транспортное средство""" + result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id)) + db_vehicle = result.scalar_one_or_none() + + if not db_vehicle: + raise HTTPException(status_code=404, detail="Vehicle not found") + + update_data = vehicle.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_vehicle, field, value) + + await db.commit() + await db.refresh(db_vehicle) + return db_vehicle + + +@router.delete("/{vehicle_id}", status_code=204) +async def delete_vehicle(vehicle_id: int, db: AsyncSession = Depends(get_db)): + """Удалить транспортное средство""" + result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id)) + db_vehicle = result.scalar_one_or_none() + + if not db_vehicle: + raise HTTPException(status_code=404, detail="Vehicle not found") + + await db.delete(db_vehicle) + await db.commit() diff --git a/transport/backend/app/schemas/__init__.py b/transport/backend/app/schemas/__init__.py new file mode 100644 index 0000000..de6c5ea --- /dev/null +++ b/transport/backend/app/schemas/__init__.py @@ -0,0 +1,9 @@ +from app.schemas.vehicle import VehicleCreate, VehicleUpdate, VehicleResponse, VehicleWithPosition +from app.schemas.position import PositionCreate, PositionResponse, PositionIngest +from app.schemas.event import EventResponse + +__all__ = [ + "VehicleCreate", "VehicleUpdate", "VehicleResponse", "VehicleWithPosition", + "PositionCreate", "PositionResponse", "PositionIngest", + "EventResponse" +] diff --git a/transport/backend/app/schemas/event.py b/transport/backend/app/schemas/event.py new file mode 100644 index 0000000..856ea3f --- /dev/null +++ b/transport/backend/app/schemas/event.py @@ -0,0 +1,14 @@ +from datetime import datetime +from typing import Any +from pydantic import BaseModel + + +class EventResponse(BaseModel): + id: int + vehicle_id: int + timestamp: datetime + type: str + payload: dict[str, Any] + + class Config: + from_attributes = True diff --git a/transport/backend/app/schemas/position.py b/transport/backend/app/schemas/position.py new file mode 100644 index 0000000..ffa7606 --- /dev/null +++ b/transport/backend/app/schemas/position.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class PositionBase(BaseModel): + lat: float + lon: float + speed: float = 0.0 + heading: float = 0.0 + + +class PositionCreate(PositionBase): + vehicle_id: int + timestamp: Optional[datetime] = None + + +class PositionIngest(PositionBase): + vehicle_id: int + timestamp: Optional[datetime] = None + + +class PositionResponse(PositionBase): + id: int + vehicle_id: int + timestamp: datetime + + class Config: + from_attributes = True diff --git a/transport/backend/app/schemas/vehicle.py b/transport/backend/app/schemas/vehicle.py new file mode 100644 index 0000000..ea205e1 --- /dev/null +++ b/transport/backend/app/schemas/vehicle.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class VehicleBase(BaseModel): + name: str + type: str = "car" + + +class VehicleCreate(VehicleBase): + pass + + +class VehicleUpdate(BaseModel): + name: Optional[str] = None + type: Optional[str] = None + + +class VehicleResponse(VehicleBase): + id: int + created_at: datetime + + class Config: + from_attributes = True + + +class LastPosition(BaseModel): + lat: float + lon: float + speed: float + heading: float + timestamp: datetime + + +class VehicleWithPosition(VehicleResponse): + last_position: Optional[LastPosition] = None + status: str = "unknown" # moving, stopped, offline diff --git a/transport/backend/app/services/__init__.py b/transport/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/transport/backend/app/services/connection_checker.py b/transport/backend/app/services/connection_checker.py new file mode 100644 index 0000000..66f54a9 --- /dev/null +++ b/transport/backend/app/services/connection_checker.py @@ -0,0 +1,83 @@ +""" +Фоновая задача для проверки потери связи с объектами. +Создаёт события CONNECTION_LOST если нет данных более N минут. +""" + +import asyncio +from datetime import datetime, timedelta +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import async_session_maker +from app.models import Vehicle, Position, Event +from app.config import settings +from app.services.websocket_manager import manager + + +async def check_connections(): + """Проверить все объекты на потерю связи""" + async with async_session_maker() as db: + # Получить все объекты + result = await db.execute(select(Vehicle)) + vehicles = result.scalars().all() + + now = datetime.utcnow() + threshold = now - timedelta(minutes=settings.connection_lost_minutes) + + for vehicle in vehicles: + # Получить последнюю позицию + pos_result = await db.execute( + select(Position) + .where(Position.vehicle_id == vehicle.id) + .order_by(desc(Position.timestamp)) + .limit(1) + ) + last_pos = pos_result.scalar_one_or_none() + + if not last_pos: + continue + + # Проверить, прошло ли достаточно времени + if last_pos.timestamp < threshold: + # Проверить, не было ли уже события CONNECTION_LOST за последние N минут + event_result = await db.execute( + select(Event) + .where(Event.vehicle_id == vehicle.id) + .where(Event.type == "CONNECTION_LOST") + .where(Event.timestamp > threshold) + .limit(1) + ) + existing_event = event_result.scalar_one_or_none() + + if not existing_event: + # Создать событие + minutes_ago = (now - last_pos.timestamp).total_seconds() / 60 + event = Event( + vehicle_id=vehicle.id, + timestamp=now, + type="CONNECTION_LOST", + payload={ + "last_seen": last_pos.timestamp.isoformat(), + "minutes_ago": round(minutes_ago, 1), + "lat": last_pos.lat, + "lon": last_pos.lon + } + ) + db.add(event) + await db.commit() + await db.refresh(event) + + # Отправить через WebSocket + await manager.broadcast_event(event) + print(f"⚠️ CONNECTION_LOST: {vehicle.name} (нет данных {minutes_ago:.0f} мин)") + + +async def connection_checker_loop(): + """Бесконечный цикл проверки соединений""" + print("🔍 Connection checker started") + while True: + try: + await check_connections() + except Exception as e: + print(f"Connection checker error: {e}") + await asyncio.sleep(60) # Проверять каждую минуту diff --git a/transport/backend/app/services/event_detector.py b/transport/backend/app/services/event_detector.py new file mode 100644 index 0000000..09384ff --- /dev/null +++ b/transport/backend/app/services/event_detector.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Vehicle, Position, Event +from app.config import settings + + +async def detect_events( + db: AsyncSession, + vehicle: Vehicle, + current: Position, + previous: Optional[Position] +) -> list[Event]: + """Обнаружение событий на основе новой позиции""" + events = [] + + # Check for overspeed + if current.speed > settings.overspeed_limit: + event = Event( + vehicle_id=vehicle.id, + timestamp=current.timestamp, + type="OVERSPEED", + payload={ + "speed": current.speed, + "limit": settings.overspeed_limit, + "lat": current.lat, + "lon": current.lon + } + ) + db.add(event) + await db.commit() + await db.refresh(event) + events.append(event) + + # Check for long stop (if speed is 0 and was 0 for a while) + if previous and current.speed < 2 and previous.speed < 2: + time_diff = current.timestamp - previous.timestamp + if time_diff >= timedelta(minutes=settings.long_stop_minutes): + event = Event( + vehicle_id=vehicle.id, + timestamp=current.timestamp, + type="LONG_STOP", + payload={ + "duration_minutes": time_diff.total_seconds() / 60, + "lat": current.lat, + "lon": current.lon + } + ) + db.add(event) + await db.commit() + await db.refresh(event) + events.append(event) + + return events diff --git a/transport/backend/app/services/websocket_manager.py b/transport/backend/app/services/websocket_manager.py new file mode 100644 index 0000000..87a94c3 --- /dev/null +++ b/transport/backend/app/services/websocket_manager.py @@ -0,0 +1,63 @@ +import json +from datetime import datetime +from typing import Set +from fastapi import WebSocket + +from app.models import Position, Event + + +class ConnectionManager: + def __init__(self): + self.active_connections: Set[WebSocket] = set() + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.add(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.discard(websocket) + + async def broadcast(self, message: dict): + """Отправить сообщение всем подключенным клиентам""" + disconnected = set() + for connection in self.active_connections: + try: + await connection.send_json(message) + except Exception: + disconnected.add(connection) + + # Remove disconnected clients + self.active_connections -= disconnected + + async def broadcast_position(self, position: Position): + """Отправить обновление позиции""" + message = { + "type": "position_update", + "data": { + "vehicle_id": position.vehicle_id, + "lat": position.lat, + "lon": position.lon, + "speed": position.speed, + "heading": position.heading, + "timestamp": position.timestamp.isoformat() + } + } + await self.broadcast(message) + + async def broadcast_event(self, event: Event): + """Отправить событие""" + message = { + "type": "event", + "data": { + "id": event.id, + "vehicle_id": event.vehicle_id, + "type": event.type, + "payload": event.payload, + "timestamp": event.timestamp.isoformat() + } + } + await self.broadcast(message) + + +# Global instance +manager = ConnectionManager() diff --git a/transport/backend/app/websocket.py b/transport/backend/app/websocket.py new file mode 100644 index 0000000..d3483dd --- /dev/null +++ b/transport/backend/app/websocket.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.services.websocket_manager import manager + +router = APIRouter() + + +@router.websocket("/ws/positions") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket эндпоинт для получения обновлений позиций в реальном времени""" + await manager.connect(websocket) + try: + while True: + # Keep connection alive, wait for messages (ping/pong) + data = await websocket.receive_text() + # Can handle client messages here if needed + except WebSocketDisconnect: + manager.disconnect(websocket) diff --git a/transport/backend/requirements.txt b/transport/backend/requirements.txt new file mode 100644 index 0000000..e4ae0df --- /dev/null +++ b/transport/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +pydantic==2.5.2 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +websockets==12.0 +httpx==0.25.2 diff --git a/transport/backend/simulator/__init__.py b/transport/backend/simulator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/transport/backend/simulator/run.py b/transport/backend/simulator/run.py new file mode 100644 index 0000000..9ea8585 --- /dev/null +++ b/transport/backend/simulator/run.py @@ -0,0 +1,121 @@ +""" +Симулятор движения транспортных средств. +Генерирует реалистичные данные о перемещении объектов. +""" + +import asyncio +import random +import math +import httpx +from datetime import datetime + +# Backend API URL (внутри Docker сети, без /api — root_path только для nginx) +API_URL = "http://backend:8000" + +# Начальные координаты (Новосибирск - центр) +START_COORDS = [ + (55.0304, 82.9204), # Центр + (55.0411, 82.9344), # Север + (55.0198, 82.9064), # Юг + (55.0350, 82.8904), # Запад + (55.0250, 82.9504), # Восток +] + + +class VehicleSimulator: + def __init__(self, vehicle_id: int, start_lat: float, start_lon: float): + self.vehicle_id = vehicle_id + self.lat = start_lat + self.lon = start_lon + self.speed = random.uniform(20, 60) # km/h + self.heading = random.uniform(0, 360) # degrees + self.is_stopped = False + self.stop_duration = 0 + + def update(self): + """Обновить позицию транспортного средства""" + # Случайная остановка + if not self.is_stopped and random.random() < 0.02: # 2% шанс остановиться + self.is_stopped = True + self.stop_duration = random.randint(5, 30) # секунд + self.speed = 0 + + if self.is_stopped: + self.stop_duration -= 1 + if self.stop_duration <= 0: + self.is_stopped = False + self.speed = random.uniform(20, 60) + + if not self.is_stopped: + # Случайное изменение направления + self.heading += random.uniform(-15, 15) + self.heading = self.heading % 360 + + # Случайное изменение скорости + self.speed += random.uniform(-5, 5) + self.speed = max(10, min(90, self.speed)) # Ограничение 10-90 км/ч + + # Расчёт нового положения + # Примерно: 1 градус широты = 111 км, 1 градус долготы = 111 * cos(lat) км + speed_ms = self.speed / 3.6 # м/с + distance = speed_ms * 2 # за 2 секунды + + # Перевод в градусы + delta_lat = (distance * math.cos(math.radians(self.heading))) / 111000 + delta_lon = (distance * math.sin(math.radians(self.heading))) / (111000 * math.cos(math.radians(self.lat))) + + self.lat += delta_lat + self.lon += delta_lon + + return { + "vehicle_id": self.vehicle_id, + "lat": round(self.lat, 6), + "lon": round(self.lon, 6), + "speed": round(self.speed, 1), + "heading": round(self.heading, 1), + "timestamp": datetime.utcnow().isoformat() + } + + +async def send_position(client: httpx.AsyncClient, position: dict): + """Отправить позицию на сервер""" + try: + response = await client.post(f"{API_URL}/ingest/position", json=position) + if response.status_code == 201: + print(f"✓ Vehicle {position['vehicle_id']}: ({position['lat']}, {position['lon']}) @ {position['speed']} km/h") + else: + print(f"✗ Vehicle {position['vehicle_id']}: Error {response.status_code}") + except Exception as e: + print(f"✗ Vehicle {position['vehicle_id']}: {e}") + + +async def main(): + print("🚗 Запуск симулятора транспорта...") + print(f"📡 API URL: {API_URL}") + + # Создаём симуляторы для каждого транспортного средства + simulators = [] + for i, (lat, lon) in enumerate(START_COORDS, start=1): + sim = VehicleSimulator(vehicle_id=i, start_lat=lat, start_lon=lon) + simulators.append(sim) + print(f" → Vehicle {i}: начальная позиция ({lat}, {lon})") + + print("\n🔄 Начинаем отправку данных (Ctrl+C для остановки)...\n") + + async with httpx.AsyncClient(timeout=10.0) as client: + while True: + # Обновляем и отправляем позиции всех транспортных средств + tasks = [] + for sim in simulators: + position = sim.update() + tasks.append(send_position(client, position)) + + await asyncio.gather(*tasks) + await asyncio.sleep(2) # Интервал 2 секунды + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n\n⏹ Симулятор остановлен") diff --git a/transport/docker-compose.yml b/transport/docker-compose.yml new file mode 100644 index 0000000..430cc6a --- /dev/null +++ b/transport/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + nginx: + image: nginx:1.25-alpine + ports: + - "3000:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + depends_on: + - backend + - frontend + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + volumes: + - ./backend:/app # Для разработки — изменения без пересборки + environment: + - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/transport + - PYTHONUNBUFFERED=1 + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + environment: + - VITE_API_URL=/api + - VITE_WS_URL=/ws + restart: unless-stopped + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=transport + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres_data: diff --git a/transport/docs/TECHNICAL_SPECIFICATION.md b/transport/docs/TECHNICAL_SPECIFICATION.md new file mode 100644 index 0000000..b35ddde --- /dev/null +++ b/transport/docs/TECHNICAL_SPECIFICATION.md @@ -0,0 +1,477 @@ +# Техническое задание +## Система мониторинга транспорта в реальном времени + +--- + +## 1. Общие сведения + +| Параметр | Значение | +|----------|----------| +| Название проекта | Transport Monitoring System | +| Тип | Учебный проект | +| Версия ТЗ | 1.0 | +| Дата | 2025-12-18 | +| Срок разработки | 2 недели | +| Frontend фреймворк | Vue 3 (выбран) | + +--- + +## 2. Цели и задачи проекта + +### 2.1 Цель +Разработка веб-системы для мониторинга местоположения транспортных средств в реальном времени с визуализацией на карте и хранением истории перемещений. + +### 2.2 Задачи +- Отображение текущего положения транспорта на интерактивной карте +- Обновление позиций в реальном времени (WebSocket) +- Хранение и просмотр истории перемещений +- Генерация событий (остановки, превышение скорости) +- Симуляция данных для демонстрации работы системы + +--- + +## 3. Технологический стек + +### 3.1 Backend +| Компонент | Технология | Версия | +|-----------|------------|--------| +| Язык | Python | 3.11+ | +| Фреймворк | FastAPI | 0.104+ | +| ORM | SQLAlchemy | 2.0+ | +| Миграции | Alembic | 1.12+ | +| База данных | PostgreSQL | 15+ | +| WebSocket | FastAPI WebSockets | встроено | + +### 3.2 Frontend +| Компонент | Технология | Версия | +|-----------|------------|--------| +| Фреймворк | Vue 3 | 3.4+ | +| Сборщик | Vite | 5.0+ | +| Карты | Leaflet | 1.9+ | +| UI-компоненты | Naive UI | 2.35+ | +| HTTP-клиент | Axios | 1.6+ | + +### 3.3 Инфраструктура +| Компонент | Технология | +|-----------|------------| +| Контейнеризация | Docker + Docker Compose | +| Reverse Proxy | Nginx 1.25+ | +| Тайлы карты | OpenStreetMap | + +### 3.4 Nginx (Reverse Proxy) +Nginx выступает единой точкой входа: +- `/` → статика Vue (production) или проксирование на Vite (development) +- `/api/*` → проксирование на FastAPI backend +- `/ws/*` → проксирование WebSocket на backend + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Nginx (:80) │ +├─────────────────────────────────────────────────────────────┤ +│ / → Frontend (статика или Vite dev:5173) │ +│ /api/* → Backend (FastAPI :8000) │ +│ /ws/* → Backend WebSocket (:8000) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Функциональные требования + +### 4.1 Модуль "Диспетчер" (главный экран) + +#### FR-1.1 Карта +- [ ] Отображение интерактивной карты на базе Leaflet + OSM +- [ ] Маркеры транспортных средств с иконками по типу (автобус, грузовик, легковой) +- [ ] Всплывающая подсказка при наведении на маркер (название, скорость) +- [ ] Центрирование карты по выбранному объекту + +#### FR-1.2 Список объектов +- [ ] Боковая панель со списком всех транспортных средств +- [ ] Поиск/фильтрация по названию +- [ ] Индикатор статуса: движется (зелёный), стоит (жёлтый), нет связи (серый) +- [ ] Клик по объекту — выделение на карте + открытие карточки + +#### FR-1.3 Карточка объекта +- [ ] Название и тип ТС +- [ ] Текущие координаты (lat, lon) +- [ ] Скорость (км/ч) +- [ ] Направление движения (heading) +- [ ] Время последней точки +- [ ] Статус (движется/стоит) + +### 4.2 Модуль "История движения" + +#### FR-2.1 Запрос истории +- [ ] Выбор временного диапазона: 30 мин / 1 час / 24 часа / произвольный +- [ ] Кнопка "Показать трек" + +#### FR-2.2 Отображение трека +- [ ] Полилиния маршрута на карте +- [ ] Цветовая индикация скорости (опционально) +- [ ] Маркеры начала и конца маршрута + +#### FR-2.3 Таблица точек +- [ ] Список точек: время, координаты, скорость +- [ ] Клик по строке — центрирование карты на точке +- [ ] Экспорт в CSV (опционально) + +### 4.3 Модуль "Реалтайм обновления" + +#### FR-3.1 WebSocket соединение +- [ ] Автоматическое подключение при загрузке страницы +- [ ] Переподключение при обрыве связи +- [ ] Индикатор статуса соединения в UI + +#### FR-3.2 Обновление данных +- [ ] Плавное перемещение маркеров при получении новых координат +- [ ] Обновление данных в карточке объекта +- [ ] Обновление статусов в списке объектов + +### 4.4 Модуль "События" + +#### FR-4.1 Типы событий +- [ ] `LONG_STOP` — остановка более N минут (настраиваемый порог) +- [ ] `OVERSPEED` — превышение скорости (порог настраивается) +- [ ] `CONNECTION_LOST` — нет данных более 5 минут + +#### FR-4.2 Лента событий +- [ ] Панель с последними событиями (10-20 штук) +- [ ] Фильтр по типу события +- [ ] Клик по событию — переход к объекту на карте + +### 4.5 Модуль "Симулятор" + +#### FR-5.1 Генерация данных +- [ ] Python-скрипт для имитации движения N объектов +- [ ] Реалистичное движение по координатам (не телепортация) +- [ ] Случайные остановки и изменения скорости +- [ ] Отправка данных через REST API или WebSocket + +--- + +## 5. Архитектура системы + +### 5.1 Общая схема +``` + ┌─────────────────┐ + │ │ + │ Nginx │ :80 + │ (reverse proxy)│ + │ │ + └────────┬────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ /api, /ws │ / │ + ▼ │ ▼ +┌─────────────────┐ │ ┌─────────────────┐ ┌─────────────────┐ +│ │ │ │ │ │ │ +│ Симулятор │────▶ Backend │ │ Frontend │ +│ (Python) │POST│ (FastAPI) │ │ (Vue 3) │ +│ │ │ :8000 │ │ :5173 (dev) │ +└─────────────────┘ └────────┬────────┘ └─────────────────┘ + │ + │ SQL + ▼ + ┌─────────────────┐ + │ │ + │ PostgreSQL │ :5432 + │ │ + └─────────────────┘ +``` + +### 5.2 Структура проекта +``` +transport/ +├── docker-compose.yml +├── docs/ +│ └── TECHNICAL_SPECIFICATION.md +├── nginx/ +│ ├── nginx.conf # Основной конфиг +│ ├── conf.d/ +│ │ └── default.conf # Конфиг сервера +│ └── Dockerfile # (опционально, для кастомизации) +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── alembic/ +│ │ └── versions/ +│ ├── alembic.ini +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI приложение +│ │ ├── config.py # Настройки +│ │ ├── database.py # Подключение к БД +│ │ ├── models/ +│ │ │ ├── __init__.py +│ │ │ ├── vehicle.py +│ │ │ ├── position.py +│ │ │ └── event.py +│ │ ├── schemas/ +│ │ │ ├── __init__.py +│ │ │ ├── vehicle.py +│ │ │ ├── position.py +│ │ │ └── event.py +│ │ ├── routers/ +│ │ │ ├── __init__.py +│ │ │ ├── vehicles.py +│ │ │ ├── positions.py +│ │ │ └── events.py +│ │ ├── services/ +│ │ │ ├── __init__.py +│ │ │ ├── event_detector.py +│ │ │ └── websocket_manager.py +│ │ └── websocket.py # WS эндпоинт +│ └── simulator/ +│ └── run.py # Симулятор данных +├── frontend/ +│ ├── Dockerfile +│ ├── package.json +│ ├── vite.config.js +│ ├── index.html +│ └── src/ +│ ├── main.js +│ ├── App.vue +│ ├── components/ +│ │ ├── MapView.vue +│ │ ├── VehicleList.vue +│ │ ├── VehicleCard.vue +│ │ ├── TrackHistory.vue +│ │ └── EventFeed.vue +│ ├── composables/ +│ │ ├── useWebSocket.js +│ │ └── useVehicles.js +│ └── stores/ +│ └── vehicles.js +└── README.md +``` + +--- + +## 6. API спецификация + +### 6.1 REST API + +#### Транспортные средства + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| GET | `/api/vehicles` | Список всех ТС | +| GET | `/api/vehicles/{id}` | Информация о ТС | +| POST | `/api/vehicles` | Создать ТС | +| PUT | `/api/vehicles/{id}` | Обновить ТС | +| DELETE | `/api/vehicles/{id}` | Удалить ТС | + +#### Позиции + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| GET | `/api/vehicles/{id}/positions` | История позиций ТС | +| POST | `/api/ingest/position` | Принять новую позицию | +| GET | `/api/vehicles/{id}/last-position` | Последняя позиция ТС | + +**Query параметры для `/api/vehicles/{id}/positions`:** +- `from` — начало периода (ISO 8601) +- `to` — конец периода (ISO 8601) +- `limit` — максимум записей (default: 1000) + +#### События + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| GET | `/api/events` | Список событий | +| GET | `/api/vehicles/{id}/events` | События конкретного ТС | + +**Query параметры для `/api/events`:** +- `type` — тип события (LONG_STOP, OVERSPEED, CONNECTION_LOST) +- `from` / `to` — временной диапазон +- `limit` — максимум записей + +### 6.2 WebSocket API + +#### Подключение +``` +ws://localhost/ws/positions +``` +*(Nginx проксирует на backend:8000)* + +#### Формат сообщений (сервер → клиент) +```json +{ + "type": "position_update", + "data": { + "vehicle_id": 1, + "lat": 55.7558, + "lon": 37.6173, + "speed": 45.5, + "heading": 180, + "timestamp": "2025-12-18T12:00:00Z" + } +} +``` + +```json +{ + "type": "event", + "data": { + "id": 123, + "vehicle_id": 1, + "type": "OVERSPEED", + "payload": {"speed": 95, "limit": 60}, + "timestamp": "2025-12-18T12:00:00Z" + } +} +``` + +--- + +## 7. Структура базы данных + +### 7.1 Таблица `vehicles` +| Поле | Тип | Описание | +|------|-----|----------| +| id | SERIAL PRIMARY KEY | Идентификатор | +| name | VARCHAR(100) NOT NULL | Название/номер ТС | +| type | VARCHAR(50) | Тип: bus, truck, car | +| created_at | TIMESTAMP | Дата создания | + +### 7.2 Таблица `positions` +| Поле | Тип | Описание | +|------|-----|----------| +| id | SERIAL PRIMARY KEY | Идентификатор | +| vehicle_id | INTEGER FK | Ссылка на vehicles | +| timestamp | TIMESTAMP NOT NULL | Время фиксации | +| lat | DOUBLE PRECISION | Широта | +| lon | DOUBLE PRECISION | Долгота | +| speed | REAL | Скорость (км/ч) | +| heading | REAL | Направление (0-360) | + +**Индексы:** +- `idx_positions_vehicle_ts` ON (vehicle_id, timestamp DESC) + +### 7.3 Таблица `events` +| Поле | Тип | Описание | +|------|-----|----------| +| id | SERIAL PRIMARY KEY | Идентификатор | +| vehicle_id | INTEGER FK | Ссылка на vehicles | +| timestamp | TIMESTAMP NOT NULL | Время события | +| type | VARCHAR(50) NOT NULL | Тип события | +| payload | JSONB | Дополнительные данные | + +**Индексы:** +- `idx_events_vehicle_ts` ON (vehicle_id, timestamp DESC) +- `idx_events_type` ON (type) + +--- + +## 8. Нефункциональные требования + +### 8.1 Производительность +- Система должна поддерживать минимум 100 одновременных объектов +- Частота обновления позиций: 1 раз в 1-2 секунды на объект +- Время отклика API: < 500 мс для 95% запросов + +### 8.2 Надёжность +- WebSocket должен автоматически переподключаться при обрыве +- При недоступности БД — graceful degradation с логированием ошибок + +### 8.3 Развёртывание +- Запуск всей системы одной командой: `docker-compose up` +- Автоматическое применение миграций при старте + +### 8.4 Безопасность (упрощённо для учебного проекта) +- CORS настроен для локальной разработки +- Валидация входных данных на уровне Pydantic-схем +- (Опционально) Базовая авторизация через API-ключ + +--- + +## 9. Этапы разработки + +### Этап 1: Инфраструктура +- [ ] Настройка Docker Compose (Nginx + PostgreSQL + backend + frontend) +- [ ] Конфигурация Nginx (проксирование /api, /ws, статика) +- [ ] Базовая структура FastAPI приложения +- [ ] Подключение к БД, настройка Alembic +- [ ] Базовая структура Vue-приложения + +### Этап 2: CRUD и карта +- [ ] Модели и миграции для vehicles, positions +- [ ] REST API для vehicles +- [ ] Endpoint POST /ingest/position +- [ ] Фронт: отображение карты с маркерами +- [ ] Фронт: список объектов + карточка + +### Этап 3: Реалтайм +- [ ] WebSocket manager на бэкенде +- [ ] Broadcast новых позиций всем клиентам +- [ ] Фронт: подключение к WS, обновление маркеров +- [ ] Симулятор: базовая версия + +### Этап 4: История и события +- [ ] GET /vehicles/{id}/positions с фильтрами +- [ ] Модель events + детектор событий +- [ ] Фронт: отображение трека на карте +- [ ] Фронт: таблица точек + лента событий + +### Этап 5: Финализация +- [ ] Улучшение UI/UX +- [ ] Доработка симулятора (реалистичные маршруты) +- [ ] Тестирование под нагрузкой (100 объектов) +- [ ] Документация для запуска и демонстрации + +--- + +## 10. Запуск проекта + +### Требования +- Docker 20.10+ +- Docker Compose 2.0+ + +### Команды +```bash +# Клонировать репозиторий +git clone +cd transport + +# Запустить всё +docker-compose up --build + +# Доступ (всё через Nginx на порту 80) +# Приложение: http://localhost +# API: http://localhost/api +# API Docs: http://localhost/api/docs +# WebSocket: ws://localhost/ws/positions + +# Запустить симулятор (в отдельном терминале) +docker-compose exec backend python -m simulator.run +``` + +### Docker Compose сервисы +| Сервис | Порт (внутренний) | Порт (внешний) | Описание | +|--------|-------------------|----------------|----------| +| nginx | 80 | 80 | Reverse proxy, точка входа | +| backend | 8000 | - | FastAPI, доступен только через nginx | +| frontend | 5173 (dev) | - | Vue dev server, доступен только через nginx | +| postgres | 5432 | 5432* | База данных | + +*Порт PostgreSQL открыт наружу для удобства разработки (подключение через IDE/DBeaver) + +--- + +## 11. Критерии приёмки + +| № | Критерий | Приоритет | +|---|----------|-----------| +| 1 | Карта отображается с маркерами объектов | Обязательно | +| 2 | Позиции обновляются в реальном времени (WS) | Обязательно | +| 3 | История движения показывается на карте | Обязательно | +| 4 | Система запускается через docker-compose up | Обязательно | +| 5 | Симулятор генерирует тестовые данные | Обязательно | +| 6 | Лента событий отображается | Желательно | +| 7 | Поддержка 100+ объектов без лагов | Желательно | +| 8 | Экспорт истории в CSV | Опционально | + +--- + +*Документ составлен: 2025-12-18* diff --git a/transport/frontend/Dockerfile b/transport/frontend/Dockerfile new file mode 100644 index 0000000..f522384 --- /dev/null +++ b/transport/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source code +COPY . . + +# Expose Vite dev server port +EXPOSE 5173 + +# Start Vite dev server with host flag for Docker +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/transport/frontend/index.html b/transport/frontend/index.html new file mode 100644 index 0000000..107acd7 --- /dev/null +++ b/transport/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Мониторинг транспорта + + + +
+ + + diff --git a/transport/frontend/package.json b/transport/frontend/package.json new file mode 100644 index 0000000..53c27c7 --- /dev/null +++ b/transport/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "transport-monitoring-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "axios": "^1.6.0", + "leaflet": "^1.9.4", + "naive-ui": "^2.35.0", + "pinia": "^2.1.7", + "@vueuse/core": "^10.7.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.0", + "vite": "^5.0.0" + } +} diff --git a/transport/frontend/src/App.vue b/transport/frontend/src/App.vue new file mode 100644 index 0000000..d0e8942 --- /dev/null +++ b/transport/frontend/src/App.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/transport/frontend/src/components/EventFeed.vue b/transport/frontend/src/components/EventFeed.vue new file mode 100644 index 0000000..9a3cc7a --- /dev/null +++ b/transport/frontend/src/components/EventFeed.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/transport/frontend/src/components/MapView.vue b/transport/frontend/src/components/MapView.vue new file mode 100644 index 0000000..4915769 --- /dev/null +++ b/transport/frontend/src/components/MapView.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/transport/frontend/src/components/TrackHistory.vue b/transport/frontend/src/components/TrackHistory.vue new file mode 100644 index 0000000..e73a517 --- /dev/null +++ b/transport/frontend/src/components/TrackHistory.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/transport/frontend/src/components/VehicleCard.vue b/transport/frontend/src/components/VehicleCard.vue new file mode 100644 index 0000000..aaf2e51 --- /dev/null +++ b/transport/frontend/src/components/VehicleCard.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/transport/frontend/src/components/VehicleList.vue b/transport/frontend/src/components/VehicleList.vue new file mode 100644 index 0000000..4c99b77 --- /dev/null +++ b/transport/frontend/src/components/VehicleList.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/transport/frontend/src/main.js b/transport/frontend/src/main.js new file mode 100644 index 0000000..f43648e --- /dev/null +++ b/transport/frontend/src/main.js @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import naive from 'naive-ui' +import App from './App.vue' + +const app = createApp(App) + +app.use(createPinia()) +app.use(naive) + +app.mount('#app') diff --git a/transport/frontend/vite.config.js b/transport/frontend/vite.config.js new file mode 100644 index 0000000..0f77598 --- /dev/null +++ b/transport/frontend/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + watch: { + usePolling: true + } + } +}) diff --git a/transport/nginx/conf.d/default.conf b/transport/nginx/conf.d/default.conf new file mode 100644 index 0000000..b611999 --- /dev/null +++ b/transport/nginx/conf.d/default.conf @@ -0,0 +1,51 @@ +# Upstream definitions +upstream backend { + server backend:8000; +} + +upstream frontend { + server frontend:5173; +} + +server { + listen 80; + server_name localhost; + + # Frontend (Vue dev server) + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Backend API (strip /api prefix) + location /api/ { + proxy_pass http://backend/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # WebSocket + location /ws { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeout settings + proxy_read_timeout 86400; + proxy_send_timeout 86400; + } +} diff --git a/transport/nginx/nginx.conf b/transport/nginx/nginx.conf new file mode 100644 index 0000000..4ecfcf0 --- /dev/null +++ b/transport/nginx/nginx.conf @@ -0,0 +1,29 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + include /etc/nginx/conf.d/*.conf; +}