Compare commits

..

7 Commits

Author SHA1 Message Date
jze9
8dc51a4f65 ci: пересобирать только изменённые сервисы + полный клон для diff
All checks were successful
Deploy / deploy (push) Successful in 3s
Первый прогон вслепую пересобирал все 5 бэкенд-образов, включая worker-gpu
(torch/faiss ~2 ГБ) — на медленном канале это ~1-2 часа впустую, хотя коммит
менял только CI-файлы. Теперь deploy.sh по git-diff с прошлого деплоя
(маркер .last_deploy_sha) собирает лишь сервисы с изменённым кодом; фронт
пересобирается только при правках services/frontend; правка compose = полная
пересборка. Workflow клонирует репозиторий полностью (нужен лог для diff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:34:30 +05:00
jze9
34fa495fb1 ci: авто-деплой в прод через Gitea Actions (push в main)
Some checks failed
Deploy / deploy (push) Failing after 8m6s
Self-hosted act_runner в host-режиме на app-хосте 1.32 (label "deploy").
При пуше в main workflow клонирует коммит и запускает scripts/deploy.sh:
синхронизация кода → пересборка/перезапуск бэкенд-сервисов → миграции →
сборка фронтенда → выкладка статики на CT 102 (reverse-proxy) с бэкапом.

Доступ к Proxmox-хосту для CT 102 — через секрет PVE_PASSWORD.
Больше ручных rsync/rebuild — деплой по git push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:23:04 +05:00
jze9
b0e1eadcbb feat(indexer): MinHash LSH (уровень 2) в общем Redis вместо памяти
Раньше LSH-индекс жил in-memory в процессе воркера: терялся при рестарте и
не шарился между воркерами — уровень 2 в проде фактически не работал.

Теперь индекс хранится в общем Redis (тот же REDIS_URL) через нативный
storage_config datasketch:
- переживает рестарт, общий для всех воркеров-индексеров;
- все ключи под префиксом antiplag_lsh — изолированы от кэша/rate-limits и
  чужих данных в общей БД; никаких FLUSH (и ACL-юзер их не умеет);
- add_to_lsh теперь upsert (remove+insert) — full-text версия документа
  корректно заменяет провизорную по аннотации (был латентный баг: skip-on-
  duplicate оставлял абстрактную версию);
- graceful-фолбэк в in-memory, если Redis недоступен, чтобы воркер не падал.

Проверено на боевом Redis через изолированный тестовый префикс: query
находит похожие, все ключи в неймспейсе, точечная чистка вернула БД к
исходному состоянию (чужие данные не затронуты).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:09:51 +05:00
jze9
f1c1530cea fix(frontend): убрать неиспользуемый импорт React в Settings
tsc падал с TS6133 ('React' is declared but never read) — проект на
automatic JSX runtime, дефолтный импорт React не нужен. Из-за этого не
собирался прод-фронт. Проверено: npm run build проходит.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:40:49 +05:00
jze9
5cbc1e127b feat(profile): редактирование персональных данных и смена пароля
Бэкенд:
- PATCH /auth/me — изменение имени и/или email. Смена email проверяет
  уникальность, сбрасывает is_verified и отправляет новое письмо
  подтверждения. Пользователь перезагружается из БД (объект из Redis-кэша
  не привязан к сессии), кэш инвалидируется после изменения.
- POST /auth/change-password — смена пароля с подтверждением текущего;
  отклоняет неверный текущий и совпадение нового со старым.

Фронтенд:
- Страница /settings: карточка персональных данных (имя, email со статусом
  подтверждения и предупреждением о повторной верификации) и карточка смены
  пароля с проверкой совпадения.
- Ссылка «Настройки» в карточке профиля кабинета, методы API-клиента.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:22:05 +05:00
jze9
3af79e9d03 fix(auth): сбрасывать Redis-кэш пользователя после подтверждения email
get_current_user кэширует пользователя в Redis на 5 минут. verify_email
не сбрасывал этот кэш после user.is_verified = True — если пользователь
до подтверждения хотя бы раз дёрнул защищённый эндпоинт (закешировался
как неверифицированный), он получал "Необходимо подтвердить email адрес"
ещё до 5 минут после реального подтверждения по ссылке из письма.
2026-07-27 12:22:37 +05:00
jze9
673e72a15a feat(infra): переход прод/test на общую инфраструктуру вместо self-hosted
Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env),
локально в докере остаётся только Elasticsearch + app-сервисы. Старый
docker-compose.yml (полностью автономный стек) сохранён как
docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в
будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS,
собирающий фронтенд в статику). Makefile переписан под три режима
(dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.
2026-07-27 12:22:36 +05:00
34 changed files with 825 additions and 94 deletions

View File

@@ -0,0 +1,37 @@
name: Deploy
# Авто-деплой в прод при пуше в main.
# Джоба выполняется на self-hosted runner'е в host-режиме прямо на app-хосте
# 1.32 (label "deploy"), поэтому имеет доступ к его docker и сети до Proxmox.
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: deploy
steps:
- name: Клонировать репозиторий (полностью — нужен git-лог для diff)
run: |
set -euo pipefail
SRC="$(mktemp -d)"
echo "SRC=$SRC" >> "$GITHUB_ENV"
git clone --branch main \
https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC"
- name: Деплой (бэкенд 1.32 + фронтенд CT 102)
env:
PVE_PASSWORD: ${{ secrets.PVE_PASSWORD }}
run: |
set -euo pipefail
cd "$SRC"
bash scripts/deploy.sh
- name: Убрать временный чекаут
if: always()
run: rm -rf "${SRC:-/tmp/none}"

View File

@@ -1,11 +1,18 @@
.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean .PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean test-up test-down test-logs test-ps
# ─── Переменные ──────────────────────────────────────────────────────────────── # ─── Переменные ────────────────────────────────────────────────────────────────
COMPOSE = docker compose # Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
COMPOSE_DEV = docker compose -f docker-compose.yml -f docker-compose.dev.yml # (postgres/redis/rabbitmq/minio/ollama) уже существует и берётся из .env.
# Dev (полностью локальный sandbox, свои postgres/redis/rabbitmq/minio/ollama +
# hot reload) — поверх self-hosted примера, используется редко.
# Test (то, что реально гоняется на машине разработчика день в день) — hot
# reload против той же общей инфры, что и прод.
COMPOSE_PROD = docker compose -f docker-compose.prod.yml
COMPOSE_DEV = docker compose -f docker-compose.selfhosted.yml.example -f docker-compose.dev.yml
COMPOSE_TEST = docker compose -f docker-compose.test.yml
PROJECT = anti-plagiarism PROJECT = anti-plagiarism
# ─── Разработка ──────────────────────────────────────────────────────────────── # ─── Разработка (полностью локальный sandbox, редко используется) ─────────────
dev: dev:
@cp -n .env.example .env 2>/dev/null || true @cp -n .env.example .env 2>/dev/null || true
$(COMPOSE_DEV) up --build $(COMPOSE_DEV) up --build
@@ -14,99 +21,112 @@ dev-d:
@cp -n .env.example .env 2>/dev/null || true @cp -n .env.example .env 2>/dev/null || true
$(COMPOSE_DEV) up --build -d $(COMPOSE_DEV) up --build -d
# ─── Test (hot reload против общей инфры — повседневная разработка) ──────────
test-up:
$(COMPOSE_TEST) up --build -d
test-down:
$(COMPOSE_TEST) down
test-logs:
$(COMPOSE_TEST) logs -f
test-ps:
$(COMPOSE_TEST) ps
# ─── Продакшн ───────────────────────────────────────────────────────────────── # ─── Продакшн ─────────────────────────────────────────────────────────────────
build: build:
$(COMPOSE) build $(COMPOSE_PROD) build
up: up:
$(COMPOSE) up -d $(COMPOSE_PROD) up -d
down: down:
$(COMPOSE) down $(COMPOSE_PROD) down
restart: restart:
$(COMPOSE) restart $(COMPOSE_PROD) restart
ps: ps:
$(COMPOSE) ps $(COMPOSE_PROD) ps
# ─── Миграции ───────────────────────────────────────────────────────────────── # ─── Миграции ─────────────────────────────────────────────────────────────────
migrate: migrate:
$(COMPOSE) exec api alembic upgrade head $(COMPOSE_PROD) exec api alembic upgrade head
migrate-dev: migrate-dev:
$(COMPOSE_DEV) exec api alembic upgrade head $(COMPOSE_DEV) exec api alembic upgrade head
makemigration: makemigration:
@read -p "Migration name: " name; \ @read -p "Migration name: " name; \
$(COMPOSE) exec api alembic revision --autogenerate -m "$$name" $(COMPOSE_PROD) exec api alembic revision --autogenerate -m "$$name"
downgrade: downgrade:
$(COMPOSE) exec api alembic downgrade -1 $(COMPOSE_PROD) exec api alembic downgrade -1
# ─── Логи ───────────────────────────────────────────────────────────────────── # ─── Логи ─────────────────────────────────────────────────────────────────────
logs: logs:
$(COMPOSE) logs -f $(COMPOSE_PROD) logs -f
logs-api: logs-api:
$(COMPOSE) logs -f api $(COMPOSE_PROD) logs -f api
logs-gpu: logs-gpu:
$(COMPOSE) logs -f worker-gpu $(COMPOSE_PROD) logs -f worker-gpu
logs-indexer: logs-indexer:
$(COMPOSE) logs -f worker-indexer $(COMPOSE_PROD) logs -f worker-indexer
logs-notifier: logs-notifier:
$(COMPOSE) logs -f worker-notifier $(COMPOSE_PROD) logs -f worker-notifier
logs-gost: logs-gost:
$(COMPOSE) logs -f worker-gost $(COMPOSE_PROD) logs -f worker-gost
# ─── Шеллы ──────────────────────────────────────────────────────────────────── # ─── Шеллы ────────────────────────────────────────────────────────────────────
shell-api: shell-api:
$(COMPOSE) exec api bash $(COMPOSE_PROD) exec api bash
shell-gpu: shell-gpu:
$(COMPOSE) exec worker-gpu bash $(COMPOSE_PROD) exec worker-gpu bash
shell-indexer: shell-indexer:
$(COMPOSE) exec worker-indexer bash $(COMPOSE_PROD) exec worker-indexer bash
shell-db: shell-db:
$(COMPOSE) exec postgres psql -U antiplagiator antiplagiator @echo "Postgres общий (192.168.1.11) — подключайся напрямую: psql -h 192.168.1.11 -U antiplagiator antiplagiator"
shell-redis: shell-redis:
$(COMPOSE) exec redis redis-cli @echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3"
# ─── Линтинг и тесты ────────────────────────────────────────────────────────── # ─── Линтинг и тесты ──────────────────────────────────────────────────────────
lint: lint:
$(COMPOSE) exec api ruff check . --fix $(COMPOSE_PROD) exec api ruff check . --fix
$(COMPOSE) exec api mypy app/ $(COMPOSE_PROD) exec api mypy app/
lint-frontend: lint-frontend:
cd services/frontend && npm run lint cd services/frontend && npm run lint
test: test:
$(COMPOSE) exec api pytest tests/ -v $(COMPOSE_PROD) exec api pytest tests/ -v
test-cov: test-cov:
$(COMPOSE) exec api pytest tests/ -v --cov=app --cov-report=html $(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html
# ─── Утилиты ────────────────────────────────────────────────────────────────── # ─── Утилиты ──────────────────────────────────────────────────────────────────
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —
# общие postgres/redis/rabbitmq/minio не в Compose, их не затронет.
clean: clean:
$(COMPOSE) down -v --remove-orphans $(COMPOSE_PROD) down -v --remove-orphans
docker system prune -f docker system prune -f
# Запустить парсер (пример: make parse PARSER=openalex ARGS="--limit 1000") # Запустить парсер (пример: make parse PARSER=openalex ARGS="--limit 1000")
parse: parse:
$(COMPOSE) exec api python /scripts/run_parser.py $(PARSER) $(ARGS) $(COMPOSE_PROD) exec api python /scripts/run_parser.py $(PARSER) $(ARGS)
# Создать MinIO бакеты # Создать MinIO бакеты (запускать один раз на общем MinIO 192.168.1.21, не локально)
minio-init: minio-init:
$(COMPOSE) exec minio mc alias set local http://localhost:9000 minioadmin changeme @echo "MinIO общий (192.168.1.21) — бакеты documents/backups создаются на нём, см. .env MINIO_*"
$(COMPOSE) exec minio mc mb local/documents || true
$(COMPOSE) exec minio mc mb local/backups || true
# Создать ES индекс # Создать ES индекс
es-init: es-init:
@@ -115,13 +135,14 @@ es-init:
# Статистика Celery # Статистика Celery
flower: flower:
@echo "Flower доступен по адресу http://localhost:5555" @echo "Flower доступен по адресу http://localhost:5555"
@$(COMPOSE) ps flower @$(COMPOSE_PROD) ps flower
help: help:
@echo "Академический помощник — команды make:" @echo "Академический помощник — команды make:"
@echo "" @echo ""
@echo " make dev — запустить в режиме разработки (hot reload)" @echo " make test-up — запустить тестовый стек (hot reload, общая инфра)"
@echo " make buildсобрать Docker образы" @echo " make dev запустить полностью локальный sandbox (редко нужно)"
@echo " make build — собрать прод-образы"
@echo " make up — запустить в продакшн режиме" @echo " make up — запустить в продакшн режиме"
@echo " make down — остановить все сервисы" @echo " make down — остановить все сервисы"
@echo " make migrate — применить миграции Alembic" @echo " make migrate — применить миграции Alembic"
@@ -129,4 +150,4 @@ help:
@echo " make shell-api — открыть shell в контейнере api" @echo " make shell-api — открыть shell в контейнере api"
@echo " make lint — запустить линтер" @echo " make lint — запустить линтер"
@echo " make test — запустить тесты" @echo " make test — запустить тесты"
@echo " make clean — удалить все контейнеры и volumes" @echo " make clean — удалить прод-контейнеры и ЛОКАЛЬНЫЕ volume"

View File

@@ -134,21 +134,31 @@ python scripts/run_parser.py arxiv \
Смотри `.env.example` для полного списка переменных. Смотри `.env.example` для полного списка переменных.
Обязательно смените `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. Обязательно смените `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`.
## Три docker-compose файла
| Файл | Назначение |
|------|-----------|
| `docker-compose.prod.yml` | **Реальный прод.** app-сервисы + nginx (собранный фронтенд + TLS) + локальный Elasticsearch. Postgres/Redis/RabbitMQ/MinIO/Ollama — уже существующие общие серверы, адреса в `.env`. |
| `docker-compose.test.yml` | Повседневная разработка — hot reload против той же общей инфры, что и прод (`make test-up`). |
| `docker-compose.selfhosted.yml.example` | Не используется. Полностью автономный вариант (свои Postgres/Redis/RabbitMQ/ES/MinIO/Ollama + GPU passthrough) — на случай отдельного выделенного сервера в будущем. |
## Продакшн деплой ## Продакшн деплой
```bash ```bash
# На сервере # На сервере
cp .env.example .env cp .env.example .env
nano .env # Настроить все пароли и ключи nano .env # Настроить все пароли и ключи, указать реальные адреса общих Postgres/Redis/RabbitMQ/MinIO/Ollama
# Сертификат ДО первого запуска nginx-контейнера (standalone, порт 80 должен быть свободен)
certbot certonly --standalone -d academic.jze9.ru
make build make build
make up make up
make migrate make migrate
# SSL сертификат (Let's Encrypt)
certbot --nginx -d academic.jze9.ru
``` ```
Nginx работает внутри контейнера (`docker-compose.prod.yml`, сервис `nginx`, Dockerfile в `infra/nginx/Dockerfile`) — собирает `services/frontend` в статику и отдаёт её вместе с проксированием `/api/`, `/ws/` на `api:8000` по конфигу `infra/nginx/nginx.conf`. Контейнер монтирует `/etc/letsencrypt` с хоста как read-only — сертификат обновляется на хосте (`certbot renew`), контейнер просто читает его.
## Лицензия ## Лицензия
MIT MIT

129
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,129 @@
# ═══════════════════════════════════════════════════════════════════════════════
# ПРОД стек: локально — nginx (static+proxy), api, воркеры, elasticsearch.
# Удалённо (через .env) — postgres, redis, minio, ollama, rabbitmq.
# Тот же принцип, что и docker-compose.test.yml (не дублировать общую инфру),
# но без bind-mount'ов исходников, без --reload, с собранным фронтендом и nginx/TLS.
# Запуск: docker compose -f docker-compose.prod.yml up -d --build
# ═══════════════════════════════════════════════════════════════════════════════
networks:
antiplagiator:
driver: bridge
volumes:
es_prod_data:
faiss_index_prod:
x-app-env: &app-env
env_file: .env
networks:
- antiplagiator
restart: unless-stopped
services:
# ─── Локальная инфраструктура ──────────────────────────────────────────────
elasticsearch:
image: elasticsearch:8.13.0
container_name: antiplagiator-elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- xpack.ml.enabled=false
- ES_JAVA_OPTS=-Xms2g -Xmx2g
- bootstrap.memory_lock=true
- cluster.name=antiplagiator-prod
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- es_prod_data:/usr/share/elasticsearch/data
networks:
- antiplagiator
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -qv '\"status\":\"red\"'"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
# ─── Приложение ────────────────────────────────────────────────────────────
# RabbitMQ вынесен на отдельный сервер 192.168.20.82 (см. RABBITMQ_URL в .env)
api:
build:
context: ./services/api
dockerfile: Dockerfile
container_name: antiplagiator-api
<<: *app-env
depends_on:
elasticsearch:
condition: service_healthy
worker-gpu:
build:
context: ./services/worker-gpu
dockerfile: Dockerfile
container_name: antiplagiator-worker-gpu
<<: *app-env
volumes:
- faiss_index_prod:/data/index
depends_on:
elasticsearch:
condition: service_healthy
worker-indexer:
build:
context: ./services/worker-indexer
dockerfile: Dockerfile
container_name: antiplagiator-worker-indexer
<<: *app-env
volumes:
- ./scripts/parsers:/parsers:ro
depends_on:
elasticsearch:
condition: service_healthy
worker-notifier:
build:
context: ./services/worker-notifier
dockerfile: Dockerfile
container_name: antiplagiator-worker-notifier
<<: *app-env
worker-gost:
build:
context: ./services/worker-gost
dockerfile: Dockerfile
container_name: antiplagiator-worker-gost
<<: *app-env
flower:
image: mher/flower:2.0
container_name: antiplagiator-flower
command: celery --broker=${RABBITMQ_URL} flower --port=5555
environment:
CELERY_BROKER_URL: ${RABBITMQ_URL}
CELERY_RESULT_BACKEND: ${REDIS_URL}
networks:
- antiplagiator
restart: unless-stopped
# Flower не публикуется наружу напрямую — доступ через ssh-туннель при необходимости
# ─── Nginx: собранный фронтенд (static) + proxy /api, /ws → api:8000, TLS ──
nginx:
build:
context: .
dockerfile: infra/nginx/Dockerfile
container_name: antiplagiator-nginx
networks:
- antiplagiator
ports:
- "80:80"
- "443:443"
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
restart: unless-stopped

View File

@@ -1,5 +1,13 @@
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# Академический помощник — Production Docker Compose # Академический помощник — self-hosted вариант (свои Postgres/Redis/RabbitMQ/
# ES/MinIO/Ollama + GPU passthrough для ollama/worker-gpu).
#
# НЕ используется сейчас — реальный прод разворачивается через
# docker-compose.prod.yml поверх уже существующей общей инфры (Postgres .11,
# Redis .19, RabbitMQ .82, MinIO .21, Ollama CT108). Держим этот файл на случай,
# если когда-нибудь появится отдельный GPU-сервер под этот проект — тогда
# у worker-indexer нужно будет ещё добавить volume ./scripts/parsers:/parsers:ro
# (в этом файле его сейчас нет, в отличие от prod/test).
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
networks: networks:
@@ -13,7 +21,7 @@ volumes:
elasticsearch_data: elasticsearch_data:
minio_data: minio_data:
ollama_data: ollama_data:
faiss_index: faiss_index_selfhosted:
# ─── Общие переменные окружения для app-сервисов ──────────────────────────── # ─── Общие переменные окружения для app-сервисов ────────────────────────────
x-app-env: &app-env x-app-env: &app-env
@@ -177,7 +185,7 @@ services:
<<: *app-env <<: *app-env
command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info
volumes: volumes:
- faiss_index:/data/index - faiss_index_selfhosted:/data/index
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

10
infra/nginx/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:20-slim AS build
WORKDIR /app
COPY services/frontend/package.json services/frontend/package-lock.json* ./
RUN npm install
COPY services/frontend/ .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /var/www/academic
COPY infra/nginx/nginx.conf /etc/nginx/nginx.conf

View File

@@ -72,6 +72,12 @@ http {
add_header X-Frame-Options DENY; add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
# ── Health check (не под /api/ в самом приложении) ─────────────────────
location = /health {
proxy_pass http://api/health;
proxy_set_header Host $host;
}
# ── API proxy ────────────────────────────────────────────────────────── # ── API proxy ──────────────────────────────────────────────────────────
location /api/ { location /api/ {
proxy_pass http://api/api/; proxy_pass http://api/api/;

109
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Деплой anti-plagiarism (умная пересборка — только изменённых сервисов).
#
# Запускается Gitea Actions runner'ом в host-режиме прямо на app-хосте 1.32
# (см. .gitea/workflows/deploy.yml). Ожидает:
# - cwd = ПОЛНЫЙ чекаут репозитория (нужен git-лог для diff);
# - переменную PVE_PASSWORD (секрет) для доступа к Proxmox-хосту → CT 102.
#
# Пересобирается только то, чей код изменился с прошлого деплоя (маркер SHA в
# $APP/.last_deploy_sha). Это критично: worker-gpu тянет torch/faiss (~2 ГБ),
# его нельзя пересобирать без нужды. Если менялся docker-compose.prod.yml —
# пересобираются все бэкенд-сервисы.
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
APP=/home/user/anti-plagiarism
SRC="$(pwd)"
PVE_HOST=192.168.20.254
COMPOSE="docker compose -f docker-compose.prod.yml"
ALL_BACKEND="api worker-gpu worker-indexer worker-notifier worker-gost"
MARKER="$APP/.last_deploy_sha"
CUR_SHA="$(git -C "$SRC" rev-parse HEAD)"
# ── Определить, что менялось ──────────────────────────────────────────────────
FULL=0
CHANGED=""
if [ -f "$MARKER" ] && PREV="$(cat "$MARKER")" && [ -n "$PREV" ] \
&& git -C "$SRC" cat-file -e "$PREV" 2>/dev/null; then
CHANGED="$(git -C "$SRC" diff --name-only "$PREV" "$CUR_SHA")"
echo "Изменения с $PREV:"
echo "$CHANGED" | sed 's/^/ /'
# Правка общего compose затрагивает все образы
echo "$CHANGED" | grep -qE '^docker-compose\.prod\.yml$' && FULL=1
else
echo "Маркер прошлого деплоя недоступен — полная пересборка (разовая)"
FULL=1
fi
REBUILD=""
FRONTEND_CHANGED=0
if [ "$FULL" = 1 ]; then
REBUILD="$ALL_BACKEND"
FRONTEND_CHANGED=1
else
for svc in $ALL_BACKEND; do
echo "$CHANGED" | grep -qE "^services/$svc/" && REBUILD="$REBUILD $svc"
done
echo "$CHANGED" | grep -qE '^services/frontend/' && FRONTEND_CHANGED=1
fi
REBUILD="$(echo "$REBUILD" | xargs || true)"
echo "==> [1/5] Синхронизация кода в $APP (сохраняя .env и compose)"
rsync -a \
--exclude='__pycache__/' --exclude='*.pyc' --exclude='.pytest_cache/' \
--exclude='node_modules/' --exclude='dist/' \
"$SRC/services" "$SRC/scripts" "$APP/"
cd "$APP"
if [ -n "$REBUILD" ]; then
echo "==> [2/5] Пересборка и перезапуск: $REBUILD"
# shellcheck disable=SC2086
$COMPOSE up -d --build $REBUILD
else
echo "==> [2/5] Бэкенд-сервисы не менялись — пропуск сборки"
fi
echo "==> [3/5] Миграции БД (с ретраем)"
for i in $(seq 1 10); do
$COMPOSE exec -T api alembic upgrade head && break
echo " api ещё не готов, повтор $i/10..."; sleep 5
done
if [ "$FRONTEND_CHANGED" = 1 ]; then
echo "==> [4/5] Сборка фронтенда"
docker run --rm -v "$APP/services/frontend":/app -w /app node:20-slim \
sh -c "npm install --no-audit --no-fund --loglevel=error && npm run build"
echo "==> [5/5] Выкладка статики на CT 102 (через $PVE_HOST)"
cd "$APP/services/frontend/dist"
tar czf /tmp/academic-dist.tgz .
export SSHPASS="${PVE_PASSWORD:?PVE_PASSWORD не задан}"
SSHOPT="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
# shellcheck disable=SC2086
sshpass -e scp $SSHOPT /tmp/academic-dist.tgz "root@${PVE_HOST}:/tmp/academic-dist.tgz"
# shellcheck disable=SC2086
sshpass -e ssh $SSHOPT "root@${PVE_HOST}" 'bash -s' <<'REMOTE'
set -e
pct push 102 /tmp/academic-dist.tgz /tmp/academic-dist.tgz
pct exec 102 -- bash -c '
set -e
cd /var/www
cp -a academic "academic.bak.$(date +%s)"
cd academic
rm -rf assets index.html
tar xzf /tmp/academic-dist.tgz
chown -R 1000:1000 .
nginx -t && nginx -s reload
'
rm -f /tmp/academic-dist.tgz
REMOTE
rm -f /tmp/academic-dist.tgz
else
echo "==> [4-5/5] Фронтенд не менялся — пропуск сборки и выкладки"
fi
echo "$CUR_SHA" > "$MARKER"
echo "==> Деплой завершён успешно (маркер обновлён: $CUR_SHA)"

View File

@@ -12,11 +12,19 @@ from app.core.security import (
create_access_token, create_access_token,
get_current_user, get_current_user,
hash_password, hash_password,
invalidate_user_cache,
verify_password, verify_password,
) )
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserInToken from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
UpdateProfileRequest,
UserInToken,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -98,6 +106,96 @@ async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken:
return UserInToken.model_validate(current_user) return UserInToken.model_validate(current_user)
@router.patch("/me", response_model=UserInToken)
async def update_profile(
data: UpdateProfileRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> UserInToken:
"""
Изменить персональные данные (имя и/или email).
Смена email сбрасывает подтверждение и отправляет новое письмо верификации.
"""
# Загружаем "живого" пользователя из БД: объект из Redis-кэша не привязан
# к сессии и не содержит части полей.
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
email_changed = False
if data.name is not None:
user.name = data.name
if data.email is not None:
new_email = data.email.lower()
if new_email != user.email:
existing = await db.execute(
select(User).where(User.email == new_email, User.id != user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Этот email уже используется другим аккаунтом",
)
user.email = new_email
user.is_verified = False
user.verification_token = secrets.token_urlsafe(32)
email_changed = True
await db.commit()
await db.refresh(user)
await invalidate_user_cache(user.id)
# При смене email — отправить новое письмо подтверждения на новый адрес
if email_changed:
try:
celery_app.send_task(
"notify.send_verification",
args=[user.email, user.name, user.verification_token],
queue="queue.notify",
)
except Exception as e:
logger.warning(f"Не удалось отправить письмо верификации при смене email: {e}")
return UserInToken.model_validate(user)
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Сменить пароль. Требует текущий пароль для подтверждения."""
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
if not verify_password(data.current_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Текущий пароль указан неверно",
)
if verify_password(data.new_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль совпадает с текущим",
)
user.hashed_password = hash_password(data.new_password)
await db.commit()
await invalidate_user_cache(user.id)
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT) @router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
async def resend_verification( async def resend_verification(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -146,5 +244,6 @@ async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
user.is_verified = True user.is_verified = True
user.verification_token = None user.verification_token = None
await db.commit() await db.commit()
await invalidate_user_cache(user.id)
return {"message": "Email успешно подтверждён"} return {"message": "Email успешно подтверждён"}

View File

@@ -18,6 +18,20 @@ class LoginRequest(BaseModel):
password: str password: str
class UpdateProfileRequest(BaseModel):
"""Изменение персональных данных (имя и/или email)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
email: EmailStr | None = None
class ChangePasswordRequest(BaseModel):
"""Смена пароля: нужен текущий пароль для подтверждения."""
current_password: str
new_password: str = Field(min_length=8, max_length=128)
class UserInToken(BaseModel): class UserInToken(BaseModel):
"""Минимальная информация о пользователе в JWT токене.""" """Минимальная информация о пользователе в JWT токене."""

View File

@@ -38,6 +38,12 @@ export const authApi = {
me: () => api.get('/auth/me'), me: () => api.get('/auth/me'),
updateProfile: (data: { name?: string; email?: string }) =>
api.patch('/auth/me', data),
changePassword: (data: { current_password: string; new_password: string }) =>
api.post('/auth/change-password', data),
verifyEmail: (token: string) => verifyEmail: (token: string) =>
api.post(`/auth/verify-email/${token}`), api.post(`/auth/verify-email/${token}`),

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { Upload, FileText, X } from 'lucide-react'; import { Upload, FileText, X } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Copy, Check } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom'; import { Link, NavLink, useNavigate } from 'react-router-dom';
import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react'; import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { EmailVerificationModal } from './EmailVerificationModal'; import { EmailVerificationModal } from './EmailVerificationModal';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'; import { AlertTriangle, CheckCircle, Info } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { PlagiarismResultData } from '../types'; import type { PlagiarismResultData } from '../types';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react'; import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { TaskStatus } from '../types'; import type { TaskStatus } from '../types';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';

View File

@@ -10,6 +10,7 @@ import { Search } from './pages/Search';
import { Check } from './pages/Check'; import { Check } from './pages/Check';
import { Bibliography } from './pages/Bibliography'; import { Bibliography } from './pages/Bibliography';
import { Cabinet } from './pages/Cabinet'; import { Cabinet } from './pages/Cabinet';
import { Settings } from './pages/Settings';
import { Task } from './pages/Task'; import { Task } from './pages/Task';
import { Pricing } from './pages/Pricing'; import { Pricing } from './pages/Pricing';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
@@ -45,6 +46,7 @@ function PublicApp() {
<Route path="/check" element={<Check />} /> <Route path="/check" element={<Check />} />
<Route path="/bibliography" element={<Bibliography />} /> <Route path="/bibliography" element={<Bibliography />} />
<Route path="/cabinet" element={<Cabinet />} /> <Route path="/cabinet" element={<Cabinet />} />
<Route path="/settings" element={<Settings />} />
<Route path="/tasks/:taskId" element={<Task />} /> <Route path="/tasks/:taskId" element={<Task />} />
<Route path="/pricing" element={<Pricing />} /> <Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />

View File

@@ -1,16 +1,14 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react'; import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { GostCitation } from '../components/GostCitation'; import { GostCitation } from '../components/GostCitation';
import { useBibliographyStore } from '../store/bibliography'; import { useBibliographyStore } from '../store/bibliography';
import { useAuthStore } from '../store/auth';
import { api } from '../api/client'; import { api } from '../api/client';
import type { GostResultData } from '../types'; import type { GostResultData } from '../types';
export function Bibliography() { export function Bibliography() {
const { sources, removeSource, clearSources } = useBibliographyStore(); const { sources, removeSource, clearSources } = useBibliographyStore();
const { isAuthenticated } = useAuthStore();
const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1'); const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1');
const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null); const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null);

View File

@@ -1,7 +1,6 @@
import React from 'react'; import { Navigate, useNavigate, Link } from 'react-router-dom';
import { Navigate, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Crown, Search, Shield, BookOpen, ShieldAlert } from 'lucide-react'; import { Crown, Search, Shield, BookOpen, ShieldAlert, Settings as SettingsIcon } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { TaskCard } from '../components/TaskCard'; import { TaskCard } from '../components/TaskCard';
import { tasksApi, adminApi } from '../api/client'; import { tasksApi, adminApi } from '../api/client';
@@ -61,6 +60,13 @@ export function Cabinet() {
<p className="text-gray-500 text-sm">{user?.email}</p> <p className="text-gray-500 text-sm">{user?.email}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link
to="/settings"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Настройки
</Link>
{user?.is_admin && ( {user?.is_admin && (
<button <button
onClick={openAdmin} onClick={openAdmin}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Search, Shield, BookOpen, Zap } from 'lucide-react'; import { Search, Shield, BookOpen, Zap } from 'lucide-react';
import { SearchBar } from '../components/SearchBar'; import { SearchBar } from '../components/SearchBar';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { PLAN_LIMITS } from '../types'; import { PLAN_LIMITS } from '../types';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { Filter, Loader2 } from 'lucide-react'; import { Filter, Loader2 } from 'lucide-react';
@@ -41,16 +41,13 @@ export function Search() {
}); });
// Поллинг задачи // Поллинг задачи
const { const { data: task } = useQuery({
data: task,
isLoading: isPolling,
} = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task), queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task),
enabled: !!taskId, enabled: !!taskId,
refetchInterval: (data) => { refetchInterval: (query) => {
if (!data) return 3000; const status = query.state.data?.status;
if (data.status === 'done' || data.status === 'failed') return false; if (status === 'done' || status === 'failed') return false;
return 3000; return 3000;
}, },
}); });

View File

@@ -0,0 +1,237 @@
import { useState } from 'react';
import { Navigate, Link } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { ArrowLeft, User as UserIcon, Lock, Mail, AlertCircle, CheckCircle2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { authApi } from '../api/client';
import { useAuthStore } from '../store/auth';
import type { User } from '../types';
export function Settings() {
const { isAuthenticated, user, updateUser } = useAuthStore();
// Персональные данные
const [name, setName] = useState(user?.name ?? '');
const [email, setEmail] = useState(user?.email ?? '');
// Смена пароля
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
if (!isAuthenticated) return <Navigate to="/login" replace />;
const emailChanged = email.trim().toLowerCase() !== (user?.email ?? '').toLowerCase();
const profileDirty = name.trim() !== (user?.name ?? '') || emailChanged;
const saveProfile = useMutation({
mutationFn: () => {
const payload: { name?: string; email?: string } = {};
if (name.trim() !== user?.name) payload.name = name.trim();
if (emailChanged) payload.email = email.trim();
return authApi.updateProfile(payload);
},
onSuccess: (response) => {
const updated: User = response.data;
updateUser(updated);
setEmail(updated.email);
setName(updated.name);
toast.success(
emailChanged
? 'Данные сохранены. Мы отправили письмо для подтверждения нового email.'
: 'Данные сохранены'
);
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Не удалось сохранить данные');
},
});
const changePassword = useMutation({
mutationFn: () =>
authApi.changePassword({ current_password: currentPassword, new_password: newPassword }),
onSuccess: () => {
toast.success('Пароль изменён');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Не удалось сменить пароль');
},
});
const passwordMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
const canChangePassword =
currentPassword.length > 0 &&
newPassword.length >= 8 &&
newPassword === confirmPassword &&
!changePassword.isPending;
const inputClass =
'w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-shadow';
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Шапка */}
<div className="flex items-center gap-3">
<Link
to="/cabinet"
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl font-semibold text-gray-900">Настройки профиля</h1>
<p className="text-sm text-gray-400">Персональные данные и безопасность</p>
</div>
</div>
{/* Персональные данные */}
<form
onSubmit={(e) => {
e.preventDefault();
if (profileDirty) saveProfile.mutate();
}}
className="bg-white rounded-2xl border border-gray-100 p-6"
>
<div className="flex items-center gap-2 mb-5">
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
<UserIcon className="w-4 h-4 text-brand-600" />
</div>
<h2 className="text-base font-semibold text-gray-900">Персональные данные</h2>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Имя</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
maxLength={255}
placeholder="Иван Иванов"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Email</label>
<div className="relative">
<Mail className="w-4 h-4 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="ivan@example.com"
className={`${inputClass} pl-10`}
/>
</div>
{/* Статус подтверждения / предупреждение о смене */}
{emailChanged ? (
<div className="flex items-start gap-2 mt-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
После сохранения потребуется подтвердить новый адрес мы пришлём письмо со ссылкой.
</span>
</div>
) : user?.is_verified ? (
<div className="flex items-center gap-1.5 mt-2 text-xs text-green-600">
<CheckCircle2 className="w-3.5 h-3.5" />
Email подтверждён
</div>
) : (
<div className="flex items-center gap-1.5 mt-2 text-xs text-amber-600">
<AlertCircle className="w-3.5 h-3.5" />
Email не подтверждён
</div>
)}
</div>
</div>
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={!profileDirty || saveProfile.isPending}
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saveProfile.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
</button>
</div>
</form>
{/* Смена пароля */}
<form
onSubmit={(e) => {
e.preventDefault();
if (canChangePassword) changePassword.mutate();
}}
className="bg-white rounded-2xl border border-gray-100 p-6"
>
<div className="flex items-center gap-2 mb-5">
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
<Lock className="w-4 h-4 text-brand-600" />
</div>
<h2 className="text-base font-semibold text-gray-900">Смена пароля</h2>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Текущий пароль</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
placeholder="••••••••"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Новый пароль</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
placeholder="Минимум 8 символов"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">
Повторите новый пароль
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="••••••••"
className={`${inputClass} ${passwordMismatch ? 'border-red-300 focus:ring-red-400' : ''}`}
/>
{passwordMismatch && (
<p className="text-xs text-red-500 mt-1.5">Пароли не совпадают</p>
)}
</div>
</div>
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={!canChangePassword}
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{changePassword.isPending ? 'Меняем...' : 'Изменить пароль'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useParams, Navigate } from 'react-router-dom'; import { useParams, Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Loader2, ArrowLeft } from 'lucide-react'; import { Loader2, ArrowLeft } from 'lucide-react';
@@ -22,9 +21,9 @@ export function Task() {
const { data: task, isLoading } = useQuery({ const { data: task, isLoading } = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType), queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType),
refetchInterval: (data) => { refetchInterval: (query) => {
if (!data) return 3000; const status = query.state.data?.status;
return (data.status === 'done' || data.status === 'failed') ? false : 3000; return (status === 'done' || status === 'failed') ? false : 3000;
}, },
}); });

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, Search } from 'lucide-react'; import { Trash2, Search } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react'; import { Play, Trash2, Plus } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, X, Eye } from 'lucide-react'; import { Check, X, Eye } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { HardDrive } from 'lucide-react'; import { HardDrive } from 'lucide-react';
import { adminApi } from '../../api/client'; import { adminApi } from '../../api/client';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, Search } from 'lucide-react'; import { Trash2, Search } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Trash2 } from 'lucide-react'; import { RefreshCw, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

1
services/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,29 +1,73 @@
"""MinHash LSH для нечёткого поиска похожих документов. """MinHash LSH для нечёткого поиска похожих документов (уровень 2).
Позволяет быстро находить документы с похожим содержимым Индекс хранится в общем Redis (тот же, что кэш/rate-limits, см. REDIS_URL),
без точного сравнения всех пар. а не в памяти процесса. Это даёт:
- переживаемость рестарта воркера (индекс не теряется);
- общий индекс для всех воркеров-индексеров (add в одном видно в query другого).
При недоступности Redis — graceful-фолбэк в in-memory (деградация: индекс
локальный и теряется при рестарте), чтобы воркер не падал целиком.
""" """
import logging import logging
from urllib.parse import urlparse
from datasketch import MinHash, MinHashLSH from datasketch import MinHash, MinHashLSH
from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Параметры MinHash LSH # Параметры MinHash LSH
LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты
LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память) LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память)
# Префикс ключей в общем Redis — изолирует индекс от кэша/rate-limits в той же БД
LSH_BASENAME = b"antiplag_lsh"
# Глобальный LSH индекс (in-memory)
_lsh: MinHashLSH | None = None _lsh: MinHashLSH | None = None
_redis_unavailable = False # чтобы не долбить недоступный Redis на каждый вызов
def _redis_storage_config() -> dict | None:
"""Собрать storage_config datasketch из REDIS_URL. None — если разбор не удался."""
try:
u = urlparse(settings.REDIS_URL)
redis_kwargs: dict = {
"host": u.hostname or "localhost",
"port": u.port or 6379,
"db": int((u.path or "/0").lstrip("/") or 0),
}
if u.username:
redis_kwargs["username"] = u.username
if u.password:
redis_kwargs["password"] = u.password
return {"type": "redis", "basename": LSH_BASENAME, "redis": redis_kwargs}
except Exception as e:
logger.error(f"MinHash LSH: не удалось разобрать REDIS_URL: {e}")
return None
def get_lsh() -> MinHashLSH: def get_lsh() -> MinHashLSH:
"""Получить или создать глобальный LSH индекс.""" """Получить или создать глобальный LSH индекс (Redis-backed, фолбэк — память)."""
global _lsh global _lsh, _redis_unavailable
if _lsh is None: if _lsh is not None:
return _lsh
if not _redis_unavailable:
cfg = _redis_storage_config()
if cfg is not None:
try:
_lsh = MinHashLSH(
threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM, storage_config=cfg
)
logger.info("MinHash LSH: общий Redis-бэкенд подключён")
return _lsh
except Exception as e:
logger.error(f"MinHash LSH: Redis недоступен ({e}) — фолбэк в память")
_redis_unavailable = True
_lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM) _lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM)
logger.info("MinHash LSH индекс создан") logger.warning("MinHash LSH: in-memory режим (индекс не шарится и теряется при рестарте)")
return _lsh return _lsh
@@ -62,7 +106,10 @@ def text_to_minhash(text: str) -> MinHash:
def add_to_lsh(doc_key: str, text: str) -> None: def add_to_lsh(doc_key: str, text: str) -> None:
""" """
Добавить документ в LSH индекс. Добавить (или обновить) документ в LSH индекс.
Upsert: если документ уже есть (например, был добавлен по аннотации, а теперь
пересчитывается по полному тексту) — старая подпись удаляется, вставляется новая.
Args: Args:
doc_key: Уникальный ключ документа (например, "doc:{id}") doc_key: Уникальный ключ документа (например, "doc:{id}")
@@ -71,10 +118,13 @@ def add_to_lsh(doc_key: str, text: str) -> None:
lsh = get_lsh() lsh = get_lsh()
m = text_to_minhash(text) m = text_to_minhash(text)
try: try:
try:
lsh.remove(doc_key) # снять прежнюю версию, если была
except Exception:
pass # ключа не было — это норма
lsh.insert(doc_key, m) lsh.insert(doc_key, m)
except ValueError: except Exception as e:
# Документ уже в индексе — игнорируем logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}")
pass
def find_similar(text: str) -> list[str]: def find_similar(text: str) -> list[str]:
@@ -113,6 +163,7 @@ def compute_jaccard_minhash(text_a: str, text_b: str) -> float:
def reset_lsh() -> None: def reset_lsh() -> None:
"""Сбросить LSH индекс (для тестов).""" """Сбросить handle LSH (для тестов). Данные в Redis не трогает."""
global _lsh global _lsh, _redis_unavailable
_lsh = None _lsh = None
_redis_unavailable = False