feat: initial microservices project structure

Services:
- api: FastAPI gateway with JWT auth, async endpoints, WebSocket
- worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM
- worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction
- worker-notifier: SMTP email notifications
- worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting

Infrastructure:
- docker-compose.yml (production) + docker-compose.dev.yml (hot reload)
- Nginx reverse proxy + WebSocket support
- PostgreSQL 16 with Alembic migrations
- Elasticsearch 8 with Russian/English analyzers
- MinIO, RabbitMQ, Redis, Ollama

Frontend:
- React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5
- 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register

Scripts:
- Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-05-24 19:42:39 +05:00
commit 7758315632
120 changed files with 9500 additions and 0 deletions

View File

@@ -0,0 +1 @@
# API роутеры

View File

@@ -0,0 +1,119 @@
"""Роутер аутентификации: регистрация, вход, верификация email."""
import secrets
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.celery_app import celery_app
from app.core.security import (
create_access_token,
get_current_user,
hash_password,
verify_password,
)
from app.database import get_db
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserInToken
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
"""
Регистрация нового пользователя.
Создаёт аккаунт и отправляет письмо с подтверждением email.
"""
# Проверить уникальность email
existing = await db.execute(select(User).where(User.email == data.email.lower()))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует",
)
# Создать токен верификации
verification_token = secrets.token_urlsafe(32)
user = User(
email=data.email.lower(),
hashed_password=hash_password(data.password),
name=data.name,
verification_token=verification_token,
is_verified=False,
plan="free",
)
db.add(user)
await db.flush() # Получить ID без коммита
await db.commit()
await db.refresh(user)
# Диспатч email верификации через воркер
try:
celery_app.send_task(
"notify.send_verification",
args=[user.email, user.name, verification_token],
queue="queue.notify",
)
except Exception as e:
logger.warning(f"Не удалось поставить задачу верификации email: {e}")
access_token = create_access_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
token_type="bearer",
user=UserInToken.model_validate(user),
)
@router.post("/login", response_model=TokenResponse)
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
"""Вход в систему. Возвращает JWT токен."""
result = await db.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none()
if user is None or not verify_password(data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный email или пароль",
)
access_token = create_access_token({"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
token_type="bearer",
user=UserInToken.model_validate(user),
)
@router.get("/me", response_model=UserInToken)
async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken:
"""Получить информацию о текущем пользователе."""
return UserInToken.model_validate(current_user)
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)
async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
"""Подтвердить email по токену из письма."""
result = await db.execute(
select(User).where(User.verification_token == token)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Неверный или просроченный токен верификации",
)
user.is_verified = True
user.verification_token = None
await db.commit()
return {"message": "Email успешно подтверждён"}

View File

@@ -0,0 +1,155 @@
"""Роутер загрузки документов на проверку плагиата."""
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from minio import Minio
from minio.error import S3Error
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.celery_app import celery_app
from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit
from app.core.security import get_current_user
from app.database import get_db
from app.models.task import Task
from app.models.user import User
from app.schemas.tasks import TaskResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/documents", tags=["documents"])
# Допустимые форматы
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"}
MAX_FILE_SIZE_MB = 100
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
def get_minio_client() -> Minio:
"""Создать MinIO клиент."""
return Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=False,
)
@router.post("/check", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED)
async def upload_for_plagiarism_check(
file: UploadFile = File(..., description="PDF, DOCX или TXT файл до 100 МБ"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> TaskResponse:
"""
Загрузить документ для проверки на плагиат.
Файл сохраняется в MinIO, затем диспатчится задача index.extract_and_check.
Результат доступен через GET /tasks/{task_id}.
"""
# Проверить расширение файла
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Имя файла не указано",
)
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Неподдерживаемый формат файла. Допустимые форматы: {', '.join(ALLOWED_EXTENSIONS)}",
)
# Проверить лимит по тарифу
limit_check = check_and_increment_limit(current_user.id, "plagiarism", current_user.plan)
if not limit_check["allowed"]:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Превышен месячный лимит проверок плагиата для тарифа '{current_user.plan}'. "
f"Использовано {limit_check['current']} из {limit_check['limit']}."
),
)
# Проверить лимит одновременных задач
if not check_concurrent_limit(current_user.id, current_user.plan):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Превышен лимит одновременных задач.",
)
# Прочитать файл
file_data = await file.read()
if len(file_data) > MAX_FILE_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Файл слишком большой. Максимальный размер: {MAX_FILE_SIZE_MB} МБ",
)
# Загрузить в MinIO
minio_key = f"uploads/{current_user.id}/{uuid.uuid4()}{ext}"
minio_client = get_minio_client()
try:
# Создать бакет если не существует
if not minio_client.bucket_exists(settings.MINIO_BUCKET_DOCS):
minio_client.make_bucket(settings.MINIO_BUCKET_DOCS)
import io
minio_client.put_object(
bucket_name=settings.MINIO_BUCKET_DOCS,
object_name=minio_key,
data=io.BytesIO(file_data),
length=len(file_data),
content_type=file.content_type or "application/octet-stream",
)
logger.info(f"Файл загружен в MinIO: {minio_key}")
except S3Error as e:
logger.error(f"Ошибка загрузки в MinIO: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Ошибка сохранения файла. Попробуйте позже.",
)
# Создать задачу в БД
task = Task(
user_id=current_user.id,
type="plagiarism",
status="queued",
input_data={
"filename": file.filename,
"minio_key": minio_key,
"file_size_bytes": len(file_data),
"content_type": file.content_type,
},
)
db.add(task)
await db.flush()
task_id = task.id
# Диспатч в индексер воркер
celery_result = celery_app.send_task(
"index.extract_and_check",
args=[task_id, minio_key, file.filename],
queue="queue.index",
)
task.celery_task_id = celery_result.id
task.queue_position = 1
task.eta_seconds = 120 # ~2 минуты с учётом 4 уровней проверки
await db.commit()
await db.refresh(task)
logger.info(
f"Задача проверки плагиата {task_id!r} создана для пользователя {current_user.id}, "
f"файл: {file.filename!r}"
)
return TaskResponse.model_validate(task)

View File

@@ -0,0 +1,77 @@
"""Роутер для получения отчётов о выполненных задачах."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.database import get_db
from app.models.task import Task
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/reports", tags=["reports"])
@router.get("/{task_id}")
async def get_report(
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""
Получить готовый отчёт по задаче.
Возвращает task.result в зависимости от типа задачи:
- search: список источников с ГОСТ-цитатами
- plagiarism: детальный отчёт с совпадениями
- gost: отформатированная библиография
- summarize: краткое изложение
"""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Задача не найдена",
)
if task.status == "queued":
raise HTTPException(
status_code=status.HTTP_202_ACCEPTED,
detail="Задача ещё в очереди",
)
if task.status == "processing":
raise HTTPException(
status_code=status.HTTP_202_ACCEPTED,
detail="Задача ещё выполняется",
)
if task.status == "failed":
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Задача завершилась с ошибкой: {task.error}",
)
if task.result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Результат недоступен",
)
return {
"task_id": task.id,
"type": task.type,
"status": task.status,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
"result": task.result,
}

View File

@@ -0,0 +1,95 @@
"""Роутер семантического поиска источников."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.celery_app import celery_app
from app.core.rate_limiter import check_and_increment_limit, check_concurrent_limit
from app.core.security import get_current_user
from app.database import get_db
from app.models.task import Task
from app.models.user import User
from app.schemas.tasks import SearchRequest, TaskResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/search", tags=["search"])
# Примерное время ожидания в секундах для каждой позиции в очереди
ETA_PER_POSITION_SECONDS = 15
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_202_ACCEPTED)
async def create_search_task(
data: SearchRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> TaskResponse:
"""
Создать задачу семантического поиска источников.
Возвращает TaskResponse с queue_position и eta_seconds сразу.
Результат доступен через GET /tasks/{task_id} или WebSocket /ws/tasks/{task_id}.
"""
# Проверить лимит по тарифу
limit_check = check_and_increment_limit(current_user.id, "search", current_user.plan)
if not limit_check["allowed"]:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Превышен дневной лимит поиска для тарифа '{current_user.plan}'. "
f"Использовано {limit_check['current']} из {limit_check['limit']}."
),
)
# Проверить лимит одновременных задач
if not check_concurrent_limit(current_user.id, current_user.plan):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Превышен лимит одновременных задач. Дождитесь завершения текущих.",
)
# Создать задачу в БД
task = Task(
user_id=current_user.id,
type="search",
status="queued",
input_data={
"query": data.query,
"lang": data.lang,
"year_from": data.year_from,
"year_to": data.year_to,
"category": data.category,
},
)
db.add(task)
await db.flush()
task_id = task.id
# Диспатч в GPU воркер
celery_result = celery_app.send_task(
"gpu.search_semantic",
args=[task_id, data.query],
kwargs={
"lang": data.lang,
"year_from": data.year_from,
"year_to": data.year_to,
},
queue="queue.gpu",
)
task.celery_task_id = celery_result.id
task.queue_position = 1 # TODO: реальный подсчёт через Redis
task.eta_seconds = ETA_PER_POSITION_SECONDS
await db.commit()
await db.refresh(task)
logger.info(
f"Задача поиска {task_id!r} создана для пользователя {current_user.id}, "
f"запрос: {data.query[:50]!r}"
)
return TaskResponse.model_validate(task)

View File

@@ -0,0 +1,89 @@
"""Роутер для управления задачами пользователя."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.database import get_db
from app.models.task import Task
from app.models.user import User
from app.schemas.tasks import TaskResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskResponse])
async def list_tasks(
limit: int = 20,
offset: int = 0,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[TaskResponse]:
"""Получить список задач текущего пользователя (от новых к старым)."""
result = await db.execute(
select(Task)
.where(Task.user_id == current_user.id)
.order_by(Task.created_at.desc())
.limit(limit)
.offset(offset)
)
tasks = result.scalars().all()
return [TaskResponse.model_validate(t) for t in tasks]
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> TaskResponse:
"""Получить детали конкретной задачи."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Задача не найдена",
)
return TaskResponse.model_validate(task)
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(
task_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""
Удалить задачу.
Нельзя удалить задачу в статусе 'processing'.
"""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Задача не найдена",
)
if task.status == "processing":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Нельзя удалить задачу в процессе выполнения",
)
await db.delete(task)
await db.commit()