B22: прикладное логирование vector.* + администрирование в доках (по образцу РВС)

- backend/logging_setup.py: setup_logging() (stdout INFO + файл /tmp/vector/vector.log,
  ротация 5×2 МБ, VectorLOGLEVEL/VectorLOGFILE/VectorLOGDIR), get_logger('vector.*')
- события: vector.analyze (матрасчёт с длительностью), vector.auth (login_ok/failed/
  lockout), vector.audit (каждая запись), vector.contour (ok/failed/rejected),
  vector.operations (operation_complete), vector.geocode (успех/timeout/ошибка)
- существующие логгеры переведены в namespace vector.* (geo, geo.osm, rules, water, admin)
- README §13а: таблица наблюдаемых событий + правила (без ПДн/секретов)
- SETUP_GUIDE: §15 RPO/RTO + backup≠restore, §16 grep по vector.*, §17 диагностика
  по цепочкам (БД, 500, fallback-зоны, вход), §17а чек-лист передачи проекта
- DEPLOY.md: вместо устаревшего дубля — ссылка на актуальные доки + шпаргалка стенда
- тесты: 239 passed
This commit is contained in:
2026-09-25 09:38:39 +03:00
parent 1513bad538
commit d5a270f3af
16 changed files with 345 additions and 117 deletions
+23 -105
View File
@@ -1,118 +1,36 @@
# Инструкция по развертыванию SAR-MCHS на LXC 108 # Развёртывание ВЕКТОРа
## Текущее состояние **Актуальная документация:**
Проект развернут на LXC 108 (Lab) по адресу: **192.168.0.99** - **[SETUP_GUIDE.md](SETUP_GUIDE.md)** — полное руководство: требования, Docker, .env,
миграции/сиды, OSM-геослой, пользователи, КОНТУР, HTTPS, обновление, бэкапы,
мониторинг, диагностика, чек-листы (развёртывание §18, передача проекта §17а).
- **[README.md](README.md)** — описание системы: архитектура, API, БД, RBAC,
аудит, прикладное логирование (§13а).
## Доступ к сервисам ## Текущее состояние (тестовый стенд)
- **Backend API**: http://192.168.0.99:8000 Проект развёрнут на LXC 108 (Lab): **192.168.0.99**, путь `/root/vector`, docker compose.
- Swagger документация: http://192.168.0.99:8000/docs
- ReDoc: http://192.168.0.99:8000/redoc
- **Frontend**: http://192.168.0.99:3000
- **Adminer** (управление БД): http://192.168.0.99:8080 | Сервис | Адрес |
- Система: PostgreSQL |--------|-------|
- Сервер: postgres | Backend API + Swagger | http://192.168.0.99:8000 (/docs) |
- Пользователь: postgres | Frontend | http://192.168.0.99:3000 |
- Пароль: postgres | Adminer | http://192.168.0.99:8080 (postgres:postgres, БД vector_mchs) |
- База данных: vector_mchs | PostgreSQL | 192.168.0.99:5432 |
- **PostgreSQL**: 192.168.0.99:5432 ## Управление (шпаргалка)
## Управление проектом
### Запуск проекта
```bash ```bash
cd /root/vector cd /root/vector
docker compose up -d docker compose up -d # запуск (после правки .env — пересоздание, restart env не перечитывает!)
``` docker compose ps # статус
### Остановка проекта
```bash
cd /root/vector
docker compose down
```
### Перезапуск с пересборкой
```bash
cd /root/vector
docker compose down
docker compose up -d --build
```
### Просмотр логов
```bash
# Все сервисы
docker compose logs -f
# Конкретный сервис
docker compose logs -f backend docker compose logs -f backend
docker compose logs -f frontend docker compose down # остановка
docker compose logs -f postgres docker compose up -d --build # пересборка
``` ```
### Статус сервисов Типовой цикл обновления: push в Gitea → `git pull` → `docker restart vector-backend vector-frontend`
```bash (после правки миграций — `alembic upgrade head` в контейнере, после правки env — `up -d backend`).
docker compose ps
```
## Настройка Подключение к LXC: `ssh root@192.168.0.65 "pct exec 108 -- bash"` (pve-node1).
Переменные окружения находятся в файле `/root/vector/.env`
Важные переменные:
- `JWT_SECRET` - секрет для JWT токенов
- `DATABASE_URL` - строка подключения к PostgreSQL
- `CORS_ORIGINS` - разрешенные origins для CORS
Примечание: внешний ИИ (Claude/Anthropic) из проекта УБРАН — анализ
полностью локальный и детерминированный (services/rules_analysis.py).
API-ключи LLM не нужны.
## Структура проекта
```
/root/vector/
├── backend/ # FastAPI backend
│ ├── api/v1/ # API endpoints
│ ├── main.py # Точка входа
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/ # React frontend
│ ├── src/
│ ├── public/
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml # Конфигурация Docker Compose
├── .env # Переменные окружения
└── README.md
```
## Подключение к LXC 108
Из Proxmox хоста:
```bash
pct exec 108 -- bash
```
Через SSH с вашей машины:
```bash
ssh root@192.168.0.65 "pct exec 108 -- bash"
```
## Следующие шаги
1. Настроить базу данных (миграции Alembic)
2. Создать модели SQLAlchemy
3. Реализовать аутентификацию с JWT
4. Разработать React компоненты для frontend
## Проверка работоспособности
Все сервисы запущены и доступны:
- ✅ PostgreSQL 16 (порт 5432)
- ✅ Adminer (порт 8080)
- ✅ Backend API (порт 8000)
- ✅ Frontend (порт 3000)
+38 -2
View File
@@ -4,7 +4,7 @@
ВЕКТОР рассчитывает приоритетные направления поиска пропавшего человека по матмодели: на основании данных о потерявшемся (возраст, диагнозы, физподготовка, транспорт), обстоятельствах потери (время, причина, направление), среде (рельеф, погода, сезон) и точке последней пропажи (ТНП) система строит концентрическую зону вероятного нахождения и подсвечивает приоритетные направления обследования с учётом рельефа из OSM-данных. ВЕКТОР рассчитывает приоритетные направления поиска пропавшего человека по матмодели: на основании данных о потерявшемся (возраст, диагнозы, физподготовка, транспорт), обстоятельствах потери (время, причина, направление), среде (рельеф, погода, сезон) и точке последней пропажи (ТНП) система строит концентрическую зону вероятного нахождения и подсвечивает приоритетные направления обследования с учётом рельефа из OSM-данных.
> **Версия документа:** соответствует состоянию репозитория на 9 сентября 2026 г. > **Версия документа:** соответствует состоянию репозитория на 25 сентября 2026 г.
> **Репозиторий:** [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector) > **Репозиторий:** [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector)
> **Развёртывание:** см. [SETUP_GUIDE.md](SETUP_GUIDE.md) — пошаговый гайд по развёртыванию на серверах МЧС. > **Развёртывание:** см. [SETUP_GUIDE.md](SETUP_GUIDE.md) — пошаговый гайд по развёртыванию на серверах МЧС.
@@ -351,6 +351,41 @@ SPA на react-scripts (CRA). Роутинг:
--- ---
## 13а. Прикладное логирование (B22)
Единая настройка — `backend/logging_setup.py`, инициализируется один раз при старте
(`backend.main::lifespan → setup_logging()`). Все модульные логгеры проекта живут
в пространстве имён `vector.*` и пишут в:
- **stdout** контейнера — уровень INFO (меняется env `VectorLOGLEVEL=debug`);
- **файл** `/tmp/vector/vector.log` внутри контейнера — ротация 5×2 МБ (отключается `VectorLOGFILE=0`, каталог `VectorLOGDIR`).
Формат строки: `время LEVEL vector.<модуль>: сообщение`. Пороги для файла — INFO и выше.
### Наблюдаемые события
| Логгер | Событие | Что означает |
|--------|---------|--------------|
| `vector.app` | `Vector API стартует` / `остановлен` | Жизненный цикл процесса |
| `vector.analyze` | `анализ case=… радиус=… срочность=… зон=… время=…мс` | Каждый матрасчёт с длительностью |
| `vector.auth` | `login_ok` / `login_failed` / `lockout` | Входы и блокировки (user, счётчик, IP) |
| `vector.audit` | `audit <тип> user=… obj=… ip=…` | Каждая запись в audit_events |
| `vector.contour` | `contour_send_ok` / `contour_send_failed` / `contour_rejected` | Отправка зон в КОНТУР |
| `vector.operations` | `operation_complete op=… исход=…` | Завершение поиска (найден живым/погибшим) |
| `vector.admin` | `admin closed case …` | Синхронизация закрытия кейса с операциями |
| `vector.geocode` | `geocode q=… результатов=…` / `geocode timeout` | Прокси Nominatim |
| `vector.geo` / `vector.geo.osm` | Overpass недоступен, OSM PostGIS недоступен | Состояние геослоя (fallback, breaker) |
| `vector.rules` | `Geo/scoring service error` | Отказ расчёта зон → fallback-зоны |
| `vector.water` | `geo layer cache write failed` | Ошибки кэша геослоя |
### Правила
- ПДн и секреты в логи не пишутся: никаких паролей, JWT, содержимого карточек (только id и агрегаты).
- Аудит-события дублируются в БД (`audit_events`) — лог это операционный след, БД — юридический.
- Уровень по умолчанию INFO; WARNING/ERROR — для неисправностей. Не логировать на INFO каждый HTTP-запрос (это делает uvicorn access-log).
---
## 15. Что готово и что в планах ## 15. Что готово и что в планах
### Реализовано ### Реализовано
@@ -363,9 +398,10 @@ SPA на react-scripts (CRA). Роутинг:
- Мультипоиск: операции, статусная машина, дашборд «Активные поиски» - Мультипоиск: операции, статусная машина, дашборд «Активные поиски»
- Завершение поиска с внесением исхода, архив завершённых с «прогноз vs факт» - Завершение поиска с внесением исхода, архив завершённых с «прогноз vs факт»
- Аудит 90 дней с field-level деталями - Аудит 90 дней с field-level деталями
- Прикладное логирование `vector.*`: события анализа, входов, КОНТУРа, аудита (README §13а)
- Интеграция с КОНТУР: отправка зон (контракт B20) - Интеграция с КОНТУР: отправка зон (контракт B20)
- Журнал аудита пользователей: входы, изменения, отправки - Журнал аудита пользователей: входы, изменения, отправки
- 231 автотест (pytest), 5 skipped - 239 автотестов (pytest), 5 skipped
### В планах ### В планах
+143 -4
View File
@@ -2,7 +2,7 @@
**Пошаговое руководство для IT-специалистов МЧС Республики Беларусь по развёртыванию системы определения приоритетных направлений поиска (ВЕКТОР) на серверах организации.** **Пошаговое руководство для IT-специалистов МЧС Республики Беларусь по развёртыванию системы определения приоритетных направлений поиска (ВЕКТОР) на серверах организации.**
> **Версия документа:** 1.0 от 9 сентября 2026 г. > **Версия документа:** 1.1 от 25 сентября 2026 г.
> **Репозиторий:** [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector) > **Репозиторий:** [`Sadmin/vector`](http://192.168.0.106:3000/Sadmin/vector)
--- ---
@@ -23,9 +23,10 @@
12. [Настройка интеграции с КОНТУР](#12-настройка-интеграции-с-контуром) 12. [Настройка интеграции с КОНТУР](#12-настройка-интеграции-с-контуром)
13. [Обратный прокси и HTTPS](#13-обратный-прокси-и-https) 13. [Обратный прокси и HTTPS](#13-обратный-прокси-и-https)
14. [Обновление проекта](#14-обновление-проекта) 14. [Обновление проекта](#14-обновление-проекта)
15. [Резервное копирование](#15-резервное-копирование) 15. [Резервное копирование (RPO/RTO)](#15-резервное-копирование)
16. [Мониторинг и логи](#16-мониторинг-и-логи) 16. [Мониторинг и логи](#16-мониторинг-и-логи)
17. [Диагностика проблем](#17-диагностика-проблем) 17. [Диагностика проблем](#17-диагностика-проблем)
17а. [Чек-лист передачи проекта](#17а-чек-лист-передачи-проекта)
18. [Чек-лист развёртывания](#18-чек-лист-развёртывания) 18. [Чек-лист развёртывания](#18-чек-лист-развёртывания)
--- ---
@@ -329,23 +330,73 @@ docker exec vector-postgres pg_dump -U postgres -d vector_mchs \
Рекомендация: ежедневный cron прикладного дампа (он небольшой) + еженедельный полный. Хранить копии вне сервера. Рекомендация: ежедневный cron прикладного дампа (он небольшой) + еженедельный полный. Хранить копии вне сервера.
**Docker volume — не backup.** Если сервер умер вместе с диском, volume умер вместе с ним. Что подлежит резервному копированию:
```text
БД (прикладные таблицы + OSM)
+ .env (JWT_SECRET, пароль БД, CONTOUR_TOKEN)
+ конфигурация compose
```
**Backup ≠ восстановление.** Копия проверяется периодическим restore на отдельном стенде:
файл читается → БД восстанавливается → backend подключается → расчёт выполняется. Непроверенный restore — это не стратегия, а файл с именем «backup».
### RPO и RTO
| Величина | Вопрос | Рекомендация для пилота |
|----------|--------|------------------------|
| **RPO** | Сколько данных допустимо потерять | Ежедневный дамп → RPO ≤ 24 ч. Потеря последних суток карточек; активная операция восстановляется из бумажного журнала смены |
| **RTO** | Сколько времени допустимо восстанавливать | Развернуть compose + restore дампа на новом LXC ≈ 30–60 мин (без импорта OSM — прикладной дамп + повторный импорт по необходимости; расчёт временно fallback'ится на Overpass) |
--- ---
## 16. Мониторинг и логи ## 16. Мониторинг и логи
```bash ```bash
docker compose logs -f # все сервисы docker compose logs -f # все сервисы
docker compose logs -f backend # API + аудит-сообщения docker compose logs -f backend # API + прикладные логи vector.*
docker compose logs -f frontend # сборка CRA / ошибки компиляции docker compose logs -f frontend # сборка CRA / ошибки компиляции
docker stats # потребление контейнеров docker stats # потребление контейнеров
tail -f /tmp/vector/vector.log # файл-лог внутри контейнера (ротация 5×2 МБ)
``` ```
Ключевые события в логах backend: `admin closed case ... операция(ий) завершены` (синхронизация закрытия), ошибки расчёта (полный traceback uvicorn). Прикладные события (B22, `backend/logging_setup.py`) — префикс `vector.<модуль>`:
```bash
# Все матрасчёты с длительностью
docker compose logs backend | grep 'vector.analyze'
# Входы и блокировки
docker compose logs backend | grep -E 'vector\.auth'
# Отправки в КОНТУР (ok/failed/rejected)
docker compose logs backend | grep 'vector.contour'
# Аудит-записи (операционный след; юридический — в БД audit_events)
docker compose logs backend | grep 'vector.audit'
```
Ключевые события: `анализ case=… радиус=… время=…мс` (каждый расчёт),
`login_ok/login_failed/lockout` (безопасность), `contour_send_ok/failed` (связь с КОНТУРом),
`operation_complete op=… исход=…` (завершение поиска), `admin closed case …` (синхронизация закрытия),
`Overpass … недоступен` / `OSM PostGIS недоступен` (геослой ушёл в fallback).
Уровень детализации: `VectorLOGLEVEL=debug` в `.env` + `docker compose up -d backend` (restart не перечитывает env). Файл-лог отключается `VectorLOGFILE=0`.
--- ---
## 17. Диагностика проблем ## 17. Диагностика проблем
### Быстрые проверки
```bash
docker compose ps # 4 контейнера Up/healthy
curl -s http://localhost:8000/api/v1/health # {"status":"ok"}
df -h && free -h # диск/память
```
### Типовые симптомы
| Симптом | Причина | Решение | | Симптом | Причина | Решение |
|---------|---------|---------| |---------|---------|---------|
| Логин даёт 422 | JSON вместо form-urlencoded | Логин строго `application/x-www-form-urlencoded` | | Логин даёт 422 | JSON вместо form-urlencoded | Логин строго `application/x-www-form-urlencoded` |
@@ -359,6 +410,94 @@ docker stats # потребление контейне
| КОНТУР недоступен при отправке зон | Нет связи/токена | curl до CONTOUR_API_URL; проверить CONTOUR_TOKEN; после правки env — up -d | | КОНТУР недоступен при отправке зон | Нет связи/токена | curl до CONTOUR_API_URL; проверить CONTOUR_TOKEN; после правки env — up -d |
| Тайлы карты не грузятся | Нет интернета у браузера | Для полного offline — локальный tile-сервер (в планах) | | Тайлы карты не грузятся | Нет интернета у браузера | Для полного offline — локальный tile-сервер (в планах) |
### Диагностика по цепочке (начинать с места проблемы, не с перезапусков)
**Бэкенд не подключается к БД** — цепочка `backend → docker network → postgres:5432 → authentication → vector_mchs`:
```bash
docker compose logs backend | grep -iE 'error|failed|refused'
docker exec vector-backend python -c "from services.osm_local import osm_available; print(osm_available())"
docker exec vector-postgres pg_isready -U postgres
```
Проверить: `DATABASE_URL` (хост `postgres`, пароль совпадает с POSTGRES_PASSWORD), общую compose-сеть, состояние БД.
**500 Internal Server Error** — «500» это результат, а не диагноз. Сначала traceback:
```bash
docker compose logs backend --tail 100
```
Затем определить слой: FastAPI / SQLAlchemy / БД / данные / конфигурация. Прикладной контекст ищется по `vector.*` в логах.
**Расчёт отдал fallback-зоны («Ближняя зона N / Средняя E»)** — геослой не ответил:
```bash
docker compose logs backend | grep -E 'vector\.(rules|geo)'
```
`Geo/scoring service error` → смотреть причину выше по стеку (PostGIS? Overpass breaker?). При работе на локальном OSM — проверить таблицы:
```bash
docker exec vector-postgres psql -U postgres -d vector_mchs -t -c \
"SELECT count(*) FROM planet_osm_line;"
```
**Пользователь не входит** — цепочка `login → bcrypt → lockout → session → JWT`:
```bash
docker compose logs backend | grep 'vector.auth'
docker exec vector-postgres psql -U postgres -d vector_mchs -c \
"SELECT username, is_active, status, failed_login_count, locked_until FROM users WHERE username='<логин>';"
```
401 после смены JWT_SECRET — все сессии инвалидны, ожидаемо: перелогин. 403 при валидном входе — проверить роль/юнит пользователя (скоуп).
---
## 17а. Чек-лист передачи проекта (по образцу РВС)
### Код
- [ ] код в Git (Gitea, `Sadmin/vector`), история коммитов осмысленная
- [ ] production-конфигурация без паролей в репо (`.env.example` вместо `.env`)
- [ ] README актуален (архитектура, API, БД, логирование §13а)
- [ ] SETUP_GUIDE актуален (развертывание, диагностика)
- [ ] тесты зелёные: `pytest` (239 passed)
### База
- [ ] известен production-сервер PostgreSQL и БД `vector_mchs`
- [ ] схема актуальна: `alembic current` = `009_e3_operations`
- [ ] понятны таблицы OSM (planet_osm_*) и способ их восстановления (дамп + b16-import.sh)
- [ ] сиды (users, units) воспроизводимы
### Docker
- [ ] понятны compose-файлы, volumes, сети
- [ ] понятно, что можно пересоздавать (backend/frontend/adminer), а что содержит данные (postgres volume)
- [ ] известен порядок обновления (§14) и отката
### Security
- [ ] `JWT_SECRET` не дефолтный; пароль БД сменён
- [ ] пароль admin сменён; `must_change_password` для созданных пользователей работает
- [ ] `.env` защищён (chmod 600, не в репо)
- [ ] внешних ИИ/API-ключей нет (политика закрытого контура, тест `TestNoLLM`)
- [ ] Adminer и порты БД не торчат наружу без необходимости
### Логи и аудит
- [ ] прикладные логи `vector.*` доступны (`docker compose logs backend`, файл в контейнере)
- [ ] аудит 90 дней читается (вкладка «Аудит», право `view_audit`)
- [ ] ретенция настроена в `security_settings`
### Backup
- [ ] cron прикладного дампа настроен, копии вне сервера
- [ ] restore проверен на стенде
- [ ] RPO/RTO осознаны и приняты (§15)
--- ---
## 18. Чек-лист развёртывания ## 18. Чек-лист развёртывания
+6
View File
@@ -12,8 +12,11 @@ from typing import Optional
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.logging_setup import get_logger
from backend.models import AuditChange, AuditEvent, SecuritySetting, User from backend.models import AuditChange, AuditEvent, SecuritySetting, User
log = get_logger('audit')
def _client_meta(request) -> tuple[str | None, str | None]: def _client_meta(request) -> tuple[str | None, str | None]:
if request is None: if request is None:
@@ -54,6 +57,9 @@ def audit_log(
new_value=None if new is None else str(new), new_value=None if new is None else str(new),
)) ))
db.commit() db.commit()
log.info('audit %s user=%s obj=%s/%s ip=%s', event_type,
event.username or '—', object_type or '—', event.object_id or '—',
event.ip_address or '—')
def audit_retention_days(db: Session) -> int: def audit_retention_days(db: Session) -> int:
+75
View File
@@ -0,0 +1,75 @@
"""Единая настройка логирования ВЕКТОРа (B22).
Все модульные логгеры проекта — `logging.getLogger('vector.<модуль>')` —
конфигурируются один раз при старте приложения (backend.main → setup_logging()).
Формат: время, уровень, логгер, сообщение. Вывод — stdout (контейнер),
дополнительно файл /tmp/vector/vector.log внутри контейнера (ротация 5×2 МБ).
Наблюдаемые события (ключевые слова для grep по логам):
vector.analyze — анализ: case_id, радиус, зоны, время расчёта
vector.auth — login_ok / login_failed / lockout / logout
vector.contour — отправка зон в КОНТУР (ok/failed)
vector.admin — синхронизация закрытия кейса
vector.audit — запись аудит-события (кто, что)
vector.geo — геослой: PostGIS-режим, fallback на Overpass, ошибки
"""
from __future__ import annotations
import logging
import logging.handlers
import os
from pathlib import Path
_LOG_FORMAT = '%(asctime)s %(levelname)s %(name)s: %(message)s'
_LOG_DATEFMT = '%Y-%m-%d %H:%M:%S'
# Порог вывода в stdout: INFO на сервере, DEBUG при VectorLOGLEVEL=debug
_LEVEL = os.getenv('VectorLOGLEVEL', 'info').upper()
# Файл-хэндлер: по умолчанию включён (внутри контейнера /tmp/vector).
_FILE_ENABLED = os.getenv('VectorLOGFILE', '1').strip().lower() not in {'0', 'false', 'no'}
_FILE_DIR = Path(os.getenv('VectorLOGDIR', '/tmp/vector'))
def setup_logging() -> None:
"""Конфигурация корневого пакета 'vector'. Идемпотентна."""
root = logging.getLogger('vector')
if getattr(root, '_vector_configured', False):
return
root.setLevel(logging.DEBUG)
fmt = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATEFMT)
stream = logging.StreamHandler()
stream.setLevel(getattr(logging, _LEVEL if _LEVEL in {'DEBUG', 'INFO', 'WARNING', 'ERROR'} else 'INFO'))
stream.setFormatter(fmt)
root.addHandler(stream)
if _FILE_ENABLED:
try:
_FILE_DIR.mkdir(parents=True, exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler(
_FILE_DIR / 'vector.log',
maxBytes=2 * 1024 * 1024,
backupCount=5,
encoding='utf-8',
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(fmt)
root.addHandler(file_handler)
except OSError:
root.warning('Файл-лог недоступен (%s) — только stdout', _FILE_DIR)
# uvicorn-логи ошибок приложения (traceback-и) идут тем же форматом
uvicorn_error = logging.getLogger('uvicorn.error')
uvicorn_error.handlers.clear()
uvicorn_error.propagate = True
root._vector_configured = True # type: ignore[attr-defined]
def get_logger(name: str) -> logging.Logger:
"""Модульный логгер: get_logger('analyze') → 'vector.analyze'."""
short = name.removeprefix('vector.')
return logging.getLogger(f'vector.{short}')
+6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -7,6 +8,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend.database import init_db from backend.database import init_db
from backend.logging_setup import setup_logging
from backend.routers.admin import router as admin_router from backend.routers.admin import router as admin_router
from backend.routers.admin_users import router as admin_users_router from backend.routers.admin_users import router as admin_users_router
from backend.routers.operations import router as operations_router from backend.routers.operations import router as operations_router
@@ -34,8 +36,12 @@ if '*' in cors_origins:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
setup_logging()
log = logging.getLogger('vector.app')
log.info('Vector API стартует: CORS=%s', ','.join(cors_origins))
init_db() init_db()
yield yield
log.info('Vector API остановлен')
app = FastAPI(title='Vector API', version='0.1.0', lifespan=lifespan) app = FastAPI(title='Vector API', version='0.1.0', lifespan=lifespan)
+2 -2
View File
@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from backend.database import db from backend.database import db
from backend.logging_setup import get_logger
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse
log = logging.getLogger('vector.admin') log = get_logger('admin')
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))]) router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))])
+14
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -8,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from backend.database import db from backend.database import db
from backend.logging_setup import get_logger
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from services.rules_analysis import rules_analyze from services.rules_analysis import rules_analyze
from services.search_engine import ( from services.search_engine import (
@@ -15,6 +18,8 @@ from services.search_engine import (
build_search_model, build_search_model,
) )
log = get_logger('analyze')
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze']) router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
@@ -44,6 +49,8 @@ class AnalysisRequest(BaseModel):
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]: async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
# B14: роутер — тонкая обёртка. Вся расчётная логика — в # B14: роутер — тонкая обёртка. Вся расчётная логика — в
# services/search_engine.py::build_search_model (без DB/auth/HTTP). # services/search_engine.py::build_search_model (без DB/auth/HTTP).
import time as _time
_t0 = _time.monotonic()
case_data: dict[str, Any] = { case_data: dict[str, Any] = {
'age': payload.age, 'age': payload.age,
'gender': payload.gender, 'gender': payload.gender,
@@ -109,6 +116,13 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
result['case_id'] = str(payload.case_id) if payload.case_id else None result['case_id'] = str(payload.case_id) if payload.case_id else None
result['analyzed_at'] = datetime.now(timezone.utc).isoformat() result['analyzed_at'] = datetime.now(timezone.utc).isoformat()
log.info(
'анализ case=%s радиус=%.1fкм срочность=%s зон=%d время=%.0fмс',
payload.case_id or '—', result.get('search_radius_km') or 0,
result.get('urgency'), len(result.get('primary_zones') or []),
(_time.monotonic() - _t0) * 1000,
)
if payload.case_id is not None: if payload.case_id is not None:
db.update_case(str(payload.case_id), analysis_log=result, status='analyzed') db.update_case(str(payload.case_id), analysis_log=result, status='analyzed')
+12
View File
@@ -15,8 +15,11 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.logging_setup import get_logger
from backend.models import AuthEvent, SecuritySetting, User, UserSession from backend.models import AuthEvent, SecuritySetting, User, UserSession
log = get_logger('auth')
router = APIRouter(prefix='/api/v1/auth', tags=['auth']) router = APIRouter(prefix='/api/v1/auth', tags=['auth'])
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
SECRET_KEY = os.getenv('JWT_SECRET', 'change-me-in-production') SECRET_KEY = os.getenv('JWT_SECRET', 'change-me-in-production')
@@ -127,9 +130,15 @@ def _register_failed_login(db: Session, user: User, request: Request | None) ->
user.failed_login_count = (user.failed_login_count or 0) + 1 user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= max_failed: if user.failed_login_count >= max_failed:
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min) user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
log.warning('lockout user=%s неудачных=%d блокировка=%dмин ip=%s',
user.username, user.failed_login_count, lockout_min,
request.client.host if request and request.client else '—')
_log_auth(db, 'lockout', user.username, user.id, request, _log_auth(db, 'lockout', user.username, user.id, request,
{'failed_count': user.failed_login_count}) {'failed_count': user.failed_login_count})
else: else:
log.warning('login_failed user=%s неудачных=%d ip=%s',
user.username, user.failed_login_count,
request.client.host if request and request.client else '—')
_log_auth(db, 'login_failed', user.username, user.id, request, _log_auth(db, 'login_failed', user.username, user.id, request,
{'failed_count': user.failed_login_count}) {'failed_count': user.failed_login_count})
db.commit() db.commit()
@@ -371,6 +380,9 @@ def login(
user.last_login = datetime.now(timezone.utc) user.last_login = datetime.now(timezone.utc)
_log_auth(db, 'login', user.username, user.id, request) _log_auth(db, 'login', user.username, user.id, request)
db.commit() db.commit()
log.info('login_ok user=%s ip=%s',
getattr(user, 'username', '—'),
request.client.host if request.client else '—')
token = create_access_token( token = create_access_token(
{'sub': user.username, 'role': user.role, 'sid': sid_secret}, {'sub': user.username, 'role': user.role, 'sid': sid_secret},
+8
View File
@@ -21,9 +21,12 @@ from pydantic import BaseModel
from backend.audit import audit_log, can_access_unit from backend.audit import audit_log, can_access_unit
from backend.database import get_db from backend.database import get_db
from backend.logging_setup import get_logger
from backend.models import SearchOperation, User from backend.models import SearchOperation, User
from backend.routers.auth import get_current_user, require_permission from backend.routers.auth import get_current_user, require_permission
log = get_logger('contour')
router = APIRouter(prefix='/api/v1/operations', tags=['operations-contour']) router = APIRouter(prefix='/api/v1/operations', tags=['operations-contour'])
def _contour_url() -> str: def _contour_url() -> str:
@@ -147,11 +150,14 @@ def send_to_contour(
headers={'Authorization': f'Bearer {_contour_token()}'}, headers={'Authorization': f'Bearer {_contour_token()}'},
timeout=30.0) timeout=30.0)
except httpx.HTTPError as e: except httpx.HTTPError as e:
log.warning('contour_send_failed op=%s url=%s error=%s', op.id, _contour_url(), e)
audit_log(db, current_user, 'contour_send_failed', object_type='operation', audit_log(db, current_user, 'contour_send_failed', object_type='operation',
object_id=str(op.id), request=request, details={'error': str(e)}) object_id=str(op.id), request=request, details={'error': str(e)})
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
if resp.status_code >= 400: if resp.status_code >= 400:
log.warning('contour_rejected op=%s status=%d body=%s',
op.id, resp.status_code, resp.text[:200])
audit_log(db, current_user, 'contour_send_failed', object_type='operation', audit_log(db, current_user, 'contour_send_failed', object_type='operation',
object_id=str(op.id), request=request, object_id=str(op.id), request=request,
details={'status': resp.status_code, 'body': resp.text[:500]}) details={'status': resp.status_code, 'body': resp.text[:500]})
@@ -159,6 +165,8 @@ def send_to_contour(
detail=f'КОНТУР отклонил импорт: {resp.status_code} {resp.text[:300]}') detail=f'КОНТУР отклонил импорт: {resp.status_code} {resp.text[:300]}')
result = resp.json() result = resp.json()
log.info('contour_send_ok op=%s зон=%d contour_op=%s',
op.id, len(payload['zones']), op.contour_operation_id)
audit_log(db, current_user, 'contour_send_ok', object_type='operation', audit_log(db, current_user, 'contour_send_ok', object_type='operation',
object_id=str(op.id), request=request, object_id=str(op.id), request=request,
details={'zones': result.get('zones_total'), 'status': result.get('status')}) details={'zones': result.get('zones_total'), 'status': result.get('status')})
+8
View File
@@ -11,15 +11,19 @@ Nominatim Usage Policy: User-Agent обязателен, ≤1 rps — прокс
- Пункт ПЕРВЫМ словом. - Пункт ПЕРВЫМ словом.
""" """
import os import os
import logging
from typing import List, Optional from typing import List, Optional
import httpx import httpx
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from backend.logging_setup import get_logger
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from fastapi import Depends from fastapi import Depends
log = get_logger('geocode')
router = APIRouter(prefix='/api/v1/geocode', tags=['geocode']) router = APIRouter(prefix='/api/v1/geocode', tags=['geocode'])
NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search' NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
@@ -59,10 +63,14 @@ async def geocode(
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
except httpx.TimeoutException: except httpx.TimeoutException:
log.warning('geocode timeout q=%s', q.strip()[:100])
raise HTTPException(status_code=504, detail='Сервис геокодирования не ответил') raise HTTPException(status_code=504, detail='Сервис геокодирования не ответил')
except Exception as e: except Exception as e:
log.warning('geocode error q=%s: %s', q.strip()[:100], e)
raise HTTPException(status_code=502, detail=f'Ошибка геокодирования: {e}') raise HTTPException(status_code=502, detail=f'Ошибка геокодирования: {e}')
log.info('geocode q=%s результатов=%d', q.strip()[:100], len(data))
return {'results': [ return {'results': [
GeocodeResult( GeocodeResult(
lat=float(item['lat']), lat=float(item['lat']),
+6
View File
@@ -13,10 +13,13 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.audit import audit_log, visible_unit_ids from backend.audit import audit_log, visible_unit_ids
from backend.logging_setup import get_logger
from backend.database import get_db from backend.database import get_db
from backend.models import Case, SearchOperation, User from backend.models import Case, SearchOperation, User
from backend.routers.auth import get_current_user, require_permission from backend.routers.auth import get_current_user, require_permission
oplog = get_logger('operations')
router = APIRouter(prefix='/api/v1/operations', tags=['operations']) router = APIRouter(prefix='/api/v1/operations', tags=['operations'])
ALLOWED_TRANSITIONS = { ALLOWED_TRANSITIONS = {
@@ -203,6 +206,9 @@ def complete_operation(
db.commit() db.commit()
oplog.info('operation_complete op=%s case=%s исход=%s юзер=%s',
op.id, case.id, 'найден живым' if payload.found_alive else 'найден погибшим',
getattr(current_user, 'username', '—'))
audit_log(db, current_user, 'operation_complete', audit_log(db, current_user, 'operation_complete',
object_type='search_operation', object_id=str(op.id), object_type='search_operation', object_id=str(op.id),
details={'case_id': str(case.id), 'found_alive': payload.found_alive}) details={'case_id': str(case.id), 'found_alive': payload.found_alive})
+1 -1
View File
@@ -29,7 +29,7 @@ from backend.database import db
from backend.routers.auth import require_roles from backend.routers.auth import require_roles
from services.geo_service import _overpass_circuit_open, query_overpass from services.geo_service import _overpass_circuit_open, query_overpass
logger = logging.getLogger(__name__) logger = logging.getLogger('vector.water')
router = APIRouter(prefix='/api/v1/water', tags=['water'], router = APIRouter(prefix='/api/v1/water', tags=['water'],
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))]) dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
+1 -1
View File
@@ -42,7 +42,7 @@ OVERPASS_URLS = [
] ]
OVERPASS_URL = OVERPASS_URLS[0] # обратная совместимость для импортов OVERPASS_URL = OVERPASS_URLS[0] # обратная совместимость для импортов
logger = logging.getLogger(__name__) logger = logging.getLogger('vector.geo')
# --- Overpass circuit breaker --------------------------------------------- # --- Overpass circuit breaker ---------------------------------------------
# build_search_zones issues ~128 Overpass calls per analysis. When the host has # build_search_zones issues ~128 Overpass calls per analysis. When the host has
+1 -1
View File
@@ -16,7 +16,7 @@ from typing import Any, Optional
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
logger = logging.getLogger(__name__) logger = logging.getLogger('vector.geo.osm')
_PG_URL = os.getenv('DATABASE_URL', 'postgresql://postgres:postgres@postgres:5432/vector_mchs') _PG_URL = os.getenv('DATABASE_URL', 'postgresql://postgres:postgres@postgres:5432/vector_mchs')
if _PG_URL.startswith('postgresql+asyncpg://'): if _PG_URL.startswith('postgresql+asyncpg://'):
+1 -1
View File
@@ -15,7 +15,7 @@ from pydantic import BaseModel, Field
from services.geo_service import build_search_zones from services.geo_service import build_search_zones
from services.scoring_service import WeightedScorer from services.scoring_service import WeightedScorer
logger = logging.getLogger(__name__) logger = logging.getLogger('vector.rules')
class PrimaryZone(BaseModel): class PrimaryZone(BaseModel):