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

20
services/api/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
# Установка системных зависимостей
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Создать директорию для логов
RUN mkdir -p /app/logs
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

46
services/api/alembic.ini Normal file
View File

@@ -0,0 +1,46 @@
# Alembic Configuration File
[alembic]
# Путь к директории с миграциями
script_location = alembic
# Формат имени файла миграции
file_template = %%(rev)s_%%(slug)s
# Часовой пояс для временных меток
timezone = Europe/Moscow
# Логирование
[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

View File

View File

@@ -0,0 +1,90 @@
"""Alembic env.py — настройка среды для миграций (async режим)."""
import asyncio
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
# Импорт всех моделей для автоопределения изменений
from app.database import Base
import app.models # noqa: F401 — регистрирует все модели
# Alembic Config
config = context.config
# Настройка логирования
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Целевые метаданные для автогенерации
target_metadata = Base.metadata
# Читаем DATABASE URL из переменной окружения (синхронный psycopg2 для Alembic)
def get_url() -> str:
host = os.getenv("POSTGRES_HOST", "postgres")
port = os.getenv("POSTGRES_PORT", "5432")
db = os.getenv("POSTGRES_DB", "antiplagiator")
user = os.getenv("POSTGRES_USER", "antiplagiator")
password = os.getenv("POSTGRES_PASSWORD", "changeme")
return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{db}"
def run_migrations_offline() -> None:
"""Запустить миграции в 'offline' режиме (без реального соединения с БД)."""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Выполнить миграции с реальным соединением."""
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Запустить миграции в async режиме."""
configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Запустить миграции в 'online' режиме."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,113 @@
"""Начальная схема базы данных.
Revision ID: 001
Revises:
Create Date: 2024-01-01 00:00:00.000000
Создаёт таблицы: users, tasks, documents, fingerprints, usage_logs
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Создать все таблицы."""
# ── users ──────────────────────────────────────────────────────────────────
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("plan", sa.String(20), nullable=False, server_default="free"),
sa.Column("verification_token", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
# ── tasks ──────────────────────────────────────────────────────────────────
op.create_table(
"tasks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE")),
sa.Column("type", sa.String(20), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="queued"),
sa.Column("celery_task_id", sa.String(255), nullable=True),
sa.Column("input_data", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("queue_position", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_tasks_user_id", "tasks", ["user_id"])
op.create_index("ix_tasks_status", "tasks", ["status"])
# ── documents ──────────────────────────────────────────────────────────────
op.create_table(
"documents",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("source", sa.String(50), nullable=False),
sa.Column("ext_id", sa.String(255), nullable=False),
sa.Column("doi", sa.String(255), nullable=True),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("authors", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("year", sa.Integer(), nullable=True),
sa.Column("lang", sa.String(10), nullable=True),
sa.Column("journal", sa.Text(), nullable=True),
sa.Column("volume", sa.String(50), nullable=True),
sa.Column("issue", sa.String(50), nullable=True),
sa.Column("pages", sa.String(50), nullable=True),
sa.Column("abstract", sa.Text(), nullable=True),
sa.Column("url", sa.Text(), nullable=True),
sa.Column("minio_key", sa.Text(), nullable=True),
sa.Column("faiss_id", sa.Integer(), nullable=True),
sa.Column("indexed_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_documents_ext_id", "documents", ["ext_id"], unique=True)
op.create_index("ix_documents_doi", "documents", ["doi"])
op.create_index("ix_documents_source", "documents", ["source"])
op.create_index("ix_documents_year", "documents", ["year"])
op.create_index("ix_documents_lang", "documents", ["lang"])
op.create_index("ix_documents_faiss_id", "documents", ["faiss_id"])
# ── fingerprints ───────────────────────────────────────────────────────────
op.create_table(
"fingerprints",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("doc_id", sa.Integer(), sa.ForeignKey("documents.id", ondelete="CASCADE")),
sa.Column("hash_value", sa.BigInteger(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
)
op.create_index("ix_fingerprints_doc_id", "fingerprints", ["doc_id"])
op.create_index("ix_fingerprints_hash_value", "fingerprints", ["hash_value"])
# ── usage_logs ─────────────────────────────────────────────────────────────
op.create_table(
"usage_logs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE")),
sa.Column("action", sa.String(50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_usage_logs_user_id", "usage_logs", ["user_id"])
op.create_index("ix_usage_logs_created_at", "usage_logs", ["created_at"])
def downgrade() -> None:
"""Удалить все таблицы."""
op.drop_table("usage_logs")
op.drop_table("fingerprints")
op.drop_table("documents")
op.drop_table("tasks")
op.drop_table("users")

View File

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

View File

@@ -0,0 +1,82 @@
"""Конфигурация приложения через Pydantic Settings."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Настройки приложения, читаемые из переменных окружения."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# PostgreSQL
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
POSTGRES_DB: str = "antiplagiator"
POSTGRES_USER: str = "antiplagiator"
POSTGRES_PASSWORD: str = "changeme"
# Redis
REDIS_URL: str = "redis://redis:6379/0"
# RabbitMQ
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
# MinIO
MINIO_ENDPOINT: str = "minio:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "changeme"
MINIO_BUCKET_DOCS: str = "documents"
MINIO_BUCKET_BACKUPS: str = "backups"
# Elasticsearch
ELASTICSEARCH_URL: str = "http://elasticsearch:9200"
# Ollama
OLLAMA_URL: str = "http://ollama:11434"
# JWT
SECRET_KEY: str = "change-me-in-production-use-openssl-rand-hex-32"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней
# SMTP
SMTP_HOST: str = "smtp.yandex.ru"
SMTP_PORT: int = 465
SMTP_USER: str = "noreply@jze9.ru"
SMTP_PASSWORD: str = "changeme"
SMTP_FROM: str = "noreply@jze9.ru"
# App
APP_URL: str = "https://academic.jze9.ru"
ENVIRONMENT: str = "development"
DEBUG: bool = False
# CORS
CORS_ORIGINS: list[str] = [
"http://localhost:5173",
"http://localhost:3000",
"https://academic.jze9.ru",
]
@property
def database_url(self) -> str:
"""URL для asyncpg (асинхронные запросы)."""
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@property
def database_url_sync(self) -> str:
"""URL для psycopg2 (Alembic и синхронные операции)."""
return (
f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
settings = Settings()

View File

View File

@@ -0,0 +1,39 @@
"""Celery приложение для API-диспатчера. Только определение — задачи находятся в воркерах."""
from celery import Celery
from app.config import settings
celery_app = Celery(
"api_dispatcher",
broker=settings.RABBITMQ_URL,
backend=settings.REDIS_URL,
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="Europe/Moscow",
enable_utc=True,
task_track_started=True,
# Маршрутизация задач по очередям воркеров
task_routes={
"gpu.*": {"queue": "queue.gpu"},
"index.*": {"queue": "queue.index"},
"notify.*": {"queue": "queue.notify"},
"gost.*": {"queue": "queue.gost"},
},
# Настройки очередей
task_queues={
"queue.gpu": {"exchange": "queue.gpu", "routing_key": "queue.gpu"},
"queue.index": {"exchange": "queue.index", "routing_key": "queue.index"},
"queue.notify": {"exchange": "queue.notify", "routing_key": "queue.notify"},
"queue.gost": {"exchange": "queue.gost", "routing_key": "queue.gost"},
},
# Результаты хранить 24 часа
result_expires=86400,
# Повторные попытки
task_acks_late=True,
task_reject_on_worker_lost=True,
)

View File

@@ -0,0 +1,167 @@
"""Redis-based rate limiter для проверки лимитов по тарифному плану."""
import json
from datetime import datetime, timezone
import redis
from app.config import settings
# Лимиты по тарифным планам
PLAN_LIMITS: dict[str, dict[str, int | None]] = {
"free": {
"search_per_day": 10,
"summarize_per_month": 3,
"plagiarism_per_month": 1,
"concurrent": 1,
},
"student": {
"search_per_day": None, # None = безлимит
"summarize_per_month": 30,
"plagiarism_per_month": 10,
"concurrent": 2,
},
"premium": {
"search_per_day": None,
"summarize_per_month": None,
"plagiarism_per_month": 50,
"concurrent": 5,
},
"science": {
"search_per_day": None,
"summarize_per_month": None,
"plagiarism_per_month": None,
"concurrent": 10,
},
}
# Маппинг действий на ключи лимитов
ACTION_TO_LIMIT: dict[str, tuple[str, str]] = {
"search": ("search_per_day", "day"),
"summarize": ("summarize_per_month", "month"),
"plagiarism": ("plagiarism_per_month", "month"),
"gost": ("gost_per_month", "month"), # ГОСТ всегда разрешён
}
def get_redis_client() -> redis.Redis:
"""Создать синхронный Redis клиент."""
return redis.from_url(settings.REDIS_URL, decode_responses=True)
def _get_period_key(period: str) -> str:
"""Получить строку периода для Redis ключа."""
now = datetime.now(timezone.utc)
if period == "day":
return now.strftime("%Y-%m-%d")
elif period == "month":
return now.strftime("%Y-%m")
return now.strftime("%Y-%m-%d")
def check_and_increment_limit(user_id: int, action: str, plan: str) -> dict:
"""
Проверить лимит и инкрементировать счётчик.
Args:
user_id: ID пользователя
action: Действие (search, plagiarism, summarize, gost)
plan: Тарифный план пользователя
Returns:
dict с полями:
- allowed: bool — разрешено ли действие
- current: int — текущее количество использований
- limit: int | None — лимит (None = безлимит)
- remaining: int | None — осталось использований
- reset_at: str — когда сбрасывается счётчик
"""
limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
if action not in ACTION_TO_LIMIT:
# Неизвестное действие — разрешаем
return {"allowed": True, "current": 0, "limit": None, "remaining": None}
limit_key, period = ACTION_TO_LIMIT[action]
limit_value = limits.get(limit_key)
# Безлимитный план
if limit_value is None:
return {
"allowed": True,
"current": 0,
"limit": None,
"remaining": None,
"reset_at": None,
}
period_str = _get_period_key(period)
redis_key = f"rate:{user_id}:{action}:{period_str}"
r = get_redis_client()
# Атомарно инкрементировать
pipe = r.pipeline()
pipe.incr(redis_key)
# Устанавливаем TTL: для дня — 86400 сек, для месяца — 32 дня
ttl = 86400 if period == "day" else 86400 * 32
pipe.expire(redis_key, ttl)
results = pipe.execute()
current = results[0]
if current > limit_value:
# Декрементировать обратно (не считать запрещённые)
r.decr(redis_key)
current -= 1
return {
"allowed": False,
"current": current,
"limit": limit_value,
"remaining": 0,
"reset_at": period_str,
}
return {
"allowed": True,
"current": current,
"limit": limit_value,
"remaining": limit_value - current,
"reset_at": period_str,
}
def check_concurrent_limit(user_id: int, plan: str) -> bool:
"""
Проверить лимит одновременных задач.
Returns:
True если можно создать новую задачу, False если превышен лимит.
"""
limits = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
max_concurrent = limits.get("concurrent", 1)
r = get_redis_client()
key = f"concurrent:{user_id}"
current = r.get(key)
return (current is None) or (int(current) < max_concurrent)
def increment_concurrent(user_id: int) -> None:
"""Увеличить счётчик одновременных задач (при создании задачи)."""
r = get_redis_client()
key = f"concurrent:{user_id}"
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, 3600) # Автосброс через 1 час
pipe.execute()
def decrement_concurrent(user_id: int) -> None:
"""Уменьшить счётчик одновременных задач (при завершении задачи)."""
r = get_redis_client()
key = f"concurrent:{user_id}"
current = r.get(key)
if current and int(current) > 0:
r.decr(key)

View File

@@ -0,0 +1,110 @@
"""Утилиты безопасности: JWT, хэширование паролей, dependency для получения текущего пользователя."""
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
# Контекст хэширования паролей (bcrypt)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 схема
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
"""Хэшировать пароль через bcrypt."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверить соответствие пароля его хэшу."""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
"""Создать JWT токен доступа."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def verify_token(token: str) -> dict[str, Any]:
"""
Декодировать и верифицировать JWT токен.
Raises:
HTTPException: если токен невалиден или просрочен.
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id: int | None = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный токен аутентификации",
headers={"WWW-Authenticate": "Bearer"},
)
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Невалидный или просроченный токен",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
):
"""
FastAPI dependency: получить текущего авторизованного пользователя из JWT токена.
Raises:
HTTPException 401: если токен невалиден.
HTTPException 404: если пользователь не найден.
"""
from app.models.user import User
payload = verify_token(token)
user_id: int = int(payload.get("sub"))
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден",
)
return user
async def get_current_verified_user(
current_user=Depends(get_current_user),
):
"""
FastAPI dependency: только верифицированные пользователи.
Raises:
HTTPException 403: если email не подтверждён.
"""
if not current_user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Необходимо подтвердить email адрес",
)
return current_user

View File

@@ -0,0 +1,86 @@
"""WebSocket менеджер для real-time обновлений статуса задач."""
import json
import logging
from typing import Any
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
"""Менеджер WebSocket соединений с поддержкой подписки на задачи."""
def __init__(self) -> None:
# task_id -> список активных соединений
self._connections: dict[str, list[WebSocket]] = {}
async def connect(self, task_id: str, websocket: WebSocket) -> None:
"""Принять WebSocket соединение и зарегистрировать его для задачи."""
await websocket.accept()
if task_id not in self._connections:
self._connections[task_id] = []
self._connections[task_id].append(websocket)
logger.info(f"WebSocket подключён к задаче {task_id!r}")
def disconnect(self, task_id: str, websocket: WebSocket) -> None:
"""Удалить соединение из реестра."""
if task_id in self._connections:
try:
self._connections[task_id].remove(websocket)
except ValueError:
pass
if not self._connections[task_id]:
del self._connections[task_id]
logger.info(f"WebSocket отключён от задачи {task_id!r}")
async def send_task_update(self, task_id: str, data: dict[str, Any]) -> None:
"""
Отправить обновление статуса задачи всем подключённым клиентам.
Args:
task_id: ID задачи
data: Словарь с обновлением (status, queue_position, result и т.д.)
"""
connections = self._connections.get(task_id, [])
if not connections:
return
message = json.dumps(data, ensure_ascii=False, default=str)
dead_connections = []
for websocket in connections:
try:
await websocket.send_text(message)
except Exception as e:
logger.warning(f"Ошибка отправки WebSocket сообщения: {e}")
dead_connections.append(websocket)
# Очистить мёртвые соединения
for ws in dead_connections:
self.disconnect(task_id, ws)
async def broadcast(self, data: dict[str, Any]) -> None:
"""Отправить сообщение всем подключённым клиентам."""
message = json.dumps(data, ensure_ascii=False, default=str)
all_dead = []
for task_id, connections in self._connections.items():
for websocket in connections:
try:
await websocket.send_text(message)
except Exception:
all_dead.append((task_id, websocket))
for task_id, ws in all_dead:
self.disconnect(task_id, ws)
@property
def active_connections_count(self) -> int:
"""Количество активных WebSocket соединений."""
return sum(len(conns) for conns in self._connections.values())
# Глобальный экземпляр менеджера
ws_manager = ConnectionManager()

View File

@@ -0,0 +1,53 @@
"""Настройка базы данных: AsyncEngine, AsyncSession, dependency для FastAPI."""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
"""Базовый класс для всех SQLAlchemy моделей."""
pass
# Асинхронный движок
engine = create_async_engine(
settings.database_url,
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
# Фабрика сессий
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
FastAPI dependency для получения сессии БД.
Сессия автоматически закрывается после запроса.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

113
services/api/app/main.py Normal file
View File

@@ -0,0 +1,113 @@
"""Точка входа FastAPI приложения — Академический помощник (anti-plagiarism)."""
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import auth, documents, reports, search, tasks
from app.config import settings
from app.core.websocket_manager import ws_manager
from app.database import engine
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""
Lifecycle менеджер приложения.
Выполняет инициализацию при старте и очистку при остановке.
"""
logger.info("Запуск Академического помощника...")
# Проверка соединений при старте
try:
async with engine.connect() as conn:
await conn.execute(__import__("sqlalchemy").text("SELECT 1"))
logger.info("PostgreSQL: соединение установлено")
except Exception as e:
logger.error(f"PostgreSQL: ошибка соединения: {e}")
yield
# Очистка при остановке
logger.info("Остановка приложения...")
await engine.dispose()
app = FastAPI(
title="Академический помощник API",
description=(
"Сервис антиплагиата и поиска академических источников. "
"Семантический поиск через FAISS GPU + Elasticsearch, "
"проверка плагиата в 4 уровня, ГОСТ библиография."
),
version="0.1.0",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
# ─── CORS ─────────────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Роутеры ──────────────────────────────────────────────────────────────────
app.include_router(auth.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(search.router, prefix="/api")
app.include_router(documents.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
# ─── WebSocket ────────────────────────────────────────────────────────────────
@app.websocket("/ws/tasks/{task_id}")
async def websocket_task_updates(websocket: WebSocket, task_id: str) -> None:
"""
WebSocket endpoint для real-time обновлений статуса задачи.
Клиент подключается и получает обновления при изменении статуса задачи.
Соединение закрывается при получении статуса done/failed.
"""
await ws_manager.connect(task_id, websocket)
try:
while True:
# Ждём входящих сообщений (ping/pong для поддержания соединения)
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
ws_manager.disconnect(task_id, websocket)
logger.info(f"WebSocket клиент отключился от задачи {task_id!r}")
# ─── Health check ──────────────────────────────────────────────────────────────
@app.get("/health", tags=["monitoring"])
async def health_check() -> JSONResponse:
"""Проверка работоспособности сервиса."""
return JSONResponse(
content={
"status": "ok",
"service": "api",
"version": "0.1.0",
}
)
@app.get("/", include_in_schema=False)
async def root() -> JSONResponse:
"""Корневой endpoint — редирект к документации."""
return JSONResponse(
content={"message": "Академический помощник API", "docs": "/api/docs"}
)

View File

View File

@@ -0,0 +1,38 @@
"""Схемы для аутентификации и авторизации."""
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
"""Запрос на регистрацию нового пользователя."""
email: EmailStr
password: str = Field(min_length=8, max_length=128)
name: str = Field(min_length=1, max_length=255)
class LoginRequest(BaseModel):
"""Запрос на вход в систему."""
email: EmailStr
password: str
class UserInToken(BaseModel):
"""Минимальная информация о пользователе в JWT токене."""
id: int
email: str
name: str
plan: str
is_verified: bool
model_config = {"from_attributes": True}
class TokenResponse(BaseModel):
"""Ответ с JWT токеном и данными пользователя."""
access_token: str
token_type: str = "bearer"
user: UserInToken

View File

@@ -0,0 +1,46 @@
"""Схемы для отчётов о плагиате и результатов поиска."""
from datetime import datetime
from pydantic import BaseModel, Field
class PlagiarismMatch(BaseModel):
"""Совпадение фрагмента с источником."""
fragment: str
position_start: int
position_end: int
similarity: float = Field(ge=0.0, le=100.0, description="Схожесть в процентах")
method: str = Field(description="Метод обнаружения: exact, fuzzy, semantic+llm")
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
reason: str | None = None
source_title: str
source_url: str | None = None
source_db: str = Field(description="База данных: openalex, cyberleninka, arxiv, и т.д.")
class PlagiarismReport(BaseModel):
"""Полный отчёт о плагиате."""
task_id: str
overall_similarity: float = Field(ge=0.0, le=100.0)
matches: list[PlagiarismMatch] = Field(default_factory=list)
total_fragments: int
flagged_fragments: int
checked_at: datetime
class SearchResult(BaseModel):
"""Источник в результатах поиска."""
id: int
title: str
authors: list[dict] = Field(default_factory=list)
year: int | None = None
journal: str | None = None
abstract: str | None = None
url: str | None = None
relevance_score: float = Field(ge=0.0, le=1.0)
gost_citation: str = Field(description="Отформатированная ГОСТ-цитата")
source_db: str

View File

@@ -0,0 +1,39 @@
"""Схемы для задач и поиска."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
"""Создание задачи."""
type: str
input_data: dict[str, Any] = Field(default_factory=dict)
class TaskResponse(BaseModel):
"""Ответ с информацией о задаче."""
id: str
type: str
status: str
queue_position: int | None = None
eta_seconds: int | None = None
input_data: dict[str, Any] = Field(default_factory=dict)
result: dict[str, Any] | None = None
error: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class SearchRequest(BaseModel):
"""Запрос на семантический поиск источников."""
query: str = Field(min_length=3, max_length=1000)
lang: str | None = Field(default=None, description="Язык: ru, en или None для всех")
year_from: int | None = Field(default=None, ge=1900, le=2100)
year_to: int | None = Field(default=None, ge=1900, le=2100)
category: str | None = Field(default=None, description="Тематическая категория")

View File

@@ -0,0 +1,17 @@
fastapi==0.111.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.30
alembic==1.13.1
asyncpg==0.29.0
psycopg2-binary==2.9.9
redis==5.0.4
celery==5.4.0
pydantic==2.7.1
pydantic-settings==2.2.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
minio==7.2.7
httpx==0.27.0
aiofiles==23.2.1
websockets==12.0