ОПОРА РОССИИ
+Платформа
+diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..403f5f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# === Общие === +ENVIRONMENT=dev +DEBUG=false + +# === PostgreSQL === +POSTGRES_USER=opora +POSTGRES_PASSWORD=change-me +POSTGRES_DB=opora +DATABASE_URL=postgresql+psycopg://opora:change-me@postgres:5432/opora + +# === Redis / Celery === +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/1 +CELERY_RESULT_BACKEND=redis://redis:6379/2 + +# === JWT === +JWT_SECRET=change-me-to-a-long-random-string +ACCESS_TOKEN_TTL_DAYS=30 +REFRESH_TOKEN_TTL_DAYS=30 + +# === CORS === +CORS_ORIGINS=["https://opora.my-dpr.ru"] + +# === MinIO / S3 === +S3_ENDPOINT=http://minio:9000 +S3_ACCESS_KEY=minio +S3_SECRET_KEY=change-me +S3_BUCKET=artifacts + +# === Мессенджеры === +TELEGRAM_BOT_TOKEN= +MAX_BOT_TOKEN= +BOT_SERVICE_TOKEN= + +# === LLM === +OPENROUTER_API_KEY= +LLM_MODEL=deepseek/deepseek-chat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59a8e11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.ruff_cache/ + +# Node +node_modules/ +dist/ +.vite/ + +# Env / secrets +.env +*.local + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Data +*.log diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..63e1028 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,80 @@ +stages: + - lint + - test + - build + - deploy + +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + +# ---------- Backend ---------- +backend:lint: + stage: lint + image: python:3.12-slim + script: + - cd backend + - pip install ruff + - ruff check app + +backend:test: + stage: test + image: python:3.12-slim + services: + - postgres:16-alpine + variables: + POSTGRES_USER: opora + POSTGRES_PASSWORD: opora + POSTGRES_DB: opora_test + DATABASE_URL: postgresql+psycopg://opora:opora@postgres:5432/opora_test + script: + - cd backend + - pip install -r requirements.txt pytest + - pytest -q + +# ---------- Frontend ---------- +frontend:lint: + stage: lint + image: node:20-alpine + script: + - cd frontend + - npm install + - npm run lint || true + +frontend:build: + stage: build + image: node:20-alpine + script: + - cd frontend + - npm install + - npm run build + artifacts: + paths: + - frontend/dist + +# ---------- Docker images ---------- +build:images: + stage: build + image: docker:27 + services: + - docker:27-dind + script: + - docker build -t $CI_REGISTRY_IMAGE/api:$CI_COMMIT_SHORT_SHA ./backend + - docker build -t $CI_REGISTRY_IMAGE/web:$CI_COMMIT_SHORT_SHA ./frontend + - docker build -t $CI_REGISTRY_IMAGE/bot:$CI_COMMIT_SHORT_SHA ./bot + rules: + - if: $CI_COMMIT_BRANCH == "main" + +# ---------- Deploy ---------- +deploy:prod: + stage: deploy + image: alpine:3.20 + before_script: + - apk add --no-cache openssh-client + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh && ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + script: + - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/opora && git pull && docker compose up -d --build" + rules: + - if: $CI_COMMIT_BRANCH == "main" + when: manual diff --git a/README.md b/README.md new file mode 100644 index 0000000..e187693 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# АС «Платформа ОПОРА РОССИИ» + +Автоматизированная система для общероссийской общественной организации МСП «ОПОРА РОССИИ»: мессенджер-бот, mobile-first веб-приложение и единое API. + +## Стек + +- **Backend:** Python 3.12, FastAPI, SQLAlchemy 2, Alembic +- **БД:** PostgreSQL 16 +- **Кэш/очередь:** Redis, Celery +- **Бот:** aiogram 3 (Telegram) + адаптер MAX +- **Frontend:** React, TypeScript, Vite, Tailwind CSS, Recharts +- **Файлы:** MinIO (S3-совместимое) +- **Инфра:** Docker Compose, Nginx, Let's Encrypt +- **AI-ассистент:** OpenRouter (`deepseek/deepseek-chat`) + +## Структура + +``` +backend/ FastAPI-приложение, модели, миграции +frontend/ React SPA (mobile-first, PWA) +bot/ aiogram-бот +deploy/ nginx.conf, сертификаты +docs/ документация проекта + tech-design/ модель данных, API, макеты + design/ дизайн-система +tools/ вспомогательные скрипты (тест LLM) +docker-compose.yml +.gitlab-ci.yml +``` + +## Быстрый старт (локально) + +```bash +cp .env.example .env +# заполнить .env (пароли, токены) + +docker compose up -d --build +``` + +- API: http://localhost/api/docs +- Сайт: http://localhost/ +- MinIO Console: http://localhost:9001 + +## Миграции БД + +```bash +docker compose exec api alembic revision --autogenerate -m "init" +docker compose exec api alembic upgrade head +``` + +## Разработка + +```bash +# backend +cd backend && pip install -r requirements.txt +uvicorn app.main:app --reload + +# frontend +cd frontend && npm install && npm run dev +``` + +## Документация + +- `docs/stack.md` — стек и архитектура +- `docs/tech-design/01-data-model.md` — модель данных +- `docs/tech-design/02-api.md` — контракты API +- `docs/tech-design/03-screens.md` — макеты экранов +- `docs/design/design-system.md` — дизайн-система +- `docs/llm-test-report.md` — тест LLM-моделей + +## Деплой + +CI/CD через GitLab CI: линтеры → тесты → сборка образов → деплой на сервер (вручную, ветка `main`). + +Требуемые переменные GitLab CI: `SSH_PRIVATE_KEY`, `DEPLOY_HOST`, `DEPLOY_USER`, `CI_REGISTRY_IMAGE`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b80b3ea --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", \ + "-w", "4", "-b", "0.0.0.0:8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..a97945f --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = + +[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/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..bb5957e --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,46 @@ +"""Окружение Alembic.""" +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.db.session import Base +import app.models # noqa: F401 — регистрация моделей + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + 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/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..5922c3e --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,9 @@ +"""Служебные эндпоинты.""" +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/ping") +def ping() -> dict: + return {"pong": True} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..efa32ff --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,7 @@ +"""Корневой роутер API v1.""" +from fastapi import APIRouter + +from app.api.v1 import health + +api_router = APIRouter() +api_router.include_router(health.router, tags=["system"]) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..377a54d --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,45 @@ +"""Конфигурация приложения (переменные окружения).""" +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + # Общие + app_name: str = "opora" + environment: str = "dev" + debug: bool = False + + # База данных + database_url: str = "postgresql+psycopg://opora:opora@postgres:5432/opora" + + # Redis / Celery + redis_url: str = "redis://redis:6379/0" + celery_broker_url: str = "redis://redis:6379/1" + celery_result_backend: str = "redis://redis:6379/2" + + # JWT + jwt_secret: str = "change-me" + jwt_algorithm: str = "HS256" + access_token_ttl_days: int = 30 + refresh_token_ttl_days: int = 30 + + # CORS + cors_origins: list[str] = ["https://opora.my-dpr.ru"] + + # MinIO / S3 + s3_endpoint: str = "http://minio:9000" + s3_access_key: str = "minio" + s3_secret_key: str = "minio123" + s3_bucket: str = "artifacts" + + # Мессенджеры + telegram_bot_token: str = "" + max_bot_token: str = "" + + # LLM + openrouter_api_key: str = "" + llm_model: str = "deepseek/deepseek-chat" + + +settings = Settings() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..76523d3 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,23 @@ +"""Подключение к БД и сессии SQLAlchemy.""" +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.core.config import settings + +engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +class Base(DeclarativeBase): + """Базовый класс моделей.""" + + +def get_db() -> Generator[Session, None, None]: + """Зависимость FastAPI: сессия БД.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..44b706b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,29 @@ +"""Точка входа FastAPI-приложения АС «Платформа ОПОРА РОССИИ».""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.router import api_router +from app.core.config import settings + +app = FastAPI( + title="АС «Платформа ОПОРА РОССИИ»", + version="0.1.0", + docs_url="/api/docs", + openapi_url="/api/openapi.json", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(api_router, prefix="/api/v1") + + +@app.get("/health", tags=["system"]) +def health() -> dict: + """Проверка работоспособности.""" + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..480b83c --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,4 @@ +"""Модели данных.""" +from app.models.region import FederalDistrict, Region + +__all__ = ["FederalDistrict", "Region"] diff --git a/backend/app/models/region.py b/backend/app/models/region.py new file mode 100644 index 0000000..9ce6a2f --- /dev/null +++ b/backend/app/models/region.py @@ -0,0 +1,33 @@ +"""Модели справочников: федеральные округа и регионы.""" +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +class FederalDistrict(Base): + __tablename__ = "federal_districts" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True) + code: Mapped[str | None] = mapped_column(String(20), unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + regions: Mapped[list["Region"]] = relationship(back_populates="district") + + +class Region(Base): + __tablename__ = "regions" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200), unique=True) + federal_district_id: Mapped[int] = mapped_column(ForeignKey("federal_districts.id")) + status: Mapped[str] = mapped_column(String(20), default="active") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + district: Mapped[FederalDistrict] = relationship(back_populates="regions") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f4bcb5f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +# Backend — АС «Платформа ОПОРА РОССИИ» +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +gunicorn==22.0.0 +sqlalchemy==2.0.35 +alembic==1.13.2 +psycopg[binary]==3.2.3 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +celery==5.4.0 +redis==5.0.8 +aiogram==3.13.1 +httpx==0.27.2 +python-multipart==0.0.12 +boto3==1.35.36 diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..d07bb19 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "-m", "app.main"] diff --git a/bot/app/__init__.py b/bot/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/app/config.py b/bot/app/config.py new file mode 100644 index 0000000..a4a4361 --- /dev/null +++ b/bot/app/config.py @@ -0,0 +1,14 @@ +"""Конфигурация бота.""" +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + telegram_bot_token: str = "" + max_bot_token: str = "" + api_base_url: str = "http://api:8000/api/v1" + bot_service_token: str = "" + + +settings = Settings() diff --git a/bot/app/main.py b/bot/app/main.py new file mode 100644 index 0000000..ac512b5 --- /dev/null +++ b/bot/app/main.py @@ -0,0 +1,40 @@ +"""Бот «ОПОРА РОССИИ»: точка входа (aiogram 3).""" +import asyncio +import logging + +from aiogram import Bot, Dispatcher +from aiogram.filters import Command +from aiogram.types import Message + +from app.config import settings + +logging.basicConfig(level=logging.INFO) + +dp = Dispatcher() + + +@dp.message(Command("start")) +async def cmd_start(message: Message) -> None: + await message.answer( + "Добро пожаловать в бот «ОПОРА РОССИИ»!\n\n" + "Доступные команды:\n" + "/login — войти на сайт\n" + "/goals — цели организации\n" + "/regions — регионы и пользователи\n" + "/events — регистрации на мероприятия\n" + "/help — помощь" + ) + + +@dp.message(Command("help")) +async def cmd_help(message: Message) -> None: + await message.answer("Помощь: напишите координатору или откройте сайт платформы.") + + +async def main() -> None: + bot = Bot(token=settings.telegram_bot_token) + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..6451a77 --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,3 @@ +aiogram==3.13.1 +httpx==0.27.2 +pydantic-settings==2.5.2 diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..a6a19cf --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,44 @@ +upstream api_upstream { + server api:8000; +} + +upstream web_upstream { + server web:80; +} + +server { + listen 80; + server_name opora.my-dpr.ru; + + # ACME challenge для Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name opora.my-dpr.ru; + + ssl_certificate /etc/letsencrypt/live/opora.my-dpr.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/opora.my-dpr.ru/privkey.pem; + + client_max_body_size 60m; + + location /api/ { + proxy_pass http://api_upstream; + 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; + } + + location / { + proxy_pass http://web_upstream; + proxy_set_header Host $host; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c5ed46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,91 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-opora} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-opora} + POSTGRES_DB: ${POSTGRES_DB:-opora} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-opora}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redisdata:/data + + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minio} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minio123} + volumes: + - miniodata:/data + + api: + build: ./backend + restart: unless-stopped + env_file: .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + expose: + - "8000" + + worker: + build: ./backend + restart: unless-stopped + command: celery -A app.tasks.celery_app worker -l info + env_file: .env + depends_on: + - redis + - postgres + + beat: + build: ./backend + restart: unless-stopped + command: celery -A app.tasks.celery_app beat -l info + env_file: .env + depends_on: + - redis + + bot: + build: ./bot + restart: unless-stopped + env_file: .env + depends_on: + - api + + web: + build: ./frontend + restart: unless-stopped + expose: + - "80" + + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./deploy/certs:/etc/letsencrypt:ro + depends_on: + - api + - web + +volumes: + pgdata: + redisdata: + miniodata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ff81a7d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +# build +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +# serve +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b920c49 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + +
+ + + +Платформа
+Раздел в разработке.
+