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:
1
services/api/app/api/__init__.py
Normal file
1
services/api/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API роутеры
|
||||
119
services/api/app/api/auth.py
Normal file
119
services/api/app/api/auth.py
Normal 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 успешно подтверждён"}
|
||||
155
services/api/app/api/documents.py
Normal file
155
services/api/app/api/documents.py
Normal 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)
|
||||
77
services/api/app/api/reports.py
Normal file
77
services/api/app/api/reports.py
Normal 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,
|
||||
}
|
||||
95
services/api/app/api/search.py
Normal file
95
services/api/app/api/search.py
Normal 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)
|
||||
89
services/api/app/api/tasks.py
Normal file
89
services/api/app/api/tasks.py
Normal 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()
|
||||
Reference in New Issue
Block a user