Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# ---- Postgres ----
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5434
|
||||
POSTGRES_DB=reception
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
|
||||
|
||||
# ---- Redis (BullMQ) ----
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
REDIS_URL=redis://localhost:6380
|
||||
|
||||
# ---- MinIO ----
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
MINIO_BUCKET=reception-evidence
|
||||
|
||||
# ---- Services ports ----
|
||||
API_PORT=4000
|
||||
POLIMED_MOCK_PORT=4100
|
||||
FACE_SERVICE_PORT=8001
|
||||
WEB_ADMIN_PORT=3000
|
||||
|
||||
# ---- Service URLs (apps see each other) ----
|
||||
FACE_SERVICE_URL=http://localhost:8001
|
||||
POLIMED_BASE_URL=http://localhost:4100
|
||||
WEB_ADMIN_ORIGIN=http://localhost:3000
|
||||
API_BASE_URL=http://localhost:4000
|
||||
|
||||
# ---- Auth ----
|
||||
JWT_ACCESS_SECRET=dev-access-secret-change-me
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-me
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=30d
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# ---- Dev users (seeded by db:seed) ----
|
||||
SEED_PASSWORD_MANAGER=manager123
|
||||
SEED_PASSWORD_SENIOR=senior123
|
||||
SEED_PASSWORD_SECURITY=security123
|
||||
SEED_PASSWORD_SYSADMIN=admin123
|
||||
|
||||
# ---- Face-service thresholds (ТЗ §4.3) ----
|
||||
REID_THRESHOLD=0.35
|
||||
RECOGNITION_THRESHOLD=0.5
|
||||
|
||||
# ---- Consent revocation delay (prod=24h, e2e=5s) ----
|
||||
CONSENT_REVOKE_DELAY_MS=86400000
|
||||
|
||||
# ---- Evidence TTL ----
|
||||
EVIDENCE_PRESIGN_TTL_SECONDS=900
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.turbo
|
||||
.cache
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
.venv-*/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# prisma local
|
||||
prisma/.env
|
||||
@@ -0,0 +1,78 @@
|
||||
# Digital Reception — Фаза 1
|
||||
|
||||
Видеоаналитика рецепции клиники: распознавание лиц, ручной enrollment, согласия, история визитов. Компонент Платформы цифровых сервисов клиники (ПЦС).
|
||||
|
||||
ТЗ: [`TZ (1).md`](./TZ%20(1).md).
|
||||
План разработки: `~/.claude/plans/plan-mode-reflective-wand.md`.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Монорепо:** pnpm workspaces + Turborepo.
|
||||
- **БД:** PostgreSQL 16 + pgvector, Prisma 5.
|
||||
- **Backend:** NestJS 10 (`apps/api`, `apps/polimed-mock`), Python + FastAPI (`apps/face-service`, `apps/video-ingest`).
|
||||
- **Frontend:** Next.js 15 (App Router) + shadcn/ui + Recharts (`apps/web-admin`).
|
||||
- **Очереди:** BullMQ (Redis).
|
||||
- **Хранилище кадров:** MinIO (S3-совместимое).
|
||||
- **Распознавание лиц:** InsightFace `buffalo_l`, 512-d cosine.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
apps/
|
||||
api/ # NestJS — auth, RBAC, треки, согласия, визиты, аудит
|
||||
face-service/ # Python FastAPI — InsightFace + pgvector + re-id
|
||||
polimed-mock/ # NestJS — мок МИС Полимед (вне скоупа Ф1)
|
||||
video-ingest/ # Python — минимальный mp4-консьюмер (скаффолд)
|
||||
fixtures-runner/ # Node — e2e сценарии треков и событий
|
||||
web-admin/ # Next.js 15 + shadcn/ui — админка
|
||||
packages/
|
||||
db/ # Prisma schema, миграции, сид
|
||||
ui/ # shadcn/ui компоненты (shared)
|
||||
tsconfig/ # base / nest / next tsconfig пресеты
|
||||
eslint-config/ # общий ESLint
|
||||
docker/
|
||||
docker-compose.yml # postgres + redis + minio
|
||||
init.sql # CREATE EXTENSION vector
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# 1. Инфраструктура
|
||||
cp .env.example .env
|
||||
pnpm docker:up # postgres+redis+minio
|
||||
|
||||
# 2. Зависимости
|
||||
pnpm install
|
||||
|
||||
# 3. БД
|
||||
pnpm db:migrate # создаёт схему
|
||||
pnpm db:seed # 4 юзера, 3 камеры, 3 зоны
|
||||
|
||||
# 4. Сервисы (каждый в своём терминале)
|
||||
pnpm --filter=@reception/api dev
|
||||
pnpm --filter=@reception/polimed-mock dev
|
||||
pnpm --filter=@reception/web-admin dev
|
||||
# face-service:
|
||||
cd apps/face-service && uvicorn main:app --reload --port 8001
|
||||
```
|
||||
|
||||
## Контроль доступа
|
||||
|
||||
Сидер создаёт 4 dev-пользователя (пароли в `.env.example`):
|
||||
|
||||
| Email | Роль | Что видит |
|
||||
|-------------------|---------------|---------------------------------|
|
||||
| `manager@local` | MANAGER | Дашборд, история визитов |
|
||||
| `senior@local` | SENIOR_ADMIN | Enrollment, согласия |
|
||||
| `security@local` | SECURITY | Лента инцидентов (Ф2) |
|
||||
| `admin@local` | SYSADMIN | Аудит, пользователи, камеры |
|
||||
|
||||
## Скоуп Фазы 1
|
||||
|
||||
- ✅ `face-service` + pgvector + cross-camera re-id.
|
||||
- ✅ Web-admin: ручной enrollment, согласия, история визитов, аудит.
|
||||
- ✅ Фиксация бумажных согласий, отзыв → удаление эмбеддингов за 24 ч.
|
||||
- 🚫 **Полимед API** — замокан `apps/polimed-mock`, реальной интеграции нет.
|
||||
- 🚫 **RTSP / GPU / ByteTrack** — работа Фазы 0, в Ф1 только минимальный mp4-скаффолд.
|
||||
- 🚫 **Поведенческие алерты (Max-бот)** — Фаза 2.
|
||||
@@ -0,0 +1,227 @@
|
||||
# ТЗ. Цифровая рецепция (видеоаналитика фронт-офиса)
|
||||
|
||||
**Статус:** v0.1 (драфт для обсуждения)
|
||||
**Контекст:** компонент Платформы цифровых сервисов клиники (ПЦС), блок «Цифровые сервисы для администрации и управления».
|
||||
**Руководитель направления ПЦС:** Пётр Потураев.
|
||||
|
||||
---
|
||||
|
||||
## 1. Цели и KPI
|
||||
|
||||
**Бизнес-цели:**
|
||||
- Контроль качества обслуживания на рецепции (объективные, а не «по ощущениям» цифры).
|
||||
- Снижение времени ожидания пациента и времени простоя администратора.
|
||||
- Раннее обнаружение инцидентов (неадекватное поведение).
|
||||
- Персональная история визитов пациента на рецепции для разбора жалоб и кейс-стади.
|
||||
|
||||
**Целевые метрики (фиксируются как KPI):**
|
||||
- Среднее время ожидания в очереди (мин).
|
||||
- Среднее время обслуживания у стойки (мин).
|
||||
- Длина очереди (среднее, пиковое, по часам).
|
||||
- Время простоя администратора (мин/смена, %).
|
||||
- Количество пациентов, ушедших без обслуживания (шт/смена).
|
||||
- Количество и тип инцидентов (агрессия, скопление и т.п.).
|
||||
|
||||
**Нормативов сейчас нет.** В админке пороги делаем редактируемыми (default — пустые). Базовые значения подбираем после 1–2 недель baseline-замеров на проде в анонимном режиме (Фаза 0).
|
||||
|
||||
---
|
||||
|
||||
## 2. Сценарии наблюдения
|
||||
|
||||
**Зоны (логические, привязываются к камерам в админке):**
|
||||
- Зона A — вход в клинику.
|
||||
- Зона B — коридор / зона ожидания.
|
||||
- Зона C — стойка рецепции (рабочее место администратора).
|
||||
|
||||
**События жизненного цикла визита:**
|
||||
| Событие | Триггер |
|
||||
|---|---|
|
||||
| `arrived` | трек впервые появился в зоне A |
|
||||
| `waiting` | трек удерживается в зоне B без позиции у стойки |
|
||||
| `service_started` | трек оказался у стойки C на расстоянии < X от администратора, > N сек |
|
||||
| `service_ended` | трек покинул зону C |
|
||||
| `left_without_service` | трек ушёл из зоны A/B без `service_started`, >= Tmin времени с `arrived` |
|
||||
|
||||
Все пороги (X, N, Tmin) — настраиваемые.
|
||||
|
||||
---
|
||||
|
||||
## 3. Источники видео
|
||||
|
||||
- IP-камеры (RTSP), уже стоят в клинике. Сырое видео **не храним**.
|
||||
- В рамках Фазы 0: **инвентаризация камер** (отдельная активность с заказчиком на месте):
|
||||
- ID/имя камеры, RTSP URL, разрешение, FPS, угол обзора, освещение, привязка к зоне (A/B/C).
|
||||
- Минимум для запуска: 1 камера на вход, 1 на коридор, 1 над стойкой. Желательно с перекрытием — нужно для cross-camera re-id.
|
||||
- Подключение по локальной сети клиники. Внешний доступ — только через VPN.
|
||||
|
||||
---
|
||||
|
||||
## 4. Идентификация пациентов
|
||||
|
||||
### 4.1 Подход
|
||||
В МИС Полимед фотографий пациентов нет, поэтому базу эмбеддингов строим сами. Используется только при наличии бумажного согласия пациента (см. 4.4).
|
||||
|
||||
### 4.2 Ручной enrollment (через web-admin)
|
||||
Сценарий старшего администратора:
|
||||
1. Открывает страницу «Новые треки за смену».
|
||||
2. Видит карточки треков. Один трек = серия кадров одного и того же человека, объединённая cross-camera re-id по лицевому эмбеддингу (вход → коридор → стойка).
|
||||
3. Рядом — журнал записей на приём из Полимед за это же временное окно (REST: поиск пациентов / журнал записей).
|
||||
4. Сопоставляет трек ↔ запись на приём → ставит галочку «бумажное согласие получено» → подтверждает.
|
||||
5. Эмбеддинг сохраняется в нашей БД с привязкой к `patient_id` из Полимед.
|
||||
|
||||
Без галочки согласия эмбеддинг в долгую базу не уходит; трек живёт обезличенно до конца смены и удаляется.
|
||||
|
||||
### 4.3 Автоматическое распознавание (повторные визиты)
|
||||
- При появлении лица — поиск ближайшего эмбеддинга в pgvector (cos distance, top-5).
|
||||
- Threshold 0.35 (как в time-tracker, тюнингуем после первой партии данных).
|
||||
- При match → подтягиваем из Полимед текущий приём → метрики становятся персональными.
|
||||
|
||||
### 4.4 Согласие
|
||||
- Только бумажный носитель (договор/анкета).
|
||||
- В админке оператор ставит галочку «согласие получено».
|
||||
- Отзыв согласия → удаление эмбеддинга и персональной истории визитов в течение 24 ч. Обезличенные агрегаты остаются.
|
||||
|
||||
---
|
||||
|
||||
## 5. Приватность и 152-ФЗ
|
||||
|
||||
- Биометрические ПДн (лицевые эмбеддинги) обрабатываются только при наличии письменного согласия.
|
||||
- Сырое видео **не записывается** на диск.
|
||||
- Хранятся:
|
||||
- Лицевые эмбеддинги (512-d) — pgvector.
|
||||
- Метаданные треков (timestamps, camera_id, события).
|
||||
- Кадры-доказательства для инцидентов — MinIO, TTL 30 дней (настраиваемо).
|
||||
- ЕБС не используется.
|
||||
- Журнал доступа к биометрии (кто, когда, к чьим данным) — для аудита.
|
||||
- Контур развёртывания — внутри ЛВС клиники. Внешний доступ только через VPN.
|
||||
|
||||
---
|
||||
|
||||
## 6. Архитектура
|
||||
|
||||
### 6.1 Сервисы
|
||||
|
||||
| Сервис | Стек | Назначение |
|
||||
|---|---|---|
|
||||
| `video-ingest` | Python, OpenCV/ffmpeg, YOLOv8 (или RT-DETR), ByteTrack/BoT-SORT | На каждую RTSP-камеру отдельный воркер. Декод (по возможности NVDEC), детекция людей, трекинг внутри камеры, выдача кропов лиц и событий треков. |
|
||||
| `face-service` | Python/FastAPI, InsightFace `buffalo_l`, pgvector | Эмбеддинги лиц, поиск ближайших, эндпоинт cross-camera re-id (склейка треков с разных камер по эмбеддингу в окне T минут). Переиспользуется из `work-pcs-adm-time-tracker`. |
|
||||
| `behavior-service` | Python, action recognition (SlowFast / X3D / MoViNet) | Распознавание агрессии. **Фаза 2.** |
|
||||
| `analytics-worker` | Node.js, BullMQ | Считает метрики (очередь, время ожидания/обслуживания), формирует инциденты. |
|
||||
| `api` | Nest.js, Prisma | Коннектор к Полимед, enrollment-API, дашборд-API, RBAC. |
|
||||
| `web-admin` | Next.js 15 (App Router), shadcn/ui, Recharts | Дашборд управляющего, ручной enrollment, фиксация согласий, пороги, расписание алертов, лента инцидентов. |
|
||||
| `max-bot` | Node.js | Рассылка алертов в мессенджер Max. Фаза 2 (после `behavior-service`). |
|
||||
|
||||
### 6.2 Инфраструктура
|
||||
- PostgreSQL 16 + pgvector.
|
||||
- Redis (BullMQ).
|
||||
- MinIO (кадры-доказательства).
|
||||
- GPU-сервер (NVIDIA, CUDA, nvidia-container-toolkit) — для `video-ingest`, `face-service`, `behavior-service`.
|
||||
- Docker Compose (по образцу time-tracker, отдельный `docker-compose.prod.yml`).
|
||||
- Монорепо: pnpm + Turborepo.
|
||||
|
||||
### 6.3 Видеопайплайн (детально)
|
||||
1. `video-ingest` открывает RTSP по камере (1 воркер = 1 камера).
|
||||
2. Декодирование на GPU (NVDEC) при поддержке камеры; fallback на CPU.
|
||||
3. Детектор людей — каждый N-й кадр (целевая частота ~5 fps хватит для рецепции).
|
||||
4. Трекер (ByteTrack или BoT-SORT) — треки внутри одной камеры.
|
||||
5. На каждый трек периодически вырезается лучший по качеству кроп лица (фронтальность, размер, sharpness) → в `face-service`.
|
||||
6. `face-service` считает эмбеддинг. Если в окне последних T минут есть открытый трек с близким эмбеддингом — треки склеиваются (cross-camera re-id). Окно T — настраиваемо.
|
||||
7. События трека (`arrived` / `waiting` / `service_started` / `service_ended` / `left_without_service`) уходят в `api` → `analytics-worker`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Интеграции
|
||||
|
||||
### 7.1 МИС Полимед (REST)
|
||||
- **Read:** поиск пациента по ФИО, журнал записей на дату, статус визита.
|
||||
- **Write:** события визита на рецепции (`arrived`, `service_started`, `service_ended`) — назад в Полимед (объём write-операций уточняется со стороны Полимед).
|
||||
|
||||
### 7.2 Мессенджер Max (бот)
|
||||
- Минимально (Фаза 2): инциденты «неадекватное поведение» (агрессия) → сообщение с кадром-доказательством.
|
||||
- Получатели и расписание — настраиваются в админке.
|
||||
- Расширения (по запросу): сводки за смену, триггерные алерты по очереди и т.п.
|
||||
|
||||
### 7.3 ПЦС-портал
|
||||
- SSO и общие роли (Управляющий, Старший администратор, Безопасность, Админ системы) — на уровне ПЦС. На время Фазы 0 — локальная авторизация (как в time-tracker, JWT + refresh).
|
||||
|
||||
---
|
||||
|
||||
## 8. Роли и UI
|
||||
|
||||
| Роль | Что видит / делает |
|
||||
|---|---|
|
||||
| Управляющий клиники | Live-дашборд (текущая очередь, метрики смены), отчёты день/неделя/месяц, лента инцидентов. |
|
||||
| Старший администратор | Очередь задач на enrollment, ручная привязка треков к пациентам Полимед, фиксация согласий, отзыв согласия. |
|
||||
| Безопасность | Лента инцидентов «неадекватное поведение» + кадры. |
|
||||
| Админ системы | Камеры (RTSP, привязка к зонам), пороги метрик, расписание алертов, пользователи и роли. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Фазы
|
||||
|
||||
### Фаза 0 — MVP, анонимная аналитика (2–4 нед)
|
||||
- Инвентаризация камер.
|
||||
- `video-ingest` + детекция + трекинг (без идентификации).
|
||||
- Метрики: длина очереди, время ожидания, время обслуживания, время простоя администратора, ушедшие без обслуживания.
|
||||
- Дашборд управляющего (live + отчёт за день/смену).
|
||||
- Baseline-замеры для подбора порогов.
|
||||
|
||||
**Критерий завершения:** управляющий видит реальные цифры по своей рецепции и может настроить пороги.
|
||||
|
||||
### Фаза 1 — идентификация + Полимед (+4–6 нед)
|
||||
- `face-service` + pgvector.
|
||||
- Cross-camera re-id (склейка треков по лицевому эмбеддингу).
|
||||
- Web-admin: страница ручного enrollment с журналом записей из Полимед.
|
||||
- Фиксация бумажных согласий, отзыв согласия (удаление за 24 ч).
|
||||
- Персональная история визитов пациента.
|
||||
- Write-события визитов в Полимед (если согласовано со стороны МИС).
|
||||
|
||||
**Критерий завершения:** при повторном визите пациента (после первого ручного enrollment) система автоматически узнаёт его и подтягивает текущий приём из Полимед.
|
||||
|
||||
### Фаза 2 — поведенческие сигналы (+6–8 нед)
|
||||
- `behavior-service`: action recognition на агрессию.
|
||||
- `max-bot`: алерты в Max с кадром-доказательством.
|
||||
- Логика «уход без обслуживания» (поверх треков, без отдельной модели).
|
||||
- Скопление в зоне (counter, без отдельной модели).
|
||||
|
||||
**Критерий завершения:** инцидент агрессии у стойки рецепции в течение ≤ N секунд попадает в Max нужному получателю.
|
||||
|
||||
### Фаза 3+ — по запросу
|
||||
- Дополнительные поведенческие сигналы.
|
||||
- Сводки и триггерные алерты в Max.
|
||||
- Интеграции с другими компонентами ПЦС.
|
||||
|
||||
---
|
||||
|
||||
## 10. Открытые вопросы
|
||||
|
||||
- Точная спецификация REST-эндпоинтов Полимед (методы, форматы, аутентификация).
|
||||
- Допустимость write-операций назад в Полимед и их объём.
|
||||
- Параметры Max-бота (как регистрируется бот, какой API/SDK).
|
||||
- Хостинг GPU-сервера (внутри ЛВС клиники, требования по сети и питанию).
|
||||
- Шаблон согласия на обработку биометрических ПДн (юр. отдел).
|
||||
- SSO от ПЦС: на каком этапе подключаем.
|
||||
|
||||
---
|
||||
|
||||
## Приложение A. Тех. стек = `work-pcs-adm-time-tracker` + дельта
|
||||
|
||||
| Компонент | Из time-tracker (`/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker`) | Дельта для «Цифровой рецепции» |
|
||||
|---|---|---|
|
||||
| Языки | Node.js (Nest.js, Next.js), Python (FastAPI) | + Python для `video-ingest` и `behavior-service` |
|
||||
| Распознавание лиц | InsightFace `buffalo_l` (`/apps/face-service`) | — (переиспользуем) |
|
||||
| Векторный поиск | PostgreSQL + pgvector (`/packages/db/prisma/schema.prisma`) | — (переиспользуем) |
|
||||
| Видео-вход | MediaDevices браузера, кадр раз в 2 сек | → серверный RTSP-консьюмер (OpenCV/ffmpeg), GPU |
|
||||
| Трекинг | нет | + ByteTrack / BoT-SORT |
|
||||
| Cross-camera re-id | нет | + по лицевому эмбеддингу в окне T мин |
|
||||
| Action recognition | нет | + SlowFast / X3D / MoViNet (Фаза 2) |
|
||||
| Хранилище | Postgres + pgvector, Redis | + MinIO (кадры-доказательства) |
|
||||
| Деплой | docker-compose (`/docker/docker-compose.yml`) | + GPU-runtime (nvidia-container-toolkit), `docker-compose.prod.yml` |
|
||||
| Авторизация | JWT + refresh в httpOnly cookie (`/apps/api`) | — (Ф0), → SSO от ПЦС (Ф1+) |
|
||||
| Монорепо | pnpm + Turborepo | — (переиспользуем) |
|
||||
|
||||
**Ссылки для разработки:**
|
||||
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/ARCHITECTURE.md` — техническая схема time-tracker.
|
||||
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/apps/face-service/main.py` — `/recognize` эндпоинт.
|
||||
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/apps/face-service/requirements.txt` — версии Python-зависимостей.
|
||||
- `/Users/alekseyrazorvinm4/Project-with-LLM/work-pcs-adm-time-tracker/packages/db/prisma/schema.prisma` — модель данных.
|
||||
@@ -0,0 +1,29 @@
|
||||
# apps/api/.env.example
|
||||
# Обычно apps/api берёт env из корневого .env через NestJS ConfigModule,
|
||||
# но дублирующий файл здесь — для удобства самостоятельного запуска.
|
||||
|
||||
NODE_ENV=development
|
||||
API_PORT=4000
|
||||
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
|
||||
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
|
||||
FACE_SERVICE_URL=http://localhost:8001
|
||||
POLIMED_BASE_URL=http://localhost:4100
|
||||
WEB_ADMIN_ORIGIN=http://localhost:3000
|
||||
|
||||
JWT_ACCESS_SECRET=dev-access-secret-change-me-please
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-me-please
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=30d
|
||||
COOKIE_SECURE=false
|
||||
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
MINIO_BUCKET=reception-evidence
|
||||
EVIDENCE_PRESIGN_TTL_SECONDS=900
|
||||
|
||||
CONSENT_REVOKE_DELAY_MS=86400000
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
testRegex: '\\.e2e-spec\\.ts$',
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json', isolatedModules: true }],
|
||||
},
|
||||
testTimeout: 30000,
|
||||
setupFiles: ['<rootDir>/test/setup-env.ts'],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@reception/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config jest.e2e.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.687.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.687.0",
|
||||
"@nestjs/bullmq": "^10.2.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@reception/db": "workspace:*",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.34.10",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@reception/eslint-config": "workspace:*",
|
||||
"@reception/tsconfig": "workspace:*",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { join } from 'node:path';
|
||||
import { validateEnv } from './config/env.schema';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
import { BiometryAccessLogInterceptor } from './auth/interceptors/biometry-access-log.interceptor';
|
||||
import { HealthController } from './health.controller';
|
||||
import { PolimedModule } from './polimed/polimed.module';
|
||||
import { FaceModule } from './face/face.module';
|
||||
import { EvidenceModule } from './evidence/evidence.module';
|
||||
import { TracksModule } from './tracks/tracks.module';
|
||||
import { EnrollmentModule } from './enrollment/enrollment.module';
|
||||
import { ConsentsModule } from './consents/consents.module';
|
||||
import { VisitsModule } from './visits/visits.module';
|
||||
import { RecognitionModule } from './recognition/recognition.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { IngestModule } from './ingest/ingest.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { CamerasModule } from './cameras/cameras.module';
|
||||
|
||||
const REPO_ROOT_ENV = join(__dirname, '..', '..', '..', '.env');
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
envFilePath: ['.env', REPO_ROOT_ENV],
|
||||
validate: validateEnv,
|
||||
}),
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
connection: {
|
||||
host: config.getOrThrow<string>('REDIS_HOST'),
|
||||
port: config.getOrThrow<number>('REDIS_PORT'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
PolimedModule,
|
||||
FaceModule,
|
||||
EvidenceModule,
|
||||
TracksModule,
|
||||
EnrollmentModule,
|
||||
ConsentsModule,
|
||||
VisitsModule,
|
||||
RecognitionModule,
|
||||
AuditModule,
|
||||
IngestModule,
|
||||
DashboardModule,
|
||||
CamerasModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: BiometryAccessLogInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Roles(Role.SYSADMIN)
|
||||
@Get('biometry')
|
||||
async biometry(
|
||||
@Query('actorUserId') actorUserId?: string,
|
||||
@Query('subjectPatientId') subjectPatientId?: string,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('limit') limitRaw?: string,
|
||||
) {
|
||||
const take = Math.min(Number(limitRaw) || 100, 500);
|
||||
const where: Record<string, unknown> = {};
|
||||
if (actorUserId) where.actorUserId = actorUserId;
|
||||
if (subjectPatientId) where.subjectPatientId = subjectPatientId;
|
||||
if (from || to) {
|
||||
where.occurredAt = {} as Record<string, Date>;
|
||||
if (from) (where.occurredAt as Record<string, Date>).gte = new Date(from);
|
||||
if (to) (where.occurredAt as Record<string, Date>).lte = new Date(to);
|
||||
}
|
||||
|
||||
return this.prisma.biometryAccessLog.findMany({
|
||||
where,
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
include: { actor: { select: { email: true, fullName: true, role: true } } },
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { AuthService, type AuthTokens } from './auth.service';
|
||||
import { CurrentUser, type AuthUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
class LoginDto {
|
||||
// Разрешаем email без TLD (для dev-юзеров senior@local и т.п.)
|
||||
@IsEmail({ require_tld: false })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
const ACCESS_COOKIE = 'access_token';
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const tokens = await this.auth.login(dto.email, dto.password);
|
||||
this.setAuthCookies(res, tokens);
|
||||
return { ok: true, userId: tokens.userId };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const refresh = req.cookies?.[REFRESH_COOKIE];
|
||||
const tokens = await this.auth.refresh(refresh);
|
||||
this.setAuthCookies(res, tokens);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const refresh = req.cookies?.[REFRESH_COOKIE];
|
||||
await this.auth.logout(refresh);
|
||||
res.clearCookie(ACCESS_COOKIE);
|
||||
res.clearCookie(REFRESH_COOKIE);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return user;
|
||||
}
|
||||
|
||||
private setAuthCookies(res: Response, tokens: AuthTokens) {
|
||||
const secure = this.config.get<boolean>('COOKIE_SECURE') ?? false;
|
||||
const baseCookie: CookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
path: '/',
|
||||
};
|
||||
res.cookie(ACCESS_COOKIE, tokens.accessToken, {
|
||||
...baseCookie,
|
||||
maxAge: 15 * 60 * 1000,
|
||||
});
|
||||
res.cookie(REFRESH_COOKIE, tokens.refreshToken, {
|
||||
...baseCookie,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
path: '/auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('JWT_ACCESS_TTL') ?? '15m',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { JwtPayload } from './strategies/jwt.strategy';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async validateUser(email: string, password: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !user.isActive) return null;
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<AuthTokens & { userId: string }> {
|
||||
const user = await this.validateUser(email, password);
|
||||
if (!user) throw new UnauthorizedException('Invalid credentials');
|
||||
const tokens = await this.issueTokens(user.id, user.email, user.role);
|
||||
return { ...tokens, userId: user.id };
|
||||
}
|
||||
|
||||
async issueTokens(userId: string, email: string, role: string): Promise<AuthTokens> {
|
||||
const payload: JwtPayload = { sub: userId, email, role };
|
||||
|
||||
const accessToken = await this.jwt.signAsync(payload, {
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: this.config.get<string>('JWT_ACCESS_TTL') ?? '15m',
|
||||
});
|
||||
|
||||
const refreshRaw = randomBytes(48).toString('hex');
|
||||
const refreshHash = this.hashToken(refreshRaw);
|
||||
const expiresAt = this.computeRefreshExpiry();
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: { userId, tokenHash: refreshHash, expiresAt },
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken: refreshRaw };
|
||||
}
|
||||
|
||||
async refresh(refreshTokenRaw: string): Promise<AuthTokens> {
|
||||
const tokenHash = this.hashToken(refreshTokenRaw);
|
||||
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: stored.userId } });
|
||||
if (!user || !user.isActive) throw new UnauthorizedException('User inactive');
|
||||
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return this.issueTokens(user.id, user.email, user.role);
|
||||
}
|
||||
|
||||
async logout(refreshTokenRaw: string | undefined) {
|
||||
if (!refreshTokenRaw) return;
|
||||
const tokenHash = this.hashToken(refreshTokenRaw);
|
||||
await this.prisma.refreshToken
|
||||
.updateMany({ where: { tokenHash, revokedAt: null }, data: { revokedAt: new Date() } })
|
||||
.catch((e) => {
|
||||
this.logger.warn(`Logout failed silently: ${e}`);
|
||||
});
|
||||
}
|
||||
|
||||
private hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
private computeRefreshExpiry(): Date {
|
||||
const ttl = this.config.get<string>('JWT_REFRESH_TTL') ?? '30d';
|
||||
const match = ttl.match(/^(\d+)([smhd])$/);
|
||||
const now = Date.now();
|
||||
if (!match) {
|
||||
throw new ConflictException(`Invalid JWT_REFRESH_TTL: ${ttl}`);
|
||||
}
|
||||
const value = Number(match[1]);
|
||||
const unit = match[2];
|
||||
const multiplier = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000;
|
||||
return new Date(now + value * multiplier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { Role } from '@reception/db';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
const req = ctx.switchToHttp().getRequest<Request & { user: AuthUser }>();
|
||||
return req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const LOGS_BIOMETRY_KEY = 'logs_biometry';
|
||||
|
||||
/**
|
||||
* Помечает контроллер/маршрут, чьи вызовы должны логироваться в biometry_access_log.
|
||||
* Используется BiometryAccessLogInterceptor.
|
||||
*
|
||||
* @param action — произвольная метка действия (например, 'enroll', 'recognize', 'view_visits').
|
||||
*/
|
||||
export const LogsBiometry = (action: string) => SetMetadata(LOGS_BIOMETRY_KEY, action);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'is_public';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '@reception/db';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import type { AuthUser } from '../decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user: AuthUser | undefined = req.user;
|
||||
if (!user) throw new ForbiddenException('No user context');
|
||||
if (!required.includes(user.role)) {
|
||||
throw new ForbiddenException(`Required role: ${required.join(' or ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { AuthModule } from './auth.module';
|
||||
export { AuthService } from './auth.service';
|
||||
export { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
export { RolesGuard } from './guards/roles.guard';
|
||||
export { BiometryAccessLogInterceptor } from './interceptors/biometry-access-log.interceptor';
|
||||
export { Roles } from './decorators/roles.decorator';
|
||||
export { Public } from './decorators/public.decorator';
|
||||
export { LogsBiometry } from './decorators/logs-biometry.decorator';
|
||||
export { CurrentUser, type AuthUser } from './decorators/current-user.decorator';
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { LOGS_BIOMETRY_KEY } from '../decorators/logs-biometry.decorator';
|
||||
import type { AuthUser } from '../decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class BiometryAccessLogInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(BiometryAccessLogInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const action = this.reflector.getAllAndOverride<string | undefined>(LOGS_BIOMETRY_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!action) return next.handle();
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user: AuthUser | undefined = req.user;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((responseBody) => {
|
||||
// Извлекаем subject (UUID нашего Patient) из:
|
||||
// 1. params.patientId, 2. body.patientId, 3. responseBody.patientId (для enrollment).
|
||||
const candidates = [
|
||||
req.params?.patientId,
|
||||
req.body?.patientId,
|
||||
(responseBody as { patientId?: string } | null)?.patientId,
|
||||
];
|
||||
const subjectPatientId = candidates.find((c) => typeof c === 'string' && UUID_RE.test(c)) ?? null;
|
||||
|
||||
this.prisma.biometryAccessLog
|
||||
.create({
|
||||
data: {
|
||||
action,
|
||||
requestPath: req.originalUrl ?? req.url,
|
||||
actorUserId: user?.id ?? null,
|
||||
subjectPatientId,
|
||||
},
|
||||
})
|
||||
.catch((err) => this.logger.error(`Failed to write biometry access log: ${err}`));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@@ -0,0 +1 @@
|
||||
export { Role } from '@reception/db';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { Request } from 'express';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { AuthUser } from '../decorators/current-user.decorator';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(req: Request) => req?.cookies?.access_token ?? null,
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or inactive');
|
||||
}
|
||||
return { id: user.id, email: user.email, role: user.role };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('cameras')
|
||||
export class CamerasController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
async list() {
|
||||
const cameras = await this.prisma.camera.findMany({
|
||||
include: { zone: true },
|
||||
orderBy: [{ zone: { code: 'asc' } }, { name: 'asc' }],
|
||||
});
|
||||
return cameras.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
zoneCode: c.zone.code,
|
||||
zoneName: c.zone.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CamerasController } from './cameras.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CamerasController],
|
||||
})
|
||||
export class CamerasModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
|
||||
API_PORT: z.coerce.number().int().positive().default(4000),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
REDIS_HOST: z.string().default('localhost'),
|
||||
REDIS_PORT: z.coerce.number().int().positive().default(6380),
|
||||
|
||||
FACE_SERVICE_URL: z.string().url().default('http://localhost:8001'),
|
||||
POLIMED_BASE_URL: z.string().url().default('http://localhost:4100'),
|
||||
|
||||
WEB_ADMIN_ORIGIN: z.string().url().default('http://localhost:3000'),
|
||||
|
||||
JWT_ACCESS_SECRET: z.string().min(16),
|
||||
JWT_REFRESH_SECRET: z.string().min(16),
|
||||
JWT_ACCESS_TTL: z.string().default('15m'),
|
||||
JWT_REFRESH_TTL: z.string().default('30d'),
|
||||
COOKIE_SECURE: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'),
|
||||
|
||||
MINIO_ENDPOINT: z.string().url().default('http://localhost:9000'),
|
||||
MINIO_ROOT_USER: z.string().default('minioadmin'),
|
||||
MINIO_ROOT_PASSWORD: z.string().default('minioadmin'),
|
||||
MINIO_BUCKET: z.string().default('reception-evidence'),
|
||||
|
||||
EVIDENCE_PRESIGN_TTL_SECONDS: z.coerce.number().int().positive().default(900),
|
||||
|
||||
CONSENT_REVOKE_DELAY_MS: z.coerce.number().int().nonnegative().default(86_400_000),
|
||||
});
|
||||
|
||||
export type AppEnv = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(raw: Record<string, unknown>): AppEnv {
|
||||
const parsed = envSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
||||
throw new Error(`Invalid environment configuration:\n${issues}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { ConsentAction, ConsentRevocationStatus, TrackStatus } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FaceClient } from '../face/face.client';
|
||||
|
||||
export const CONSENT_REVOCATION_QUEUE = 'consent-revocation';
|
||||
|
||||
export interface ConsentRevocationJobData {
|
||||
jobId: string;
|
||||
patientId: string;
|
||||
actorUserId: string;
|
||||
}
|
||||
|
||||
@Processor(CONSENT_REVOCATION_QUEUE)
|
||||
export class ConsentRevocationProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(ConsentRevocationProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly face: FaceClient,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<ConsentRevocationJobData>): Promise<void> {
|
||||
const { jobId, patientId, actorUserId } = job.data;
|
||||
this.logger.log(`Executing consent revocation for patient ${patientId} (job=${jobId})`);
|
||||
|
||||
const deletedEmbeddings = await this.face.deletePatientEmbeddings(patientId);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.patient.update({
|
||||
where: { id: patientId },
|
||||
data: { fullName: null, pendingDeletionAt: null },
|
||||
});
|
||||
|
||||
await tx.track.updateMany({
|
||||
where: { patientId },
|
||||
data: { status: TrackStatus.ANONYMIZED },
|
||||
});
|
||||
|
||||
await tx.patientConsent.create({
|
||||
data: {
|
||||
patientId,
|
||||
action: ConsentAction.REVOKED,
|
||||
paperRef: 'revocation-completed',
|
||||
actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.consentRevocationJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: ConsentRevocationStatus.DONE, completedAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.biometryAccessLog.create({
|
||||
data: {
|
||||
action: 'consent_revocation_completed',
|
||||
actorUserId,
|
||||
subjectPatientId: patientId,
|
||||
requestPath: 'queue:consent-revocation',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Consent revocation done: patient=${patientId}, deleted_embeddings=${deletedEmbeddings}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { CurrentUser, type AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { ConsentsService } from './consents.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('consents')
|
||||
export class ConsentsController {
|
||||
constructor(private readonly consents: ConsentsService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN)
|
||||
@LogsBiometry('consent_revoke')
|
||||
@Post(':patientId/revoke')
|
||||
@HttpCode(202)
|
||||
revoke(@Param('patientId') patientId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.consents.revoke(patientId, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConsentsController } from './consents.controller';
|
||||
import { ConsentsService } from './consents.service';
|
||||
import { CONSENT_REVOCATION_QUEUE, ConsentRevocationProcessor } from './consent-revocation.processor';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: CONSENT_REVOCATION_QUEUE })],
|
||||
controllers: [ConsentsController],
|
||||
providers: [ConsentsService, ConsentRevocationProcessor],
|
||||
exports: [ConsentsService],
|
||||
})
|
||||
export class ConsentsModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CONSENT_REVOCATION_QUEUE,
|
||||
type ConsentRevocationJobData,
|
||||
} from './consent-revocation.processor';
|
||||
|
||||
@Injectable()
|
||||
export class ConsentsService {
|
||||
private readonly logger = new Logger(ConsentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
@InjectQueue(CONSENT_REVOCATION_QUEUE) private readonly queue: Queue<ConsentRevocationJobData>,
|
||||
) {}
|
||||
|
||||
private getDelayMs(): number {
|
||||
// Читаем динамически — env может быть переопределён в тестах перед вызовом.
|
||||
return this.config.get<number>('CONSENT_REVOKE_DELAY_MS') ?? 86_400_000;
|
||||
}
|
||||
|
||||
async revoke(patientId: string, actorUserId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({ where: { id: patientId } });
|
||||
if (!patient) throw new NotFoundException(`Patient ${patientId} not found`);
|
||||
|
||||
const delayMs = this.getDelayMs();
|
||||
const scheduledFor = new Date(Date.now() + delayMs);
|
||||
|
||||
const job = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.patient.update({
|
||||
where: { id: patientId },
|
||||
data: { consentRevokedAt: new Date(), pendingDeletionAt: scheduledFor },
|
||||
});
|
||||
|
||||
return tx.consentRevocationJob.create({
|
||||
data: {
|
||||
patientId,
|
||||
revokedAt: new Date(),
|
||||
scheduledFor,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.queue.add(
|
||||
'revoke',
|
||||
{ jobId: job.id, patientId, actorUserId },
|
||||
{ delay: delayMs, removeOnComplete: true, removeOnFail: false },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Scheduled consent revocation: patient=${patientId} → ${scheduledFor.toISOString()} (delay=${delayMs}ms)`,
|
||||
);
|
||||
|
||||
return { jobId: job.id, scheduledFor };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboard: DashboardService) {}
|
||||
|
||||
@Roles(Role.MANAGER, Role.SYSADMIN, Role.SENIOR_ADMIN)
|
||||
@Get('overview')
|
||||
overview(@Query('date') date?: string) {
|
||||
const dateIso = date ?? new Date().toISOString().slice(0, 10);
|
||||
return this.dashboard.overview({ dateIso });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface OverviewOpts {
|
||||
dateIso: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface KpiCard {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
hint?: string;
|
||||
/** true — данные синтетические / неполные (нужен Ф0 для realistic). */
|
||||
synthetic?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async overview(opts: OverviewOpts) {
|
||||
const start = new Date(`${opts.dateIso}T00:00:00.000Z`);
|
||||
const end = new Date(`${opts.dateIso}T23:59:59.999Z`);
|
||||
|
||||
const [
|
||||
visitsToday,
|
||||
newEnrollmentsToday,
|
||||
unmatchedTracks,
|
||||
leftWithoutService,
|
||||
visitsWithService,
|
||||
tracksToday,
|
||||
visitsByHour,
|
||||
eventsByZone,
|
||||
zoneTimeStats,
|
||||
] = await Promise.all([
|
||||
this.prisma.visit.count({ where: { arrivedAt: { gte: start, lte: end } } }),
|
||||
|
||||
this.prisma.patientConsent.count({
|
||||
where: { action: 'GRANTED', occurredAt: { gte: start, lte: end } },
|
||||
}),
|
||||
|
||||
this.prisma.track.count({
|
||||
where: { status: 'UNMATCHED', firstSeenAt: { gte: start, lte: end } },
|
||||
}),
|
||||
|
||||
this.prisma.visit.count({
|
||||
where: { arrivedAt: { gte: start, lte: end }, leftWithoutService: true },
|
||||
}),
|
||||
|
||||
this.prisma.visit.findMany({
|
||||
where: {
|
||||
arrivedAt: { gte: start, lte: end },
|
||||
serviceStartedAt: { not: null },
|
||||
serviceEndedAt: { not: null },
|
||||
},
|
||||
select: { arrivedAt: true, serviceStartedAt: true, serviceEndedAt: true },
|
||||
}),
|
||||
|
||||
this.prisma.track.count({
|
||||
where: { firstSeenAt: { gte: start, lte: end } },
|
||||
}),
|
||||
|
||||
this.prisma.$queryRaw<Array<{ hour: number; visits: bigint }>>`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM arrived_at)::int AS hour,
|
||||
COUNT(*)::bigint AS visits
|
||||
FROM visits
|
||||
WHERE arrived_at >= ${start} AND arrived_at <= ${end}
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`,
|
||||
|
||||
this.prisma.$queryRaw<Array<{ code: string; events: bigint; tracks: bigint }>>`
|
||||
SELECT
|
||||
z.code::text AS code,
|
||||
COUNT(te.*)::bigint AS events,
|
||||
COUNT(DISTINCT te.track_id)::bigint AS tracks
|
||||
FROM zones z
|
||||
LEFT JOIN track_events te ON te.zone_id = z.id
|
||||
AND te.occurred_at >= ${start} AND te.occurred_at <= ${end}
|
||||
GROUP BY z.code
|
||||
ORDER BY z.code
|
||||
`,
|
||||
|
||||
// Среднее время в зоне для каждой зоны: для каждого трека
|
||||
// считаем разницу между первым и последним событием в зоне.
|
||||
this.prisma.$queryRaw<Array<{ code: string; avg_seconds: number }>>`
|
||||
WITH zone_segments AS (
|
||||
SELECT
|
||||
z.code::text AS code,
|
||||
te.track_id,
|
||||
EXTRACT(EPOCH FROM (MAX(te.occurred_at) - MIN(te.occurred_at)))::float AS seconds
|
||||
FROM track_events te
|
||||
JOIN zones z ON z.id = te.zone_id
|
||||
WHERE te.occurred_at >= ${start} AND te.occurred_at <= ${end}
|
||||
GROUP BY z.code, te.track_id
|
||||
HAVING COUNT(*) >= 2
|
||||
)
|
||||
SELECT code, AVG(seconds)::float AS avg_seconds
|
||||
FROM zone_segments
|
||||
GROUP BY code
|
||||
ORDER BY code
|
||||
`,
|
||||
]);
|
||||
|
||||
const avgWaitingSec = avg(
|
||||
visitsWithService.map(
|
||||
(v) => (v.serviceStartedAt!.getTime() - v.arrivedAt.getTime()) / 1000,
|
||||
),
|
||||
);
|
||||
const avgServiceSec = avg(
|
||||
visitsWithService.map(
|
||||
(v) => (v.serviceEndedAt!.getTime() - v.serviceStartedAt!.getTime()) / 1000,
|
||||
),
|
||||
);
|
||||
|
||||
// Подтянем «in flight» — пациентов сейчас в клинике (есть `arrived`, нет `service_ended` и не вышел через `left_without_service`).
|
||||
const liveQueue = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(DISTINCT t.id)::bigint AS count
|
||||
FROM tracks t
|
||||
WHERE t.first_seen_at >= ${start} AND t.last_seen_at >= NOW() - INTERVAL '30 minutes'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM track_events te
|
||||
WHERE te.track_id = t.id
|
||||
AND te.type IN ('service_ended', 'left_without_service')
|
||||
)
|
||||
`;
|
||||
|
||||
const cards: KpiCard[] = [
|
||||
{ label: 'Визитов сегодня', value: visitsToday, unit: 'шт', hint: 'из таблицы visits' },
|
||||
{
|
||||
label: 'Новых enrollment',
|
||||
value: newEnrollmentsToday,
|
||||
unit: 'шт',
|
||||
hint: 'согласие GRANTED сегодня',
|
||||
},
|
||||
{
|
||||
label: 'Активных треков',
|
||||
value: Number(liveQueue[0]?.count ?? 0),
|
||||
unit: 'шт',
|
||||
hint: 'не закрыты service_ended/left_without_service',
|
||||
},
|
||||
{
|
||||
label: 'Unmatched-треков',
|
||||
value: unmatchedTracks,
|
||||
unit: 'шт',
|
||||
hint: 'ждут ручного enrollment',
|
||||
},
|
||||
{
|
||||
label: 'Среднее ожидание',
|
||||
value: Math.round(avgWaitingSec ?? 0),
|
||||
unit: 'сек',
|
||||
hint: 'arrived → service_started',
|
||||
synthetic: visitsWithService.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Среднее обслуживание',
|
||||
value: Math.round(avgServiceSec ?? 0),
|
||||
unit: 'сек',
|
||||
hint: 'service_started → service_ended',
|
||||
synthetic: visitsWithService.length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Ушли без обслуживания',
|
||||
value: leftWithoutService,
|
||||
unit: 'шт',
|
||||
hint: 'left_without_service за день',
|
||||
},
|
||||
{
|
||||
label: 'Треков создано',
|
||||
value: tracksToday,
|
||||
unit: 'шт',
|
||||
hint: 'все треки за день, включая повторные узнавания',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
date: opts.dateIso,
|
||||
cards,
|
||||
visitsByHour: visitsByHour.map((v) => ({ hour: v.hour, visits: Number(v.visits) })),
|
||||
zoneActivity: eventsByZone.map((z) => ({
|
||||
code: z.code,
|
||||
events: Number(z.events),
|
||||
tracks: Number(z.tracks),
|
||||
})),
|
||||
avgTimeInZoneSec: zoneTimeStats.map((s) => ({
|
||||
code: s.code,
|
||||
seconds: Math.round(s.avg_seconds),
|
||||
})),
|
||||
hasRealData: visitsWithService.length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function avg(arr: number[]): number | null {
|
||||
if (arr.length === 0) return null;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { CurrentUser, type AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { EnrollmentService } from './enrollment.service';
|
||||
|
||||
class EnrollmentDto {
|
||||
@IsUUID()
|
||||
trackId!: string;
|
||||
|
||||
@IsString()
|
||||
polimedPatientId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
polimedAppointmentId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
paperConsentRef!: string;
|
||||
}
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('enrollment')
|
||||
export class EnrollmentController {
|
||||
constructor(private readonly enrollment: EnrollmentService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN)
|
||||
@LogsBiometry('enroll')
|
||||
@Post()
|
||||
enroll(@Body() dto: EnrollmentDto, @CurrentUser() user: AuthUser) {
|
||||
return this.enrollment.enroll({
|
||||
trackId: dto.trackId,
|
||||
polimedPatientId: dto.polimedPatientId,
|
||||
polimedAppointmentId: dto.polimedAppointmentId,
|
||||
paperConsentRef: dto.paperConsentRef,
|
||||
actorUserId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EnrollmentController } from './enrollment.controller';
|
||||
import { EnrollmentService } from './enrollment.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EnrollmentController],
|
||||
providers: [EnrollmentService],
|
||||
})
|
||||
export class EnrollmentModule {}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConsentAction, TrackStatus } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FaceClient } from '../face/face.client';
|
||||
import { PolimedClient } from '../polimed/polimed.client';
|
||||
|
||||
interface EnrollmentOpts {
|
||||
trackId: string;
|
||||
polimedPatientId: string;
|
||||
polimedAppointmentId?: string;
|
||||
paperConsentRef: string;
|
||||
actorUserId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
private readonly logger = new Logger(EnrollmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly face: FaceClient,
|
||||
private readonly polimed: PolimedClient,
|
||||
) {}
|
||||
|
||||
async enroll(opts: EnrollmentOpts) {
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { id: opts.trackId },
|
||||
include: { _count: { select: { faceEmbeddings: true } } },
|
||||
});
|
||||
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
|
||||
if (track.patientId) {
|
||||
throw new BadRequestException(`Track ${opts.trackId} уже привязан к пациенту`);
|
||||
}
|
||||
if (track._count.faceEmbeddings === 0) {
|
||||
throw new BadRequestException(`У трека ${opts.trackId} нет эмбеддингов`);
|
||||
}
|
||||
|
||||
// 1. Получаем данные пациента из Полимед.
|
||||
const polimedAppointment = opts.polimedAppointmentId
|
||||
? await this.polimed.getAppointment(opts.polimedAppointmentId).catch(() => null)
|
||||
: null;
|
||||
|
||||
const polimedPatients = await this.polimed.searchPatients(opts.polimedPatientId, 50);
|
||||
const polimedPatient = polimedPatients.find((p) => p.id === opts.polimedPatientId)
|
||||
?? polimedAppointment;
|
||||
const fullName = polimedAppointment?.patientFullName
|
||||
?? polimedPatients.find((p) => p.id === opts.polimedPatientId)?.fullName
|
||||
?? null;
|
||||
|
||||
if (!fullName) {
|
||||
this.logger.warn(`Не нашли полное ФИО в Полимед для ${opts.polimedPatientId}`);
|
||||
}
|
||||
|
||||
// 2. Транзакция: создаём/обновляем Patient + Consent + Track + Visit.
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const patient = await tx.patient.upsert({
|
||||
where: { polimedPatientId: opts.polimedPatientId },
|
||||
update: {
|
||||
fullName: fullName ?? undefined,
|
||||
consentReceivedAt: new Date(),
|
||||
consentRevokedAt: null,
|
||||
pendingDeletionAt: null,
|
||||
},
|
||||
create: {
|
||||
polimedPatientId: opts.polimedPatientId,
|
||||
fullName: fullName ?? 'Без ФИО',
|
||||
consentReceivedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.patientConsent.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
action: ConsentAction.GRANTED,
|
||||
paperRef: opts.paperConsentRef,
|
||||
actorUserId: opts.actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { patientId: patient.id, status: TrackStatus.MATCHED },
|
||||
});
|
||||
|
||||
const visit = await tx.visit.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
polimedAppointmentId: opts.polimedAppointmentId ?? null,
|
||||
arrivedAt: track.firstSeenAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { patient, visit };
|
||||
});
|
||||
|
||||
// 3. Привязываем эмбеддинги трека к пациенту в face-service.
|
||||
const attached = await this.face.enrollTrack(opts.trackId, result.patient.id);
|
||||
|
||||
if (opts.polimedAppointmentId) {
|
||||
this.polimed
|
||||
.pushVisitEvent(opts.polimedAppointmentId, {
|
||||
type: 'arrived',
|
||||
occurredAt: track.firstSeenAt.toISOString(),
|
||||
})
|
||||
.catch((err) => this.logger.warn(`Polimed pushVisitEvent failed: ${err}`));
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Enrollment: track=${opts.trackId} → patient=${result.patient.id}, embeddings=${attached}`,
|
||||
);
|
||||
|
||||
return {
|
||||
patientId: result.patient.id,
|
||||
visitId: result.visit.id,
|
||||
embeddingsAttached: attached,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EvidenceService } from './evidence.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EvidenceService],
|
||||
exports: [EvidenceService],
|
||||
})
|
||||
export class EvidenceModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class EvidenceService implements OnModuleInit {
|
||||
private readonly logger = new Logger(EvidenceService.name);
|
||||
private readonly s3: S3Client;
|
||||
private readonly bucket: string;
|
||||
private readonly presignTtl: number;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: config.getOrThrow<string>('MINIO_ENDPOINT'),
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: config.getOrThrow<string>('MINIO_ROOT_USER'),
|
||||
secretAccessKey: config.getOrThrow<string>('MINIO_ROOT_PASSWORD'),
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
this.bucket = config.getOrThrow<string>('MINIO_BUCKET');
|
||||
this.presignTtl = config.get<number>('EVIDENCE_PRESIGN_TTL_SECONDS') ?? 900;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
} catch {
|
||||
try {
|
||||
await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
||||
this.logger.log(`Created MinIO bucket "${this.bucket}"`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`MinIO bucket "${this.bucket}" unavailable: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохраняет JPEG-кадр в MinIO. Возвращает object key. */
|
||||
async putEvidence(jpegBuffer: Buffer, opts: { trackId: string; cameraId: string }): Promise<string> {
|
||||
const key = `tracks/${opts.trackId}/${opts.cameraId}-${Date.now()}-${randomUUID()}.jpg`;
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: jpegBuffer,
|
||||
ContentType: 'image/jpeg',
|
||||
}),
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
async getPresignedUrl(key: string, ttlSeconds?: number): Promise<string> {
|
||||
const cmd = new GetObjectCommand({ Bucket: this.bucket, Key: key });
|
||||
return getSignedUrl(this.s3, cmd, { expiresIn: ttlSeconds ?? this.presignTtl });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface RecognizeResult {
|
||||
patient_id: string;
|
||||
confidence: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FaceClient {
|
||||
private readonly logger = new Logger(FaceClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.baseUrl = config.getOrThrow<string>('FACE_SERVICE_URL');
|
||||
}
|
||||
|
||||
async recognize(frameBase64: string): Promise<RecognizeResult | null> {
|
||||
const res = await fetch(`${this.baseUrl}/recognize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frame: frameBase64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`face-service /recognize failed: ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as RecognizeResult | null;
|
||||
return json;
|
||||
}
|
||||
|
||||
async enrollTrack(trackId: string, patientId: string): Promise<number> {
|
||||
const res = await fetch(`${this.baseUrl}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track_id: trackId, patient_id: patientId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`face-service /enroll failed: ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as { embeddings_attached: number };
|
||||
return json.embeddings_attached;
|
||||
}
|
||||
|
||||
async deletePatientEmbeddings(patientId: string): Promise<number> {
|
||||
const res = await fetch(`${this.baseUrl}/patient/${patientId}/embeddings`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`face-service DELETE /patient/.../embeddings failed: ${res.status}`);
|
||||
return 0;
|
||||
}
|
||||
const json = (await res.json()) as { deleted: number };
|
||||
return json.deleted;
|
||||
}
|
||||
|
||||
async countPatientEmbeddings(patientId: string): Promise<number> {
|
||||
const res = await fetch(`${this.baseUrl}/patient/${patientId}/count`);
|
||||
if (!res.ok) return 0;
|
||||
const json = (await res.json()) as { count: number };
|
||||
return json.count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { FaceClient } from './face.client';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [FaceClient],
|
||||
exports: [FaceClient],
|
||||
})
|
||||
export class FaceModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from './auth/decorators/public.decorator';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'reception-api' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { IsEnum, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { TrackEventType, ZoneCode } from '@reception/db';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
class CreateTrackDto {
|
||||
@IsString()
|
||||
cameraName!: string;
|
||||
|
||||
@IsISO8601()
|
||||
firstSeenAt!: string;
|
||||
}
|
||||
|
||||
class AddEventDto {
|
||||
@IsUUID()
|
||||
trackId!: string;
|
||||
|
||||
@IsEnum(['arrived', 'waiting', 'service_started', 'service_ended', 'left_without_service'])
|
||||
type!: TrackEventType;
|
||||
|
||||
@IsString()
|
||||
cameraName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['A', 'B', 'C'])
|
||||
zoneCode?: ZoneCode;
|
||||
|
||||
@IsISO8601()
|
||||
occurredAt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
evidenceKey?: string;
|
||||
}
|
||||
|
||||
class CaptureFrameDto {
|
||||
@IsUUID()
|
||||
trackId!: string;
|
||||
|
||||
@IsString()
|
||||
cameraName!: string;
|
||||
|
||||
@IsString()
|
||||
frame!: string;
|
||||
}
|
||||
|
||||
@Controller('ingest')
|
||||
export class IngestController {
|
||||
constructor(private readonly ingest: IngestService) {}
|
||||
|
||||
@Public()
|
||||
@Post('tracks')
|
||||
createTrack(@Body() dto: CreateTrackDto) {
|
||||
return this.ingest.createTrack({
|
||||
cameraName: dto.cameraName,
|
||||
firstSeenAt: new Date(dto.firstSeenAt),
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('track-events')
|
||||
addEvent(@Body() dto: AddEventDto) {
|
||||
return this.ingest.addEvent({
|
||||
trackId: dto.trackId,
|
||||
type: dto.type,
|
||||
cameraName: dto.cameraName,
|
||||
zoneCode: dto.zoneCode,
|
||||
occurredAt: new Date(dto.occurredAt),
|
||||
evidenceKey: dto.evidenceKey,
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('capture-frame')
|
||||
captureFrame(@Body() dto: CaptureFrameDto) {
|
||||
return this.ingest.captureFrame({
|
||||
trackId: dto.trackId,
|
||||
cameraName: dto.cameraName,
|
||||
frameBase64: dto.frame,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IngestController } from './ingest.controller';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Module({
|
||||
controllers: [IngestController],
|
||||
providers: [IngestService],
|
||||
exports: [IngestService],
|
||||
})
|
||||
export class IngestModule {}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TrackEventType, ZoneCode } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EvidenceService } from '../evidence/evidence.service';
|
||||
|
||||
interface CreateTrackOpts {
|
||||
cameraName: string;
|
||||
firstSeenAt: Date;
|
||||
}
|
||||
|
||||
interface AddEventOpts {
|
||||
trackId: string;
|
||||
type: TrackEventType;
|
||||
cameraName: string;
|
||||
zoneCode?: ZoneCode;
|
||||
occurredAt: Date;
|
||||
evidenceKey?: string;
|
||||
}
|
||||
|
||||
interface CaptureFrameOpts {
|
||||
trackId: string;
|
||||
cameraName: string;
|
||||
frameBase64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal ingest API для video-ingest и fixtures-runner. Помечен Public
|
||||
* (нет JWT в Ф1) — в проде будет service-to-service токен.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IngestService {
|
||||
private readonly logger = new Logger(IngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly evidence: EvidenceService,
|
||||
) {}
|
||||
|
||||
async createTrack(opts: CreateTrackOpts) {
|
||||
const camera = await this.prisma.camera.findUnique({ where: { name: opts.cameraName } });
|
||||
if (!camera) throw new NotFoundException(`Camera "${opts.cameraName}" not found`);
|
||||
|
||||
const track = await this.prisma.track.create({
|
||||
data: { firstSeenAt: opts.firstSeenAt, lastSeenAt: opts.firstSeenAt },
|
||||
});
|
||||
|
||||
this.logger.log(`Track created: ${track.id} (camera=${opts.cameraName})`);
|
||||
return { trackId: track.id, cameraId: camera.id, zoneId: camera.zoneId };
|
||||
}
|
||||
|
||||
async addEvent(opts: AddEventOpts) {
|
||||
const camera = await this.prisma.camera.findUnique({
|
||||
where: { name: opts.cameraName },
|
||||
include: { zone: true },
|
||||
});
|
||||
if (!camera) throw new NotFoundException(`Camera "${opts.cameraName}" not found`);
|
||||
|
||||
if (opts.zoneCode && camera.zone.code !== opts.zoneCode) {
|
||||
throw new BadRequestException(
|
||||
`Camera "${opts.cameraName}" привязана к зоне ${camera.zone.code}, не к ${opts.zoneCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
const track = await this.prisma.track.findUnique({ where: { id: opts.trackId } });
|
||||
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
|
||||
|
||||
const event = await this.prisma.trackEvent.create({
|
||||
data: {
|
||||
trackId: opts.trackId,
|
||||
type: opts.type,
|
||||
cameraId: camera.id,
|
||||
zoneId: camera.zone.id,
|
||||
occurredAt: opts.occurredAt,
|
||||
evidenceKey: opts.evidenceKey ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Обновляем lastSeenAt трека.
|
||||
if (opts.occurredAt > track.lastSeenAt) {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: opts.occurredAt },
|
||||
});
|
||||
}
|
||||
|
||||
return { eventId: event.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Принимает base64 JPEG из браузера, шлёт в face-service /track-embeddings,
|
||||
* получает id сохранённого эмбеддинга (либо null, если лицо не найдено).
|
||||
* Параллельно вызывает face-service /recognize чтобы узнать, есть ли уже
|
||||
* привязанный пациент (для UI «узнан/не узнан»).
|
||||
*/
|
||||
async captureFrame(opts: CaptureFrameOpts) {
|
||||
const camera = await this.prisma.camera.findUniqueOrThrow({
|
||||
where: { name: opts.cameraName },
|
||||
include: { zone: true },
|
||||
});
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { id: opts.trackId },
|
||||
include: { _count: { select: { events: true } } },
|
||||
});
|
||||
if (!track) throw new NotFoundException(`Track ${opts.trackId} not found`);
|
||||
|
||||
const faceServiceUrl = this.config.getOrThrow<string>('FACE_SERVICE_URL');
|
||||
const now = new Date();
|
||||
|
||||
type EmbedRes = {
|
||||
id: string;
|
||||
quality: number;
|
||||
bbox: { box: number[]; imgW: number; imgH: number } | null;
|
||||
} | null;
|
||||
type RecogRes = { patient_id: string; confidence: number; distance: number } | null;
|
||||
|
||||
const [embedRes, recogRes] = (await Promise.all([
|
||||
fetch(`${faceServiceUrl}/track-embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
frame: opts.frameBase64,
|
||||
track_id: opts.trackId,
|
||||
camera_id: camera.id,
|
||||
captured_at: now.toISOString(),
|
||||
}),
|
||||
}).then((r) => (r.ok ? (r.json() as Promise<EmbedRes>) : null)),
|
||||
fetch(`${faceServiceUrl}/recognize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frame: opts.frameBase64 }),
|
||||
}).then((r) => (r.ok ? (r.json() as Promise<RecogRes>) : null)),
|
||||
]).catch((err) => {
|
||||
this.logger.warn(`captureFrame face-service error: ${err}`);
|
||||
return [null, null] as const;
|
||||
})) as [EmbedRes, RecogRes];
|
||||
|
||||
// Сохраняем JPEG в MinIO (только когда лицо обнаружено — нет смысла копить пустые кадры).
|
||||
let evidenceKey: string | null = null;
|
||||
if (embedRes) {
|
||||
try {
|
||||
const jpegBuffer = decodeBase64Jpeg(opts.frameBase64);
|
||||
evidenceKey = await this.evidence.putEvidence(jpegBuffer, {
|
||||
trackId: opts.trackId,
|
||||
cameraId: camera.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`MinIO put failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Каждый удачный кадр → событие. Первый — `arrived`, остальные — `waiting`.
|
||||
// Так на /enrollment/[id] видна вся серия кадров.
|
||||
if (embedRes && evidenceKey) {
|
||||
await this.prisma.trackEvent.create({
|
||||
data: {
|
||||
trackId: opts.trackId,
|
||||
type: track._count.events === 0 ? 'arrived' : 'waiting',
|
||||
cameraId: camera.id,
|
||||
zoneId: camera.zone.id,
|
||||
occurredAt: now,
|
||||
evidenceKey,
|
||||
faceBbox: (embedRes.bbox as unknown as object) ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Если узнан — подтягиваем пациента и АВТО-ПРИВЯЗЫВАЕМ к нему текущий трек.
|
||||
// Без этого каждая сессия на /capture создаёт unmatched-трек, и маршрут пациента
|
||||
// остаётся обрезанным (видим только первый enrollment-трек).
|
||||
let recognizedPatient: { id: string; fullName: string | null } | null = null;
|
||||
if (recogRes) {
|
||||
const p = await this.prisma.patient.findUnique({
|
||||
where: { id: recogRes.patient_id },
|
||||
select: { id: true, fullName: true, consentRevokedAt: true, pendingDeletionAt: true },
|
||||
});
|
||||
if (p && !p.consentRevokedAt && !p.pendingDeletionAt) {
|
||||
recognizedPatient = { id: p.id, fullName: p.fullName };
|
||||
|
||||
if (!track.patientId) {
|
||||
// Привязываем трек к пациенту и переводим в MATCHED.
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { patientId: p.id, status: 'MATCHED', lastSeenAt: now },
|
||||
});
|
||||
// Привязываем эмбеддинги трека к пациенту в face-service.
|
||||
fetch(`${faceServiceUrl}/enroll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track_id: opts.trackId, patient_id: p.id }),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) this.logger.warn(`face-service /enroll auto failed: ${r.status}`);
|
||||
else this.logger.log(`Auto-enrolled track ${opts.trackId} → patient ${p.id}`);
|
||||
})
|
||||
.catch((err) => this.logger.warn(`face-service /enroll auto error: ${err}`));
|
||||
} else {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.prisma.track.update({
|
||||
where: { id: opts.trackId },
|
||||
data: { lastSeenAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
embedding: embedRes,
|
||||
faceDetected: embedRes !== null,
|
||||
evidenceKey,
|
||||
recognized: recognizedPatient
|
||||
? {
|
||||
patientId: recognizedPatient.id,
|
||||
fullName: recognizedPatient.fullName,
|
||||
confidence: recogRes?.confidence ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Jpeg(input: string): Buffer {
|
||||
// Убираем data:image/jpeg;base64, если есть.
|
||||
const stripped = input.includes(',') ? input.split(',', 2)[1]! : input;
|
||||
return Buffer.from(stripped, 'base64');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: false });
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.enableCors({
|
||||
origin: config.get<string>('WEB_ADMIN_ORIGIN'),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const port = config.get<number>('API_PORT') ?? 4000;
|
||||
await app.listen(port);
|
||||
Logger.log(`reception-api listening on http://localhost:${port}`, 'Bootstrap');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface PolimedPatient {
|
||||
id: string;
|
||||
fullName: string;
|
||||
birthDate: string;
|
||||
phone: string;
|
||||
cardNumber: string;
|
||||
}
|
||||
|
||||
export interface PolimedAppointment {
|
||||
id: string;
|
||||
patientId: string;
|
||||
patientFullName: string;
|
||||
doctorFullName: string;
|
||||
specialty: string;
|
||||
scheduledFor: string;
|
||||
status: 'scheduled' | 'completed' | 'cancelled' | 'no_show';
|
||||
}
|
||||
|
||||
export type PolimedVisitEventType =
|
||||
| 'arrived'
|
||||
| 'service_started'
|
||||
| 'service_ended'
|
||||
| 'left_without_service';
|
||||
|
||||
@Injectable()
|
||||
export class PolimedClient {
|
||||
private readonly logger = new Logger(PolimedClient.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.baseUrl = config.getOrThrow<string>('POLIMED_BASE_URL');
|
||||
}
|
||||
|
||||
async searchPatients(query: string, limit = 20): Promise<PolimedPatient[]> {
|
||||
const url = new URL('/patients/search', this.baseUrl);
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('limit', String(limit));
|
||||
return this.fetchJson(url);
|
||||
}
|
||||
|
||||
async getAppointments(date?: string): Promise<PolimedAppointment[]> {
|
||||
const url = new URL('/appointments', this.baseUrl);
|
||||
if (date) url.searchParams.set('date', date);
|
||||
return this.fetchJson(url);
|
||||
}
|
||||
|
||||
async getAppointment(id: string): Promise<PolimedAppointment> {
|
||||
const url = new URL(`/appointments/${id}`, this.baseUrl);
|
||||
return this.fetchJson(url);
|
||||
}
|
||||
|
||||
async pushVisitEvent(
|
||||
appointmentId: string,
|
||||
event: { type: PolimedVisitEventType; occurredAt: string; source?: string },
|
||||
): Promise<void> {
|
||||
const url = new URL(`/visits/${appointmentId}/events`, this.baseUrl);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...event, source: event.source ?? 'reception-video' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`pushVisitEvent failed: ${res.status} ${url}`);
|
||||
throw new Error(`Polimed event failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchJson<T>(url: URL): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Polimed ${url.pathname} failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PolimedClient } from './polimed.client';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('polimed')
|
||||
export class PolimedController {
|
||||
constructor(private readonly polimed: PolimedClient) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('patients/search')
|
||||
search(@Query('q') q = '', @Query('limit') limit = '20') {
|
||||
return this.polimed.searchPatients(q, Number(limit) || 20);
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('appointments')
|
||||
appointments(@Query('date') date?: string) {
|
||||
return this.polimed.getAppointments(date);
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('appointments/:id')
|
||||
appointment(@Param('id') id: string) {
|
||||
return this.polimed.getAppointment(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PolimedClient } from './polimed.client';
|
||||
import { PolimedController } from './polimed.controller';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [PolimedController],
|
||||
providers: [PolimedClient],
|
||||
exports: [PolimedClient],
|
||||
})
|
||||
export class PolimedModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { PrismaClient } from '@reception/db';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
this.logger.log('Connected to PostgreSQL');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { IsISO8601, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { RecognitionService } from './recognition.service';
|
||||
|
||||
class ProbeDto {
|
||||
@IsString()
|
||||
frame!: string;
|
||||
|
||||
@IsString()
|
||||
cameraId!: string;
|
||||
|
||||
@IsISO8601()
|
||||
@Type(() => String)
|
||||
occurredAt!: string;
|
||||
}
|
||||
|
||||
@Controller('recognition')
|
||||
export class RecognitionController {
|
||||
constructor(private readonly recognition: RecognitionService) {}
|
||||
|
||||
// Public (нет JWT), но логируется как биометрический доступ.
|
||||
// В будущем заменим на внутренний service-to-service token.
|
||||
@Public()
|
||||
@LogsBiometry('recognition_probe')
|
||||
@Post('probe')
|
||||
probe(@Body() dto: ProbeDto) {
|
||||
return this.recognition.probe({
|
||||
frameBase64: dto.frame,
|
||||
cameraId: dto.cameraId,
|
||||
occurredAt: new Date(dto.occurredAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RecognitionController } from './recognition.controller';
|
||||
import { RecognitionService } from './recognition.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RecognitionController],
|
||||
providers: [RecognitionService],
|
||||
exports: [RecognitionService],
|
||||
})
|
||||
export class RecognitionModule {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { FaceClient } from '../face/face.client';
|
||||
import { PolimedClient } from '../polimed/polimed.client';
|
||||
|
||||
interface ProbeOpts {
|
||||
frameBase64: string;
|
||||
cameraId: string;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RecognitionService {
|
||||
private readonly logger = new Logger(RecognitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly face: FaceClient,
|
||||
private readonly polimed: PolimedClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Внутренний эндпоинт: вызывается fixtures-runner / video-ingest при появлении
|
||||
* лица. Идёт в face-service /recognize, при match создаёт Visit и
|
||||
* (опционально) подтягивает текущий appointment из Полимед.
|
||||
*
|
||||
* Возвращает информацию о созданном Visit или null если пациент не узнан.
|
||||
*/
|
||||
async probe(opts: ProbeOpts) {
|
||||
const match = await this.face.recognize(opts.frameBase64);
|
||||
if (!match) return null;
|
||||
|
||||
const patient = await this.prisma.patient.findUnique({ where: { id: match.patient_id } });
|
||||
if (!patient || patient.consentRevokedAt) {
|
||||
// Согласие отозвано — не создаём Visit, эмбеддинги скоро удалят.
|
||||
this.logger.warn(`Match patient_id=${match.patient_id} но пациент не найден / без согласия`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Подтягиваем appointments на сегодня и ищем подходящий по polimedPatientId.
|
||||
let polimedAppointmentId: string | null = null;
|
||||
if (patient.polimedPatientId) {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const appointments = await this.polimed.getAppointments(today);
|
||||
const ap = appointments.find((a) => a.patientId === patient.polimedPatientId);
|
||||
if (ap) polimedAppointmentId = ap.id;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Polimed lookup failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const visit = await this.prisma.visit.create({
|
||||
data: {
|
||||
patientId: patient.id,
|
||||
polimedAppointmentId,
|
||||
arrivedAt: opts.occurredAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (polimedAppointmentId) {
|
||||
this.polimed
|
||||
.pushVisitEvent(polimedAppointmentId, {
|
||||
type: 'arrived',
|
||||
occurredAt: opts.occurredAt.toISOString(),
|
||||
})
|
||||
.catch((err) => this.logger.warn(`Polimed pushVisitEvent failed: ${err}`));
|
||||
}
|
||||
|
||||
return {
|
||||
visitId: visit.id,
|
||||
patientId: patient.id,
|
||||
polimedAppointmentId,
|
||||
confidence: match.confidence,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { Role, TrackStatus } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { TracksService } from './tracks.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller('tracks')
|
||||
export class TracksController {
|
||||
constructor(private readonly tracks: TracksService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get()
|
||||
list(
|
||||
@Query('status') status?: TrackStatus,
|
||||
@Query('shiftDate') shiftDate?: string,
|
||||
) {
|
||||
return this.tracks.list({ status, shiftDate });
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@LogsBiometry('view_track')
|
||||
@Get(':id')
|
||||
getOne(@Param('id') id: string) {
|
||||
return this.tracks.getOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TracksController } from './tracks.controller';
|
||||
import { TracksService } from './tracks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TracksController],
|
||||
providers: [TracksService],
|
||||
exports: [TracksService],
|
||||
})
|
||||
export class TracksModule {}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TrackStatus } from '@reception/db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EvidenceService } from '../evidence/evidence.service';
|
||||
|
||||
interface ListTracksOpts {
|
||||
status?: TrackStatus;
|
||||
shiftDate?: string; // YYYY-MM-DD; если задан — firstSeenAt в этом дне
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TracksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly evidence: EvidenceService,
|
||||
) {}
|
||||
|
||||
async list(opts: ListTracksOpts) {
|
||||
const where: Parameters<PrismaService['track']['findMany']>[0] extends infer T
|
||||
? T extends { where?: infer W }
|
||||
? W
|
||||
: never
|
||||
: never = {};
|
||||
|
||||
if (opts.status) (where as { status?: TrackStatus }).status = opts.status;
|
||||
|
||||
if (opts.shiftDate) {
|
||||
const from = new Date(opts.shiftDate);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = new Date(opts.shiftDate);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
(where as { firstSeenAt?: object }).firstSeenAt = { gte: from, lte: to };
|
||||
}
|
||||
|
||||
const tracks = await this.prisma.track.findMany({
|
||||
where,
|
||||
orderBy: { firstSeenAt: 'desc' },
|
||||
include: {
|
||||
events: { orderBy: { occurredAt: 'asc' } },
|
||||
_count: { select: { faceEmbeddings: true } },
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
tracks.map(async (t) => ({
|
||||
id: t.id,
|
||||
status: t.status,
|
||||
firstSeenAt: t.firstSeenAt,
|
||||
lastSeenAt: t.lastSeenAt,
|
||||
patientId: t.patientId,
|
||||
embeddingsCount: t._count.faceEmbeddings,
|
||||
zonesPath: Array.from(new Set(t.events.map((e) => e.zoneId))),
|
||||
events: t.events.map((e) => ({
|
||||
type: e.type,
|
||||
cameraId: e.cameraId,
|
||||
zoneId: e.zoneId,
|
||||
occurredAt: e.occurredAt,
|
||||
evidenceKey: e.evidenceKey,
|
||||
})),
|
||||
thumbnailUrl: await this.firstEvidenceUrl(t.events),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async getOne(id: string) {
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
events: { orderBy: { occurredAt: 'asc' }, include: { zone: true, camera: true } },
|
||||
patient: true,
|
||||
},
|
||||
});
|
||||
if (!track) throw new NotFoundException(`Track ${id} not found`);
|
||||
|
||||
const eventsWithUrls = await Promise.all(
|
||||
track.events.map(async (e) => ({
|
||||
type: e.type,
|
||||
cameraId: e.cameraId,
|
||||
cameraName: e.camera.name,
|
||||
zoneCode: e.zone.code,
|
||||
occurredAt: e.occurredAt,
|
||||
evidenceKey: e.evidenceKey,
|
||||
evidenceUrl: e.evidenceKey ? await this.evidence.getPresignedUrl(e.evidenceKey) : null,
|
||||
faceBbox: e.faceBbox as { box: number[]; imgW: number; imgH: number } | null,
|
||||
})),
|
||||
);
|
||||
|
||||
const consistency = await this.computeEmbeddingConsistency(id);
|
||||
|
||||
return {
|
||||
id: track.id,
|
||||
status: track.status,
|
||||
firstSeenAt: track.firstSeenAt,
|
||||
lastSeenAt: track.lastSeenAt,
|
||||
patient: track.patient,
|
||||
events: eventsWithUrls,
|
||||
consistency,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Считает попарные cos-дистанции между всеми эмбеддингами трека (через pgvector `<=>`).
|
||||
* Возвращает min/max/avg + статус «один ли это человек».
|
||||
*
|
||||
* Пороги (эмпирические для InsightFace buffalo_l):
|
||||
* max ≤ 0.40 — definitely_same: один человек, ракурсы похожи.
|
||||
* 0.40 < max ≤ 0.55 — likely_same: один человек, но сильная вариация (поворот головы, очки).
|
||||
* max > 0.55 — suspicious: возможно, разные лица.
|
||||
*
|
||||
* REID_THRESHOLD (0.35) для consistency не подходит — он строже, рассчитан на склейку
|
||||
* РАЗНЫХ треков между камерами, а не на внутреннюю когерентность одного трека.
|
||||
*/
|
||||
private async computeEmbeddingConsistency(trackId: string): Promise<{
|
||||
count: number;
|
||||
pairs: number;
|
||||
minDistance: number | null;
|
||||
maxDistance: number | null;
|
||||
avgDistance: number | null;
|
||||
status: 'definitely_same' | 'likely_same' | 'suspicious';
|
||||
isCoherent: boolean;
|
||||
}> {
|
||||
const [{ count }] = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM face_embeddings
|
||||
WHERE track_id = ${trackId}::uuid
|
||||
`;
|
||||
const n = Number(count);
|
||||
|
||||
if (n < 2) {
|
||||
return {
|
||||
count: n,
|
||||
pairs: 0,
|
||||
minDistance: null,
|
||||
maxDistance: null,
|
||||
avgDistance: null,
|
||||
status: 'definitely_same',
|
||||
isCoherent: true,
|
||||
};
|
||||
}
|
||||
|
||||
const [stats] = await this.prisma.$queryRaw<
|
||||
Array<{ min_d: number; max_d: number; avg_d: number; pairs: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
MIN(d)::float AS min_d,
|
||||
MAX(d)::float AS max_d,
|
||||
AVG(d)::float AS avg_d,
|
||||
COUNT(*)::bigint AS pairs
|
||||
FROM (
|
||||
SELECT (a.embedding <=> b.embedding) AS d
|
||||
FROM face_embeddings a
|
||||
JOIN face_embeddings b ON a.id < b.id
|
||||
WHERE a.track_id = ${trackId}::uuid AND b.track_id = ${trackId}::uuid
|
||||
) p
|
||||
`;
|
||||
|
||||
const maxD = stats?.max_d ?? 1;
|
||||
const status: 'definitely_same' | 'likely_same' | 'suspicious' =
|
||||
maxD <= 0.4 ? 'definitely_same' : maxD <= 0.55 ? 'likely_same' : 'suspicious';
|
||||
|
||||
return {
|
||||
count: n,
|
||||
pairs: Number(stats?.pairs ?? 0),
|
||||
minDistance: stats?.min_d ?? null,
|
||||
maxDistance: maxD,
|
||||
avgDistance: stats?.avg_d ?? null,
|
||||
status,
|
||||
isCoherent: status !== 'suspicious',
|
||||
};
|
||||
}
|
||||
|
||||
private async firstEvidenceUrl(events: Array<{ evidenceKey: string | null }>): Promise<string | null> {
|
||||
const first = events.find((e) => e.evidenceKey);
|
||||
return first?.evidenceKey ? this.evidence.getPresignedUrl(first.evidenceKey) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { Role } from '@reception/db';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { LogsBiometry } from '../auth/decorators/logs-biometry.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@UseGuards(RolesGuard)
|
||||
@Controller()
|
||||
export class VisitsController {
|
||||
constructor(private readonly visits: VisitsService) {}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@Get('patients')
|
||||
listPatients() {
|
||||
return this.visits.listPatients();
|
||||
}
|
||||
|
||||
@Roles(Role.SENIOR_ADMIN, Role.MANAGER, Role.SYSADMIN)
|
||||
@LogsBiometry('view_patient_visits')
|
||||
@Get('patients/:patientId/visits')
|
||||
listVisits(@Param('patientId') patientId: string) {
|
||||
return this.visits.listForPatient(patientId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VisitsController } from './visits.controller';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@Module({
|
||||
controllers: [VisitsController],
|
||||
providers: [VisitsService],
|
||||
})
|
||||
export class VisitsModule {}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EvidenceService } from '../evidence/evidence.service';
|
||||
|
||||
export interface JourneySegment {
|
||||
zoneCode: string;
|
||||
cameraName: string;
|
||||
startedAt: Date;
|
||||
endedAt: Date;
|
||||
eventTypes: string[];
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VisitsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly evidence: EvidenceService,
|
||||
) {}
|
||||
|
||||
async listForPatient(patientId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
include: { visits: { orderBy: { arrivedAt: 'desc' } } },
|
||||
});
|
||||
if (!patient) throw new NotFoundException(`Patient ${patientId} not found`);
|
||||
|
||||
const [avatar, journey] = await Promise.all([
|
||||
this.getPatientAvatar(patientId),
|
||||
this.getPatientJourney(patientId),
|
||||
]);
|
||||
|
||||
return {
|
||||
patient: {
|
||||
id: patient.id,
|
||||
fullName: patient.fullName,
|
||||
polimedPatientId: patient.polimedPatientId,
|
||||
consentReceivedAt: patient.consentReceivedAt,
|
||||
consentRevokedAt: patient.consentRevokedAt,
|
||||
pendingDeletionAt: patient.pendingDeletionAt,
|
||||
avatarUrl: avatar?.url ?? null,
|
||||
avatarBbox: avatar?.bbox ?? null,
|
||||
},
|
||||
journey,
|
||||
visits: patient.visits.map((v) => ({
|
||||
id: v.id,
|
||||
arrivedAt: v.arrivedAt,
|
||||
serviceStartedAt: v.serviceStartedAt,
|
||||
serviceEndedAt: v.serviceEndedAt,
|
||||
leftWithoutService: v.leftWithoutService,
|
||||
polimedAppointmentId: v.polimedAppointmentId,
|
||||
waitingSec:
|
||||
v.serviceStartedAt && v.arrivedAt
|
||||
? Math.round((v.serviceStartedAt.getTime() - v.arrivedAt.getTime()) / 1000)
|
||||
: null,
|
||||
serviceSec:
|
||||
v.serviceEndedAt && v.serviceStartedAt
|
||||
? Math.round((v.serviceEndedAt.getTime() - v.serviceStartedAt.getTime()) / 1000)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listPatients() {
|
||||
const patients = await this.prisma.patient.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: { _count: { select: { visits: true } } },
|
||||
});
|
||||
|
||||
const rows = await Promise.all(
|
||||
patients.map(async (p) => {
|
||||
const avatar = await this.getPatientAvatar(p.id);
|
||||
return {
|
||||
id: p.id,
|
||||
fullName: p.fullName,
|
||||
polimedPatientId: p.polimedPatientId,
|
||||
consentReceivedAt: p.consentReceivedAt,
|
||||
consentRevokedAt: p.consentRevokedAt,
|
||||
pendingDeletionAt: p.pendingDeletionAt,
|
||||
visitsCount: p._count.visits,
|
||||
avatarUrl: avatar?.url ?? null,
|
||||
avatarBbox: avatar?.bbox ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Маршрут пациента: события всех его треков, сгруппированные по непрерывным
|
||||
* пребываниям в одной зоне. Считаем длительность каждого сегмента и суммы по зонам.
|
||||
* Эвристика "потерян": последний сегмент в зоне D, прошло >15 мин, нет последующих событий.
|
||||
*/
|
||||
private async getPatientJourney(patientId: string) {
|
||||
const events = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
track_id: string;
|
||||
type: string;
|
||||
occurred_at: Date;
|
||||
zone_code: string;
|
||||
camera_name: string;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
te.track_id::text AS track_id,
|
||||
te.type::text AS type,
|
||||
te.occurred_at,
|
||||
z.code::text AS zone_code,
|
||||
c.name AS camera_name
|
||||
FROM track_events te
|
||||
JOIN tracks t ON t.id = te.track_id
|
||||
JOIN zones z ON z.id = te.zone_id
|
||||
JOIN cameras c ON c.id = te.camera_id
|
||||
WHERE t.patient_id = ${patientId}::uuid
|
||||
ORDER BY te.occurred_at ASC
|
||||
`;
|
||||
|
||||
const segments: JourneySegment[] = [];
|
||||
for (const e of events) {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.zoneCode === e.zone_code && last.cameraName === e.camera_name) {
|
||||
last.endedAt = e.occurred_at;
|
||||
last.eventTypes.push(e.type);
|
||||
last.durationSec = Math.round((last.endedAt.getTime() - last.startedAt.getTime()) / 1000);
|
||||
} else {
|
||||
segments.push({
|
||||
zoneCode: e.zone_code,
|
||||
cameraName: e.camera_name,
|
||||
startedAt: e.occurred_at,
|
||||
endedAt: e.occurred_at,
|
||||
eventTypes: [e.type],
|
||||
durationSec: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byZone: Record<string, number> = {};
|
||||
for (const s of segments) {
|
||||
byZone[s.zoneCode] = (byZone[s.zoneCode] ?? 0) + s.durationSec;
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
let lostInTransit = false;
|
||||
if (lastSegment && lastSegment.zoneCode === 'D') {
|
||||
const ageMs = Date.now() - lastSegment.endedAt.getTime();
|
||||
if (ageMs > 15 * 60 * 1000) lostInTransit = true;
|
||||
}
|
||||
|
||||
return {
|
||||
segments,
|
||||
timeInZoneSec: byZone,
|
||||
lostInTransit,
|
||||
totalEvents: events.length,
|
||||
firstSeenAt: events[0]?.occurred_at ?? null,
|
||||
lastSeenAt: events[events.length - 1]?.occurred_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Аватар пациента — лучший кадр из одного из его треков.
|
||||
* Предпочитаем кадры с face_bbox (детектированное лицо).
|
||||
*/
|
||||
private async getPatientAvatar(
|
||||
patientId: string,
|
||||
): Promise<{ url: string; bbox: { box: number[]; imgW: number; imgH: number } | null } | null> {
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
Array<{ evidence_key: string; face_bbox: unknown }>
|
||||
>`
|
||||
SELECT te.evidence_key, te.face_bbox
|
||||
FROM track_events te
|
||||
JOIN tracks t ON t.id = te.track_id
|
||||
WHERE t.patient_id = ${patientId}::uuid
|
||||
AND te.evidence_key IS NOT NULL
|
||||
ORDER BY (te.face_bbox IS NULL) ASC, te.occurred_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
|
||||
const url = await this.evidence.getPresignedUrl(row.evidence_key);
|
||||
return {
|
||||
url,
|
||||
bbox:
|
||||
(row.face_bbox as { box: number[]; imgW: number; imgH: number } | null) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('Auth (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const SENIOR_EMAIL = 'senior@local';
|
||||
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.init();
|
||||
prisma = app.get(PrismaService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /health is public', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('GET /auth/me requires auth', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/auth/me');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /auth/login returns access cookie and userId', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.userId).toBeDefined();
|
||||
const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
expect(cookies.some((c) => c.startsWith('access_token='))).toBe(true);
|
||||
expect(cookies.some((c) => c.startsWith('refresh_token='))).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /auth/me with cookie returns user + role', async () => {
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
|
||||
const meRes = await request(app.getHttpServer())
|
||||
.get('/auth/me')
|
||||
.set('Cookie', cookies);
|
||||
|
||||
expect(meRes.status).toBe(200);
|
||||
expect(meRes.body.email).toBe(SENIOR_EMAIL);
|
||||
expect(meRes.body.role).toBe('SENIOR_ADMIN');
|
||||
});
|
||||
|
||||
it('POST /auth/login with bad password → 401', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: 'wrong-password' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /auth/logout clears tokens', async () => {
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
|
||||
const logoutRes = await request(app.getHttpServer())
|
||||
.post('/auth/logout')
|
||||
.set('Cookie', cookies);
|
||||
|
||||
expect(logoutRes.status).toBe(200);
|
||||
|
||||
// Используем тот же refresh — должен быть отозван.
|
||||
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='));
|
||||
expect(refreshCookie).toBeDefined();
|
||||
|
||||
const refreshRes = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', refreshCookie!);
|
||||
|
||||
expect(refreshRes.status).toBe(401);
|
||||
});
|
||||
|
||||
describe('refresh token rotation', () => {
|
||||
it('issues new tokens with valid refresh', async () => {
|
||||
const loginRes = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='))!;
|
||||
|
||||
const refreshRes = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', refreshCookie);
|
||||
|
||||
expect(refreshRes.status).toBe(200);
|
||||
const newCookies = (refreshRes.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
expect(newCookies.some((c) => c.startsWith('access_token='))).toBe(true);
|
||||
|
||||
// Старый refresh теперь невалиден — должен быть отозван.
|
||||
const reuseRes = await request(app.getHttpServer())
|
||||
.post('/auth/refresh')
|
||||
.set('Cookie', refreshCookie);
|
||||
expect(reuseRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* M6 acceptance test:
|
||||
* создаём фикстуру (track + embedding) →
|
||||
* POST /enrollment →
|
||||
* POST /consents/.../revoke с CONSENT_REVOKE_DELAY_MS=300 →
|
||||
* через ~600мс эмбеддинги удалены, трек ANONYMIZED, ФИО null,
|
||||
* в biometry_access_log полный след.
|
||||
*
|
||||
* FaceClient и PolimedClient замоканы. Redis/Postgres — реальные.
|
||||
*/
|
||||
// CONSENT_REVOKE_DELAY_MS=300 устанавливается в test/setup-env.ts ДО imports.
|
||||
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { TrackStatus } from '@reception/db';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import request from 'supertest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { FaceClient } from '../src/face/face.client';
|
||||
import { PolimedClient } from '../src/polimed/polimed.client';
|
||||
|
||||
const SENIOR_EMAIL = 'senior@local';
|
||||
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
|
||||
|
||||
class FaceClientMock {
|
||||
enrolledTracks: string[] = [];
|
||||
deletedPatients: string[] = [];
|
||||
countCalls = 0;
|
||||
|
||||
async recognize() {
|
||||
return null;
|
||||
}
|
||||
async enrollTrack(trackId: string, _patientId: string): Promise<number> {
|
||||
this.enrolledTracks.push(trackId);
|
||||
return 1;
|
||||
}
|
||||
async deletePatientEmbeddings(patientId: string): Promise<number> {
|
||||
this.deletedPatients.push(patientId);
|
||||
return 1;
|
||||
}
|
||||
async countPatientEmbeddings(_patientId: string): Promise<number> {
|
||||
this.countCalls += 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class PolimedClientMock {
|
||||
async searchPatients(q: string) {
|
||||
return [
|
||||
{ id: q, fullName: 'Иванов Иван Иванович', birthDate: '1980-01-01', phone: '+7900', cardNumber: 'K-MOCK' },
|
||||
];
|
||||
}
|
||||
async getAppointments() {
|
||||
return [];
|
||||
}
|
||||
async getAppointment(id: string) {
|
||||
return {
|
||||
id,
|
||||
patientId: 'pol-p-test',
|
||||
patientFullName: 'Иванов Иван Иванович',
|
||||
doctorFullName: 'Доктор Тест',
|
||||
specialty: 'Терапевт',
|
||||
scheduledFor: new Date().toISOString(),
|
||||
status: 'scheduled' as const,
|
||||
};
|
||||
}
|
||||
async pushVisitEvent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Enrollment + Consent revoke (e2e, M6)', () => {
|
||||
let app: INestApplication;
|
||||
let prisma: PrismaService;
|
||||
let face: FaceClientMock;
|
||||
|
||||
let cameraId: string;
|
||||
let zoneId: string;
|
||||
let trackId: string;
|
||||
let embeddingId: string;
|
||||
const polimedPatientId = `pol-p-test-${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(FaceClient)
|
||||
.useClass(FaceClientMock)
|
||||
.overrideProvider(PolimedClient)
|
||||
.useClass(PolimedClientMock)
|
||||
.compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.init();
|
||||
|
||||
prisma = app.get(PrismaService);
|
||||
face = app.get(FaceClient) as unknown as FaceClientMock;
|
||||
|
||||
// Готовим зону, камеру, трек, фейковый эмбеддинг.
|
||||
const zone = await prisma.zone.findUniqueOrThrow({ where: { code: 'A' } });
|
||||
zoneId = zone.id;
|
||||
const camera = await prisma.camera.create({
|
||||
data: { name: `test-cam-${Date.now()}`, zoneId },
|
||||
});
|
||||
cameraId = camera.id;
|
||||
trackId = randomUUID();
|
||||
embeddingId = randomUUID();
|
||||
await prisma.track.create({
|
||||
data: {
|
||||
id: trackId,
|
||||
firstSeenAt: new Date(Date.now() - 60_000),
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Прямой INSERT эмбеддинга через $executeRawUnsafe (синтетический 512-d).
|
||||
const embeddingLiteral = '[' + Array.from({ length: 512 }, () => Math.random()).join(',') + ']';
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO face_embeddings (id, embedding, track_id, camera_id, quality, captured_at, created_at)
|
||||
VALUES ($1::uuid, $2::vector, $3::uuid, $4::uuid, $5, NOW(), NOW())`,
|
||||
embeddingId,
|
||||
embeddingLiteral,
|
||||
trackId,
|
||||
cameraId,
|
||||
0.9,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM face_embeddings WHERE track_id = $1::uuid`, trackId);
|
||||
await prisma.visit.deleteMany({ where: { patient: { polimedPatientId } } });
|
||||
await prisma.patientConsent.deleteMany({ where: { patient: { polimedPatientId } } });
|
||||
await prisma.consentRevocationJob.deleteMany({ where: { patient: { polimedPatientId } } });
|
||||
await prisma.biometryAccessLog.deleteMany({ where: { subjectPatientId: { not: null } } });
|
||||
await prisma.track.deleteMany({ where: { id: trackId } });
|
||||
await prisma.patient.deleteMany({ where: { polimedPatientId } });
|
||||
await prisma.camera.deleteMany({ where: { id: cameraId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function loginSenior(): Promise<string[]> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
return (res.headers['set-cookie'] as unknown as string[]) ?? [];
|
||||
}
|
||||
|
||||
it('full enrollment + revoke flow', async () => {
|
||||
const cookies = await loginSenior();
|
||||
|
||||
// 1. Enrollment.
|
||||
const enrollRes = await request(app.getHttpServer())
|
||||
.post('/enrollment')
|
||||
.set('Cookie', cookies)
|
||||
.send({
|
||||
trackId,
|
||||
polimedPatientId,
|
||||
polimedAppointmentId: 'pol-a-test',
|
||||
paperConsentRef: 'paper-ref-001',
|
||||
});
|
||||
|
||||
expect(enrollRes.status).toBe(201);
|
||||
expect(enrollRes.body.patientId).toBeDefined();
|
||||
expect(enrollRes.body.visitId).toBeDefined();
|
||||
const patientId: string = enrollRes.body.patientId;
|
||||
|
||||
// FaceClient.enrollTrack был вызван.
|
||||
expect(face.enrolledTracks).toContain(trackId);
|
||||
|
||||
// Проверяем DB-состояние после enrollment.
|
||||
const patientAfterEnroll = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
|
||||
expect(patientAfterEnroll.polimedPatientId).toBe(polimedPatientId);
|
||||
expect(patientAfterEnroll.consentReceivedAt).not.toBeNull();
|
||||
|
||||
const trackAfterEnroll = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
|
||||
expect(trackAfterEnroll.patientId).toBe(patientId);
|
||||
expect(trackAfterEnroll.status).toBe(TrackStatus.MATCHED);
|
||||
|
||||
const grantedConsent = await prisma.patientConsent.findFirst({
|
||||
where: { patientId, action: 'GRANTED' },
|
||||
});
|
||||
expect(grantedConsent).not.toBeNull();
|
||||
expect(grantedConsent?.paperRef).toBe('paper-ref-001');
|
||||
|
||||
// 2. Revoke consent.
|
||||
const revokeRes = await request(app.getHttpServer())
|
||||
.post(`/consents/${patientId}/revoke`)
|
||||
.set('Cookie', cookies);
|
||||
expect(revokeRes.status).toBe(202);
|
||||
expect(revokeRes.body.jobId).toBeDefined();
|
||||
|
||||
// Сразу после revoke — consentRevokedAt и pendingDeletionAt установлены.
|
||||
const patientAfterRevoke = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
|
||||
expect(patientAfterRevoke.consentRevokedAt).not.toBeNull();
|
||||
expect(patientAfterRevoke.pendingDeletionAt).not.toBeNull();
|
||||
|
||||
// 3. Ждём срабатывания BullMQ (delay=300мс + обработка).
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// 4. Проверяем результат отложенной задачи.
|
||||
expect(face.deletedPatients).toContain(patientId);
|
||||
|
||||
const patientFinal = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } });
|
||||
expect(patientFinal.fullName).toBeNull();
|
||||
expect(patientFinal.pendingDeletionAt).toBeNull();
|
||||
|
||||
const trackFinal = await prisma.track.findUniqueOrThrow({ where: { id: trackId } });
|
||||
expect(trackFinal.status).toBe(TrackStatus.ANONYMIZED);
|
||||
|
||||
const job = await prisma.consentRevocationJob.findFirstOrThrow({ where: { patientId } });
|
||||
expect(job.status).toBe('DONE');
|
||||
expect(job.completedAt).not.toBeNull();
|
||||
|
||||
// 5. Полный аудит-след в biometry_access_log.
|
||||
const logs = await prisma.biometryAccessLog.findMany({
|
||||
where: { subjectPatientId: patientId },
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
});
|
||||
const actions = logs.map((l) => l.action);
|
||||
expect(actions).toContain('enroll');
|
||||
expect(actions).toContain('consent_revoke');
|
||||
expect(actions).toContain('consent_revocation_completed');
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { config as dotenv } from 'dotenv';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Загружаем корневой .env для e2e-тестов.
|
||||
dotenv({ path: join(__dirname, '..', '..', '..', '.env') });
|
||||
|
||||
// В e2e ускоряем delay очереди до 300мс (иначе тест M6 не дождётся 24 ч).
|
||||
// override=true потому что .env может уже содержать прод-значение.
|
||||
process.env.CONSENT_REVOKE_DELAY_MS = '300';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@reception/tsconfig/nest.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/reception
|
||||
MODEL_NAME=buffalo_l
|
||||
DET_SCORE_THRESHOLD=0.7
|
||||
RECOGNITION_THRESHOLD=0.5
|
||||
REID_THRESHOLD=0.35
|
||||
REID_WINDOW_MINUTES=5
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender1 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8001/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# face-service
|
||||
|
||||
Python + FastAPI + InsightFace `buffalo_l`. Считает 512-d L2-нормализованные эмбеддинги лиц, хранит в pgvector, делает cross-camera re-id и узнавание пациентов.
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|---|---|---|
|
||||
| `GET` | `/health` | Статус + флаг загрузки модели |
|
||||
| `POST` | `/embed` | Только эмбеддинг (без БД). Body: `{frame: base64}` |
|
||||
| `POST` | `/track-embeddings` | Сохранить эмбеддинг с привязкой к треку/камере |
|
||||
| `POST` | `/reid/search` | Cross-camera re-id (top-K в окне T мин) |
|
||||
| `POST` | `/recognize` | Узнать пациента (`patient_id`) по кадру |
|
||||
| `POST` | `/enroll` | Привязать эмбеддинги трека к пациенту |
|
||||
| `DELETE` | `/patient/{id}/embeddings` | Удалить эмбеддинги пациента (отзыв согласия) |
|
||||
| `GET` | `/patient/{id}/count` | Кол-во эмбеддингов у пациента |
|
||||
|
||||
## Запуск (dev)
|
||||
|
||||
```bash
|
||||
cd apps/face-service
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # либо использовать корневой .env
|
||||
uvicorn main:app --reload --port 8001
|
||||
```
|
||||
|
||||
## Запуск (docker)
|
||||
|
||||
```bash
|
||||
docker build -t reception/face-service .
|
||||
docker run --rm -p 8001:8001 \
|
||||
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5434/reception \
|
||||
reception/face-service
|
||||
```
|
||||
|
||||
## Пороги (ТЗ §4.3)
|
||||
|
||||
- `RECOGNITION_THRESHOLD=0.5` — узнавание уже зарегистрированного пациента.
|
||||
- `REID_THRESHOLD=0.35` — склейка треков между камерами (строже, иначе ложные склейки).
|
||||
|
||||
Тюним после baseline-замеров на проде.
|
||||
|
||||
## Источник
|
||||
|
||||
Скопировано и расширено из `work-pcs-adm-time-tracker/apps/face-service/`. Изменения:
|
||||
|
||||
- Новая схема `face_embeddings` (track_id, camera_id, patient_id, quality, captured_at) — управляется через Prisma в `packages/db`.
|
||||
- Добавлены функции `save_embedding_with_meta`, `attach_track_to_patient`, `find_topk_in_window`, `find_nearest_patient`, `delete_patient_embeddings`.
|
||||
- Эндпоинты `/embed`, `/track-embeddings`, `/reid/search`, `/enroll`, `/patient/.../embeddings` — новые.
|
||||
- Эндпоинт `/recognize` теперь работает только с эмбеддингами с проставленным `patient_id` (т.е. с согласием).
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Прямой доступ к pgvector через psycopg2.
|
||||
|
||||
Расширение time-tracker/apps/face-service/database.py для домена «Цифровой
|
||||
рецепции»: новая схема (patient_id, track_id, camera_id, quality, captured_at),
|
||||
cross-camera re-id (top-K в окне T минут с фильтром по камерам),
|
||||
поиск пациента (только эмбеддинги с patient_id IS NOT NULL).
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from pgvector.psycopg2 import register_vector
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://postgres:postgres@localhost:5434/reception",
|
||||
)
|
||||
|
||||
|
||||
def get_connection():
|
||||
conn = psycopg2.connect(DATABASE_URL)
|
||||
register_vector(conn)
|
||||
return conn
|
||||
|
||||
|
||||
# ---------- WRITE ----------
|
||||
|
||||
def save_embedding_with_meta(
|
||||
embedding,
|
||||
track_id: str,
|
||||
camera_id: str,
|
||||
quality: float,
|
||||
captured_at: datetime | None = None,
|
||||
patient_id: str | None = None,
|
||||
) -> str:
|
||||
"""Сохраняет эмбеддинг с привязкой к треку/камере/пациенту.
|
||||
|
||||
Возвращает id записи. captured_at по умолчанию — now().
|
||||
"""
|
||||
record_id = str(uuid.uuid4())
|
||||
captured_at = captured_at or datetime.utcnow()
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO face_embeddings
|
||||
(id, embedding, track_id, camera_id, quality, patient_id, captured_at, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
""",
|
||||
(record_id, embedding, track_id, camera_id, quality, patient_id, captured_at),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return record_id
|
||||
|
||||
|
||||
def attach_track_to_patient(track_id: str, patient_id: str) -> int:
|
||||
"""Привязывает все эмбеддинги трека к пациенту. Возвращает кол-во затронутых строк."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE face_embeddings SET patient_id = %s WHERE track_id = %s",
|
||||
(patient_id, track_id),
|
||||
)
|
||||
affected = cur.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return affected
|
||||
|
||||
|
||||
# ---------- READ ----------
|
||||
|
||||
def find_topk_in_window(
|
||||
embedding,
|
||||
camera_id: str,
|
||||
window_minutes: int = 5,
|
||||
k: int = 5,
|
||||
exclude_same_camera: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Top-K ближайших эмбеддингов с других камер в окне последних window_minutes минут.
|
||||
|
||||
Используется для cross-camera re-id: на новой камере появился человек,
|
||||
ищем тот же эмбеддинг с другой камеры в недавнем прошлом → склейка треков.
|
||||
"""
|
||||
since = datetime.utcnow() - timedelta(minutes=window_minutes)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
sql = """
|
||||
SELECT fe.track_id, fe.camera_id, fe.captured_at,
|
||||
(fe.embedding <=> %s::vector) AS distance
|
||||
FROM face_embeddings fe
|
||||
WHERE fe.captured_at >= %s
|
||||
AND fe.track_id IS NOT NULL
|
||||
"""
|
||||
params: list[Any] = [embedding, since]
|
||||
|
||||
if exclude_same_camera:
|
||||
sql += " AND fe.camera_id <> %s"
|
||||
params.append(camera_id)
|
||||
|
||||
sql += " ORDER BY distance ASC LIMIT %s"
|
||||
params.append(k)
|
||||
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"track_id": str(r["track_id"]),
|
||||
"camera_id": str(r["camera_id"]),
|
||||
"captured_at": r["captured_at"].isoformat(),
|
||||
"distance": float(r["distance"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def find_nearest_patient(embedding, threshold: float) -> dict[str, Any] | None:
|
||||
"""Узнать пациента по лицу: ищет ближайший эмбеддинг среди записей,
|
||||
у которых patient_id IS NOT NULL (т.е. дано согласие).
|
||||
|
||||
Возвращает {patient_id, confidence, distance} или None.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT fe.patient_id, (fe.embedding <=> %s::vector) AS distance
|
||||
FROM face_embeddings fe
|
||||
WHERE fe.patient_id IS NOT NULL
|
||||
ORDER BY distance ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(embedding,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
distance = float(row["distance"])
|
||||
if distance > threshold:
|
||||
return None
|
||||
|
||||
confidence = round(max(0.0, 1.0 - (distance / threshold)), 3)
|
||||
return {
|
||||
"patient_id": str(row["patient_id"]),
|
||||
"confidence": confidence,
|
||||
"distance": distance,
|
||||
}
|
||||
|
||||
|
||||
# ---------- DELETE ----------
|
||||
|
||||
def delete_patient_embeddings(patient_id: str) -> int:
|
||||
"""Удаляет все эмбеддинги пациента. Возвращает кол-во удалённых.
|
||||
|
||||
Вызывается при отзыве согласия (через 24 ч).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM face_embeddings WHERE patient_id = %s",
|
||||
(patient_id,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return deleted
|
||||
|
||||
|
||||
def count_patient_embeddings(patient_id: str) -> int:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM face_embeddings WHERE patient_id = %s",
|
||||
(patient_id,),
|
||||
)
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""InsightFace wrapper: load model, decode images, extract 512-d embeddings.
|
||||
|
||||
Скопировано и расширено из work-pcs-adm-time-tracker. Импорты InsightFace и PIL
|
||||
сделаны ленивыми — face-service может запускаться без них (SKIP_MODEL_LOAD=true)
|
||||
для интеграционных тестов raw-embedding эндпоинтов.
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_NAME = os.getenv("MODEL_NAME", "buffalo_l")
|
||||
DET_SCORE_THRESHOLD = float(os.getenv("DET_SCORE_THRESHOLD", "0.7"))
|
||||
|
||||
_app = None # FaceAnalysis | None — ленивый импорт.
|
||||
|
||||
|
||||
def load_model():
|
||||
global _app
|
||||
if _app is not None:
|
||||
return _app
|
||||
from insightface.app import FaceAnalysis # ленивый импорт
|
||||
logger.info(f"Загружаю модель InsightFace '{MODEL_NAME}'...")
|
||||
app = FaceAnalysis(name=MODEL_NAME, providers=["CPUExecutionProvider"])
|
||||
app.prepare(ctx_id=0, det_thresh=DET_SCORE_THRESHOLD, det_size=(640, 640))
|
||||
_app = app
|
||||
logger.info("Модель загружена.")
|
||||
return _app
|
||||
|
||||
|
||||
def decode_image(base64_str: str) -> np.ndarray:
|
||||
"""Декодирует base64-строку в numpy-массив (BGR, формат OpenCV)."""
|
||||
from PIL import Image # ленивый импорт
|
||||
if "," in base64_str:
|
||||
base64_str = base64_str.split(",", 1)[1]
|
||||
|
||||
image_bytes = base64.b64decode(base64_str)
|
||||
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
img_array = np.array(image)
|
||||
return img_array[:, :, ::-1].copy()
|
||||
|
||||
|
||||
def detect_best_face(base64_str: str):
|
||||
"""Возвращает (embedding, quality, bbox_norm) лучшего лица или (None, None, None).
|
||||
|
||||
bbox_norm — [x1, y1, x2, y2] в нормализованных 0..1 координатах относительно
|
||||
размера изображения. UI рисует overlay поверх displayed image.
|
||||
"""
|
||||
app = load_model()
|
||||
|
||||
try:
|
||||
img = decode_image(base64_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка декодирования изображения: {e}")
|
||||
return None, None, None
|
||||
|
||||
faces = app.get(img)
|
||||
if not faces:
|
||||
return None, None, None
|
||||
|
||||
best_face = max(faces, key=lambda f: f.det_score)
|
||||
if best_face.det_score < DET_SCORE_THRESHOLD:
|
||||
return None, None, None
|
||||
|
||||
embedding = best_face.normed_embedding.astype(np.float32)
|
||||
quality = float(best_face.det_score)
|
||||
|
||||
h, w = img.shape[:2]
|
||||
box = best_face.bbox.tolist() # [x1, y1, x2, y2] в пикселях
|
||||
bbox = {
|
||||
"box": [
|
||||
max(0, int(box[0])),
|
||||
max(0, int(box[1])),
|
||||
min(w, int(box[2])),
|
||||
min(h, int(box[3])),
|
||||
],
|
||||
"imgW": w,
|
||||
"imgH": h,
|
||||
}
|
||||
return embedding, quality, bbox
|
||||
|
||||
|
||||
def get_embedding(base64_str: str) -> np.ndarray | None:
|
||||
"""Обратная совместимость: только эмбеддинг лучшего лица."""
|
||||
embedding, _, _ = detect_best_face(base64_str)
|
||||
return embedding
|
||||
|
||||
|
||||
def is_model_loaded() -> bool:
|
||||
return _app is not None
|
||||
@@ -0,0 +1,288 @@
|
||||
"""face-service v2 для проекта «Цифровая рецепция».
|
||||
|
||||
Расширение time-tracker face-service: добавлены эндпоинты cross-camera re-id,
|
||||
сохранения эмбеддингов с метаданными трека/камеры, узнавания пациента
|
||||
(только среди эмбеддингов с согласием), удаления при отзыве согласия.
|
||||
|
||||
Эндпоинты:
|
||||
GET /health — статус + флаг loaded
|
||||
POST /embed — только эмбеддинг кадра (без БД)
|
||||
POST /track-embeddings — сохранить эмбеддинг с привязкой к треку
|
||||
POST /reid/search — cross-camera re-id (top-K в окне T мин)
|
||||
POST /recognize — узнать пациента (patient_id) по кадру
|
||||
POST /enroll — привязать эмбеддинги трека к пациенту
|
||||
DELETE /patient/{patient_id}/embeddings — удалить все эмбеддинги пациента
|
||||
GET /patient/{patient_id}/count — кол-во эмбеддингов у пациента
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from database import (
|
||||
save_embedding_with_meta,
|
||||
attach_track_to_patient,
|
||||
find_topk_in_window,
|
||||
find_nearest_patient,
|
||||
delete_patient_embeddings,
|
||||
count_patient_embeddings,
|
||||
)
|
||||
from face_engine import detect_best_face, load_model, is_model_loaded
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Порог узнавания пациента по лицу (ТЗ §4.3 — после первой партии данных тюним).
|
||||
RECOGNITION_THRESHOLD = float(os.getenv("RECOGNITION_THRESHOLD", "0.5"))
|
||||
# Порог склейки треков cross-camera (строже — иначе ложные склейки).
|
||||
REID_THRESHOLD = float(os.getenv("REID_THRESHOLD", "0.35"))
|
||||
# Окно для cross-camera re-id (минуты).
|
||||
DEFAULT_REID_WINDOW_MIN = int(os.getenv("REID_WINDOW_MINUTES", "5"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if os.getenv("SKIP_MODEL_LOAD", "false").lower() == "true":
|
||||
logger.warning("SKIP_MODEL_LOAD=true — модель не загружается, frame-эндпоинты вернут 503")
|
||||
yield
|
||||
return
|
||||
try:
|
||||
load_model()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"Не удалось загрузить InsightFace: {e}. Frame-эндпоинты не будут работать.")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="reception/face-service", version="0.2.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ---------- Схемы ----------
|
||||
|
||||
class EmbedRequest(BaseModel):
|
||||
frame: str = Field(..., description="base64-encoded JPEG/PNG")
|
||||
|
||||
|
||||
class FaceBbox(BaseModel):
|
||||
box: list[int] # [x1, y1, x2, y2] в пикселях
|
||||
imgW: int
|
||||
imgH: int
|
||||
|
||||
|
||||
class EmbedResponse(BaseModel):
|
||||
embedding: list[float]
|
||||
quality: float
|
||||
bbox: FaceBbox | None = None
|
||||
|
||||
|
||||
class TrackEmbeddingRequest(BaseModel):
|
||||
frame: str
|
||||
track_id: str
|
||||
camera_id: str
|
||||
captured_at: datetime | None = None
|
||||
patient_id: str | None = None
|
||||
|
||||
|
||||
class TrackEmbeddingResponse(BaseModel):
|
||||
id: str
|
||||
quality: float
|
||||
bbox: FaceBbox | None = None
|
||||
|
||||
|
||||
class ReidSearchRequest(BaseModel):
|
||||
frame: str | None = None
|
||||
embedding: list[float] | None = None
|
||||
camera_id: str
|
||||
window_minutes: int | None = None
|
||||
k: int = 5
|
||||
exclude_same_camera: bool = True
|
||||
|
||||
|
||||
class ReidMatch(BaseModel):
|
||||
track_id: str
|
||||
camera_id: str
|
||||
captured_at: str
|
||||
distance: float
|
||||
|
||||
|
||||
class ReidSearchResponse(BaseModel):
|
||||
matches: list[ReidMatch]
|
||||
threshold: float
|
||||
|
||||
|
||||
class RecognizeRequest(BaseModel):
|
||||
frame: str
|
||||
|
||||
|
||||
class RecognizeResponse(BaseModel):
|
||||
patient_id: str
|
||||
confidence: float
|
||||
distance: float
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
track_id: str
|
||||
patient_id: str
|
||||
|
||||
|
||||
class EnrollResponse(BaseModel):
|
||||
ok: bool
|
||||
embeddings_attached: int
|
||||
|
||||
|
||||
class TrackEmbeddingRawRequest(BaseModel):
|
||||
"""Сохранить готовый эмбеддинг без детекции лица — для fixtures-runner."""
|
||||
embedding: list[float]
|
||||
track_id: str
|
||||
camera_id: str
|
||||
captured_at: datetime | None = None
|
||||
patient_id: str | None = None
|
||||
quality: float = 0.9
|
||||
|
||||
|
||||
class RecognizeEmbeddingRequest(BaseModel):
|
||||
"""Узнать пациента по готовому эмбеддингу — для fixtures-runner."""
|
||||
embedding: list[float]
|
||||
|
||||
|
||||
# ---------- Эндпоинты ----------
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"model_loaded": is_model_loaded(),
|
||||
"recognition_threshold": RECOGNITION_THRESHOLD,
|
||||
"reid_threshold": REID_THRESHOLD,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/embed", response_model=Optional[EmbedResponse])
|
||||
def embed(req: EmbedRequest):
|
||||
"""Возвращает 512-d эмбеддинг лучшего лица на кадре, без записи в БД."""
|
||||
embedding, quality, bbox = detect_best_face(req.frame)
|
||||
if embedding is None:
|
||||
return None
|
||||
return EmbedResponse(embedding=embedding.tolist(), quality=quality, bbox=bbox)
|
||||
|
||||
|
||||
@app.post("/track-embeddings", response_model=Optional[TrackEmbeddingResponse])
|
||||
def store_track_embedding(req: TrackEmbeddingRequest):
|
||||
"""Сохраняет эмбеддинг с привязкой к треку (используется video-ingest/fixtures)."""
|
||||
embedding, quality, bbox = detect_best_face(req.frame)
|
||||
if embedding is None:
|
||||
return None
|
||||
|
||||
record_id = save_embedding_with_meta(
|
||||
embedding=embedding,
|
||||
track_id=req.track_id,
|
||||
camera_id=req.camera_id,
|
||||
quality=quality,
|
||||
captured_at=req.captured_at,
|
||||
patient_id=req.patient_id,
|
||||
)
|
||||
return TrackEmbeddingResponse(id=record_id, quality=quality, bbox=bbox)
|
||||
|
||||
|
||||
@app.post("/reid/search", response_model=ReidSearchResponse)
|
||||
def reid_search(req: ReidSearchRequest):
|
||||
"""Cross-camera re-id: ищет top-K ближайших эмбеддингов с других камер в окне T мин."""
|
||||
if req.embedding is None and req.frame is None:
|
||||
raise HTTPException(400, "Нужен либо frame, либо embedding")
|
||||
|
||||
if req.embedding is not None:
|
||||
embedding = req.embedding
|
||||
else:
|
||||
embedding, _quality, _bbox = detect_best_face(req.frame)
|
||||
if embedding is None:
|
||||
return ReidSearchResponse(matches=[], threshold=REID_THRESHOLD)
|
||||
embedding = embedding.tolist()
|
||||
|
||||
window = req.window_minutes or DEFAULT_REID_WINDOW_MIN
|
||||
matches = find_topk_in_window(
|
||||
embedding=embedding,
|
||||
camera_id=req.camera_id,
|
||||
window_minutes=window,
|
||||
k=req.k,
|
||||
exclude_same_camera=req.exclude_same_camera,
|
||||
)
|
||||
|
||||
return ReidSearchResponse(
|
||||
matches=[ReidMatch(**m) for m in matches if m["distance"] <= REID_THRESHOLD],
|
||||
threshold=REID_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/recognize", response_model=Optional[RecognizeResponse])
|
||||
def recognize(req: RecognizeRequest):
|
||||
"""Узнавание пациента: ищет ближайший эмбеддинг среди записей с patient_id."""
|
||||
embedding, _quality, _bbox = detect_best_face(req.frame)
|
||||
if embedding is None:
|
||||
return None
|
||||
|
||||
result = find_nearest_patient(embedding, threshold=RECOGNITION_THRESHOLD)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
return RecognizeResponse(**result)
|
||||
|
||||
|
||||
@app.post("/track-embeddings/raw", response_model=TrackEmbeddingResponse)
|
||||
def store_raw_track_embedding(req: TrackEmbeddingRawRequest):
|
||||
"""Сохранить эмбеддинг без детекции лица. Использует fixtures-runner с
|
||||
синтетическими векторами; в продовом потоке не используется."""
|
||||
record_id = save_embedding_with_meta(
|
||||
embedding=req.embedding,
|
||||
track_id=req.track_id,
|
||||
camera_id=req.camera_id,
|
||||
quality=req.quality,
|
||||
captured_at=req.captured_at,
|
||||
patient_id=req.patient_id,
|
||||
)
|
||||
return TrackEmbeddingResponse(id=record_id, quality=req.quality)
|
||||
|
||||
|
||||
@app.post("/recognize/embedding", response_model=Optional[RecognizeResponse])
|
||||
def recognize_by_embedding(req: RecognizeEmbeddingRequest):
|
||||
"""Узнать пациента по готовому эмбеддингу (для fixtures-runner)."""
|
||||
result = find_nearest_patient(req.embedding, threshold=RECOGNITION_THRESHOLD)
|
||||
if result is None:
|
||||
return None
|
||||
return RecognizeResponse(**result)
|
||||
|
||||
|
||||
@app.post("/enroll", response_model=EnrollResponse)
|
||||
def enroll(req: EnrollRequest):
|
||||
"""Привязывает все эмбеддинги трека к пациенту (после согласия)."""
|
||||
affected = attach_track_to_patient(req.track_id, req.patient_id)
|
||||
if affected == 0:
|
||||
raise HTTPException(404, f"Не найдено эмбеддингов для трека {req.track_id}")
|
||||
logger.info(f"Enroll: трек {req.track_id} → пациент {req.patient_id} ({affected} эмбеддингов)")
|
||||
return EnrollResponse(ok=True, embeddings_attached=affected)
|
||||
|
||||
|
||||
@app.delete("/patient/{patient_id}/embeddings")
|
||||
def delete_embeddings(patient_id: str):
|
||||
deleted = delete_patient_embeddings(patient_id)
|
||||
logger.info(f"Отозвано согласие пациента {patient_id}: удалено {deleted} эмбеддингов")
|
||||
return {"ok": True, "deleted": deleted}
|
||||
|
||||
|
||||
@app.get("/patient/{patient_id}/count")
|
||||
def patient_count(patient_id: str):
|
||||
count = count_patient_embeddings(patient_id)
|
||||
return {"patient_id": patient_id, "count": count}
|
||||
@@ -0,0 +1,22 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
insightface==0.7.3
|
||||
onnxruntime==1.24.1
|
||||
opencv-python-headless==4.11.0.86
|
||||
numpy==2.2.3
|
||||
psycopg2-binary==2.9.11
|
||||
pgvector==0.3.6
|
||||
python-dotenv==1.0.1
|
||||
Pillow==12.1.0
|
||||
onnx
|
||||
tqdm
|
||||
prettytable
|
||||
requests
|
||||
scipy
|
||||
scikit-learn
|
||||
scikit-image
|
||||
matplotlib
|
||||
albumentations
|
||||
easydict
|
||||
cython
|
||||
pytest==8.3.4
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Pytest-фикстуры для face-service.
|
||||
|
||||
Подключение к локальному pgvector через DATABASE_URL из корневого .env.
|
||||
Тесты re-id логики работают на чистых эмбеддингах — модель InsightFace не нужна.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Подгружаем корневой .env и .env сервиса.
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Импортируем после load_dotenv, чтобы DATABASE_URL подцепился.
|
||||
from database import get_connection # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn():
|
||||
conn = get_connection()
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_camera_and_track(db_conn):
|
||||
"""Создаёт уникальные camera_id + track_id для теста и убирает после."""
|
||||
camera_ids = []
|
||||
track_ids = []
|
||||
|
||||
def _make(zone_code: str = "A"):
|
||||
cam_id = str(uuid.uuid4())
|
||||
zone_id = str(uuid.uuid4())
|
||||
track_id = str(uuid.uuid4())
|
||||
with db_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO zones (id, code, name) VALUES (%s, %s::\"ZoneCode\", %s)"
|
||||
" ON CONFLICT (code) DO NOTHING RETURNING id",
|
||||
(zone_id, zone_code, f"test-zone-{zone_code}"),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
cur.execute('SELECT id FROM zones WHERE code = %s::"ZoneCode"', (zone_code,))
|
||||
zone_id = str(cur.fetchone()[0])
|
||||
cur.execute(
|
||||
"INSERT INTO cameras (id, name, zone_id) VALUES (%s, %s, %s)",
|
||||
(cam_id, f"test-cam-{cam_id[:6]}", zone_id),
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO tracks (id, status, first_seen_at, last_seen_at, updated_at)"
|
||||
" VALUES (%s, 'UNMATCHED', NOW(), NOW(), NOW())",
|
||||
(track_id,),
|
||||
)
|
||||
db_conn.commit()
|
||||
camera_ids.append(cam_id)
|
||||
track_ids.append(track_id)
|
||||
return cam_id, track_id
|
||||
|
||||
yield _make
|
||||
|
||||
# Cleanup
|
||||
with db_conn.cursor() as cur:
|
||||
if track_ids:
|
||||
cur.execute(
|
||||
"DELETE FROM face_embeddings WHERE track_id = ANY(%s::uuid[])",
|
||||
(track_ids,),
|
||||
)
|
||||
cur.execute("DELETE FROM tracks WHERE id = ANY(%s::uuid[])", (track_ids,))
|
||||
if camera_ids:
|
||||
cur.execute("DELETE FROM cameras WHERE id = ANY(%s::uuid[])", (camera_ids,))
|
||||
db_conn.commit()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Тесты cross-camera re-id логики.
|
||||
|
||||
Используем синтетические 512-мерные эмбеддинги (без InsightFace).
|
||||
Проверяем, что find_topk_in_window:
|
||||
1. Возвращает соседей в правильном порядке по cos-дистанции.
|
||||
2. Фильтрует по camera_id (исключает ту же камеру).
|
||||
3. Фильтрует по временному окну.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from database import (
|
||||
save_embedding_with_meta,
|
||||
find_topk_in_window,
|
||||
find_nearest_patient,
|
||||
attach_track_to_patient,
|
||||
delete_patient_embeddings,
|
||||
)
|
||||
|
||||
|
||||
def normed(vec: np.ndarray) -> np.ndarray:
|
||||
return (vec / np.linalg.norm(vec)).astype(np.float32)
|
||||
|
||||
|
||||
def make_embedding(seed: int) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
return normed(rng.standard_normal(512))
|
||||
|
||||
|
||||
def test_topk_in_window_basic(seed_camera_and_track):
|
||||
"""Из 3 эмбеддингов на 3 разных камерах находим 2 ближайших к query (исключая саму камеру query)."""
|
||||
cam_a, track_a = seed_camera_and_track("A")
|
||||
cam_b, track_b = seed_camera_and_track("B")
|
||||
cam_c, track_c = seed_camera_and_track("C")
|
||||
|
||||
base = make_embedding(seed=42)
|
||||
|
||||
# Соседи: tweak base слегка для cam_b, сильнее для cam_c.
|
||||
near = normed(base + 0.05 * make_embedding(seed=43))
|
||||
far = normed(base + 0.5 * make_embedding(seed=44))
|
||||
|
||||
save_embedding_with_meta(base, track_a, cam_a, quality=0.9, captured_at=datetime.utcnow())
|
||||
save_embedding_with_meta(near, track_b, cam_b, quality=0.9, captured_at=datetime.utcnow())
|
||||
save_embedding_with_meta(far, track_c, cam_c, quality=0.9, captured_at=datetime.utcnow())
|
||||
|
||||
# Запрос с cam_a — должен вернуть cam_b раньше cam_c, cam_a исключаем.
|
||||
results = find_topk_in_window(
|
||||
embedding=base.tolist(),
|
||||
camera_id=cam_a,
|
||||
window_minutes=5,
|
||||
k=5,
|
||||
exclude_same_camera=True,
|
||||
)
|
||||
|
||||
cam_ids_in_results = [r["camera_id"] for r in results]
|
||||
assert cam_a not in cam_ids_in_results
|
||||
assert cam_b in cam_ids_in_results
|
||||
assert cam_c in cam_ids_in_results
|
||||
# Порядок: ближе → дальше
|
||||
assert results[0]["camera_id"] == cam_b
|
||||
assert results[0]["distance"] < results[-1]["distance"]
|
||||
|
||||
|
||||
def test_topk_filters_by_window(seed_camera_and_track):
|
||||
"""Старый эмбеддинг (вне окна) не должен попадать в результат."""
|
||||
cam_a, track_a = seed_camera_and_track("A")
|
||||
cam_b, track_b = seed_camera_and_track("B")
|
||||
|
||||
base = make_embedding(seed=7)
|
||||
|
||||
save_embedding_with_meta(
|
||||
base, track_b, cam_b, quality=0.9,
|
||||
captured_at=datetime.utcnow() - timedelta(hours=1), # вне окна 5 мин
|
||||
)
|
||||
|
||||
results = find_topk_in_window(
|
||||
embedding=base.tolist(),
|
||||
camera_id=cam_a,
|
||||
window_minutes=5,
|
||||
k=5,
|
||||
)
|
||||
|
||||
cam_ids = [r["camera_id"] for r in results]
|
||||
assert cam_b not in cam_ids
|
||||
|
||||
|
||||
def test_find_nearest_patient_only_consented(db_conn, seed_camera_and_track):
|
||||
"""find_nearest_patient ищет только среди эмбеддингов с patient_id IS NOT NULL."""
|
||||
cam_a, track_a = seed_camera_and_track("A")
|
||||
base = make_embedding(seed=100)
|
||||
|
||||
# Сохраняем эмбеддинг без patient_id.
|
||||
save_embedding_with_meta(base, track_a, cam_a, quality=0.9)
|
||||
|
||||
# Ищем — никого не должно найти.
|
||||
assert find_nearest_patient(base.tolist(), threshold=0.5) is None
|
||||
|
||||
# Создаём пациента и привязываем трек.
|
||||
import uuid
|
||||
patient_id = str(uuid.uuid4())
|
||||
with db_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO patients (id, full_name, updated_at) VALUES (%s, %s, NOW())",
|
||||
(patient_id, "Тестовый Пациент"),
|
||||
)
|
||||
db_conn.commit()
|
||||
|
||||
affected = attach_track_to_patient(track_a, patient_id)
|
||||
assert affected == 1
|
||||
|
||||
# Теперь должны найти.
|
||||
result = find_nearest_patient(base.tolist(), threshold=0.5)
|
||||
assert result is not None
|
||||
assert result["patient_id"] == patient_id
|
||||
assert result["distance"] < 0.01 # тот же эмбеддинг
|
||||
|
||||
# Очистка.
|
||||
deleted = delete_patient_embeddings(patient_id)
|
||||
assert deleted == 1
|
||||
with db_conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM patients WHERE id = %s", (patient_id,))
|
||||
db_conn.commit()
|
||||
@@ -0,0 +1,40 @@
|
||||
# fixtures-runner
|
||||
|
||||
E2E-сценарии для Фазы 1. Генерирует синтетические треки с детерминированными эмбеддингами (через persona seed) и шлёт их через `apps/api /ingest/*` + `face-service /track-embeddings/raw`. Не требует реальных видео.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
# Список сценариев
|
||||
pnpm fixtures:run --list
|
||||
|
||||
# Запуск
|
||||
pnpm fixtures:run --scenario=new-patient
|
||||
pnpm fixtures:run --scenario=returning-patient
|
||||
pnpm fixtures:run --scenario=left-without-service
|
||||
|
||||
# Realtime — с задержками между событиями (как «живой» поток)
|
||||
pnpm fixtures:run --scenario=new-patient --mode=realtime
|
||||
```
|
||||
|
||||
## Сценарии
|
||||
|
||||
| Имя | Описание |
|
||||
|---|---|
|
||||
| `new-patient` | A → B → C. Создаёт unmatched-трек для ручного enrollment в web-admin. |
|
||||
| `returning-patient` | Тот же `personaSeed=1001` как у new-patient. После enrollment персона должна быть узнана автоматически → создаётся Visit. **Это критерий завершения Ф1.** |
|
||||
| `left-without-service` | A → B → уход. Триггерит событие `left_without_service`. |
|
||||
|
||||
## Как это работает
|
||||
|
||||
- `personaSeed` → детерминированный 512-d L2-нормализованный вектор. Тот же seed = тот же вектор (с малым jitter между эмбеддингами одного трека). После enrollment в `new-patient` вектор привязан к `patient_id`. В `returning-patient` тот же seed → match.
|
||||
- Эмбеддинги пишутся через face-service `/track-embeddings/raw` (без InsightFace-детекции, эмбеддинг сразу передаётся).
|
||||
- События — через `apps/api /ingest/track-events`.
|
||||
- При `triggerRecognition=true` после прогона runner вызывает `/recognize/embedding` и при match создаёт Visit напрямую через Prisma.
|
||||
|
||||
## Критерий приёмки M11
|
||||
|
||||
После прогона:
|
||||
1. `pnpm fixtures:run --scenario=new-patient` — в БД появился unmatched-трек.
|
||||
2. Через web-admin (или curl) — `POST /enrollment` для этого трека.
|
||||
3. `pnpm fixtures:run --scenario=returning-patient` — `runner.visitCreated !== null`, в БД есть новый Visit с привязкой к тому же `patientId`.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@reception/fixtures-runner",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/main.ts",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reception/db": "workspace:*",
|
||||
"dotenv": "^16.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reception/eslint-config": "workspace:*",
|
||||
"@reception/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.9.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "left-without-service",
|
||||
"description": "Пациент входит, ждёт, уходит без обслуживания (триггер left_without_service).",
|
||||
"personaSeed": 2002,
|
||||
"embeddingsPerCamera": 2,
|
||||
"triggerRecognition": false,
|
||||
"events": [
|
||||
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
|
||||
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 15 },
|
||||
{ "type": "left_without_service", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 600 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "new-patient",
|
||||
"description": "Новый пациент: входит (A) → ждёт (B) → обслуживается у стойки (C). Создаёт unmatched-трек для ручного enrollment.",
|
||||
"personaSeed": 1001,
|
||||
"embeddingsPerCamera": 3,
|
||||
"triggerRecognition": false,
|
||||
"events": [
|
||||
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
|
||||
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 20 },
|
||||
{ "type": "service_started", "cameraName": "cam-reception", "zoneCode": "C", "offsetSec": 180 },
|
||||
{ "type": "service_ended", "cameraName": "cam-reception", "zoneCode": "C", "offsetSec": 480 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "returning-patient",
|
||||
"description": "Тот же пациент (тот же personaSeed) приходит повторно. После enrollment в new-patient — system должна узнать его, создать Visit автоматически. Критерий завершения Ф1.",
|
||||
"personaSeed": 1001,
|
||||
"embeddingsPerCamera": 2,
|
||||
"triggerRecognition": true,
|
||||
"events": [
|
||||
{ "type": "arrived", "cameraName": "cam-entrance", "zoneCode": "A", "offsetSec": 0 },
|
||||
{ "type": "waiting", "cameraName": "cam-corridor", "zoneCode": "B", "offsetSec": 25 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export interface IngestTrackResponse {
|
||||
trackId: string;
|
||||
cameraId: string;
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
async createTrack(cameraName: string, firstSeenAt: Date): Promise<IngestTrackResponse> {
|
||||
return this.post('/ingest/tracks', { cameraName, firstSeenAt: firstSeenAt.toISOString() });
|
||||
}
|
||||
|
||||
async addEvent(opts: {
|
||||
trackId: string;
|
||||
type: string;
|
||||
cameraName: string;
|
||||
zoneCode: string;
|
||||
occurredAt: Date;
|
||||
}) {
|
||||
return this.post('/ingest/track-events', {
|
||||
trackId: opts.trackId,
|
||||
type: opts.type,
|
||||
cameraName: opts.cameraName,
|
||||
zoneCode: opts.zoneCode,
|
||||
occurredAt: opts.occurredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`POST ${path} ${res.status}: ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecognizeResult {
|
||||
patient_id: string;
|
||||
confidence: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export class FaceServiceClient {
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
async saveRawEmbedding(opts: {
|
||||
embedding: number[];
|
||||
trackId: string;
|
||||
cameraId: string;
|
||||
capturedAt: Date;
|
||||
quality?: number;
|
||||
}): Promise<{ id: string }> {
|
||||
return this.post('/track-embeddings/raw', {
|
||||
embedding: opts.embedding,
|
||||
track_id: opts.trackId,
|
||||
camera_id: opts.cameraId,
|
||||
captured_at: opts.capturedAt.toISOString(),
|
||||
quality: opts.quality ?? 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
async recognizeByEmbedding(embedding: number[]): Promise<RecognizeResult | null> {
|
||||
return this.post('/recognize/embedding', { embedding });
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`POST ${path} ${res.status}: ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Детерминированный 512-d L2-нормализованный псевдо-эмбеддинг. */
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
function seededRandom(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 0xffffffff;
|
||||
};
|
||||
}
|
||||
|
||||
export function makeEmbedding(seed: number, jitter = 0): number[] {
|
||||
// Гауссово приближение через 12-tap uniform.
|
||||
const rng = seededRandom(seed);
|
||||
const jitterRng = seededRandom(seed + Math.floor(jitter * 1000) + 7);
|
||||
const vec = new Array<number>(512);
|
||||
let norm = 0;
|
||||
for (let i = 0; i < 512; i++) {
|
||||
let s = 0;
|
||||
for (let k = 0; k < 12; k++) s += rng();
|
||||
const base = s - 6;
|
||||
let noise = 0;
|
||||
if (jitter > 0) {
|
||||
let s2 = 0;
|
||||
for (let k = 0; k < 12; k++) s2 += jitterRng();
|
||||
noise = (s2 - 6) * jitter;
|
||||
}
|
||||
const v = base + noise;
|
||||
vec[i] = v;
|
||||
norm += v * v;
|
||||
}
|
||||
norm = Math.sqrt(norm) || 1;
|
||||
for (let i = 0; i < 512; i++) vec[i] /= norm;
|
||||
return vec;
|
||||
}
|
||||
|
||||
/** Хеш для логов. */
|
||||
export function embeddingFingerprint(embedding: number[]): string {
|
||||
return createHash('sha1').update(embedding.slice(0, 32).join(',')).digest('hex').slice(0, 12);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dotenv/config';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PrismaClient } from '@reception/db';
|
||||
import { runScenario } from './runner.js';
|
||||
import type { Scenario } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCENARIOS_DIR = resolve(__dirname, '..', 'scenarios');
|
||||
|
||||
function parseArgs(argv: string[]): { scenario?: string; mode: 'realtime' | 'fast'; list: boolean } {
|
||||
let scenario: string | undefined;
|
||||
let mode: 'realtime' | 'fast' = 'fast';
|
||||
let list = false;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--list') list = true;
|
||||
else if (arg?.startsWith('--scenario=')) scenario = arg.slice('--scenario='.length);
|
||||
else if (arg === '--scenario') scenario = argv[++i];
|
||||
else if (arg?.startsWith('--mode=')) mode = arg.slice('--mode='.length) as 'realtime' | 'fast';
|
||||
else if (arg === '--mode') mode = argv[++i] as 'realtime' | 'fast';
|
||||
}
|
||||
return { scenario, mode, list };
|
||||
}
|
||||
|
||||
function loadScenario(name: string): Scenario {
|
||||
const path = join(SCENARIOS_DIR, `${name}.json`);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Сценарий "${name}" не найден в ${SCENARIOS_DIR}`);
|
||||
}
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as Scenario;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { scenario, mode, list } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (list || !scenario) {
|
||||
const files = readdirSync(SCENARIOS_DIR).filter((f) => f.endsWith('.json'));
|
||||
console.log('Доступные сценарии:');
|
||||
for (const f of files) {
|
||||
const s = JSON.parse(readFileSync(join(SCENARIOS_DIR, f), 'utf-8')) as Scenario;
|
||||
console.log(` ${s.name.padEnd(24)} — ${s.description}`);
|
||||
}
|
||||
if (!scenario) {
|
||||
console.log('\nИспользование: pnpm fixtures:run --scenario=new-patient [--mode=realtime|fast]');
|
||||
// --list — нормальный код выхода 0. Если просто отсутствует --scenario, тоже 0.
|
||||
return;
|
||||
}
|
||||
if (list) return; // --list + --scenario — показать список и продолжить выполнение? нет, выйти.
|
||||
}
|
||||
|
||||
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://localhost:4000';
|
||||
const faceServiceUrl = process.env.FACE_SERVICE_URL ?? 'http://localhost:8001';
|
||||
|
||||
console.log(`Загружаю сценарий ${scenario}, mode=${mode}, api=${apiBaseUrl}, face=${faceServiceUrl}`);
|
||||
const s = loadScenario(scenario);
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const result = await runScenario({
|
||||
scenario: s,
|
||||
apiBaseUrl,
|
||||
faceServiceUrl,
|
||||
prisma,
|
||||
realtime: mode === 'realtime',
|
||||
});
|
||||
console.log('\nИтог:', JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { PrismaClient } from '@reception/db';
|
||||
import { ApiClient, FaceServiceClient } from './clients.js';
|
||||
import { makeEmbedding, embeddingFingerprint } from './embedding.js';
|
||||
import type { Scenario } from './types.js';
|
||||
|
||||
export interface RunOptions {
|
||||
scenario: Scenario;
|
||||
apiBaseUrl: string;
|
||||
faceServiceUrl: string;
|
||||
prisma: PrismaClient;
|
||||
realtime: boolean;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
trackIds: string[];
|
||||
embeddingsSaved: number;
|
||||
eventsSent: number;
|
||||
visitCreated?: { visitId: string; patientId: string; confidence: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогоняет сценарий: создаёт треки (по одному на камеру в сценарии),
|
||||
* сохраняет эмбеддинги, шлёт события. Если scenario.triggerRecognition —
|
||||
* после всех событий пытается узнать persona и создать Visit.
|
||||
*/
|
||||
export async function runScenario(opts: RunOptions): Promise<RunResult> {
|
||||
const { scenario, apiBaseUrl, faceServiceUrl, prisma } = opts;
|
||||
const api = new ApiClient(apiBaseUrl);
|
||||
const face = new FaceServiceClient(faceServiceUrl);
|
||||
|
||||
const baseTime = new Date();
|
||||
const personaEmbedding = makeEmbedding(scenario.personaSeed, 0);
|
||||
console.log(
|
||||
`[scenario:${scenario.name}] persona seed=${scenario.personaSeed}, fp=${embeddingFingerprint(personaEmbedding)}`,
|
||||
);
|
||||
|
||||
const tracksByCamera = new Map<string, { trackId: string; cameraId: string; zoneCode: string }>();
|
||||
const result: RunResult = { trackIds: [], embeddingsSaved: 0, eventsSent: 0, visitCreated: null };
|
||||
|
||||
for (const event of scenario.events) {
|
||||
const occurredAt = new Date(baseTime.getTime() + event.offsetSec * 1000);
|
||||
|
||||
let track = tracksByCamera.get(event.cameraName);
|
||||
if (!track) {
|
||||
const created = await api.createTrack(event.cameraName, occurredAt);
|
||||
track = { trackId: created.trackId, cameraId: created.cameraId, zoneCode: event.zoneCode };
|
||||
tracksByCamera.set(event.cameraName, track);
|
||||
result.trackIds.push(track.trackId);
|
||||
|
||||
// Сохраняем N эмбеддингов с малым jitter — чтобы было что искать.
|
||||
for (let i = 0; i < scenario.embeddingsPerCamera; i++) {
|
||||
const jittered = makeEmbedding(scenario.personaSeed, 0.0001 * i);
|
||||
await face.saveRawEmbedding({
|
||||
embedding: jittered,
|
||||
trackId: track.trackId,
|
||||
cameraId: track.cameraId,
|
||||
capturedAt: new Date(occurredAt.getTime() + i * 100),
|
||||
});
|
||||
result.embeddingsSaved += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.realtime && event.offsetSec > 0) {
|
||||
await sleep(Math.min(event.offsetSec * 1000, 5000));
|
||||
}
|
||||
|
||||
await api.addEvent({
|
||||
trackId: track.trackId,
|
||||
type: event.type,
|
||||
cameraName: event.cameraName,
|
||||
zoneCode: event.zoneCode,
|
||||
occurredAt,
|
||||
});
|
||||
result.eventsSent += 1;
|
||||
console.log(
|
||||
`[scenario:${scenario.name}] event ${event.type} on ${event.cameraName} at +${event.offsetSec}s`,
|
||||
);
|
||||
}
|
||||
|
||||
if (scenario.triggerRecognition) {
|
||||
const match = await face.recognizeByEmbedding(personaEmbedding);
|
||||
if (match) {
|
||||
console.log(
|
||||
`[scenario:${scenario.name}] recognized patient ${match.patient_id} (confidence=${match.confidence})`,
|
||||
);
|
||||
|
||||
// Если уже есть Visit для этого пациента в окне ~5 мин — переиспользуем (idempotent).
|
||||
const recentVisit = await prisma.visit.findFirst({
|
||||
where: {
|
||||
patientId: match.patient_id,
|
||||
arrivedAt: { gte: new Date(Date.now() - 5 * 60 * 1000) },
|
||||
},
|
||||
});
|
||||
if (recentVisit) {
|
||||
result.visitCreated = {
|
||||
visitId: recentVisit.id,
|
||||
patientId: match.patient_id,
|
||||
confidence: match.confidence,
|
||||
};
|
||||
} else {
|
||||
const visit = await prisma.visit.create({
|
||||
data: { patientId: match.patient_id, arrivedAt: baseTime },
|
||||
});
|
||||
result.visitCreated = {
|
||||
visitId: visit.id,
|
||||
patientId: match.patient_id,
|
||||
confidence: match.confidence,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.log(`[scenario:${scenario.name}] persona не узнан (нет enrolled-эмбеддинга)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[scenario:${scenario.name}] DONE — tracks=${result.trackIds.length}, embeddings=${result.embeddingsSaved}, events=${result.eventsSent}, visit=${result.visitCreated ? 'created' : 'none'}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ZoneCode = 'A' | 'B' | 'C';
|
||||
export type TrackEventType =
|
||||
| 'arrived'
|
||||
| 'waiting'
|
||||
| 'service_started'
|
||||
| 'service_ended'
|
||||
| 'left_without_service';
|
||||
|
||||
export interface ScenarioEvent {
|
||||
type: TrackEventType;
|
||||
cameraName: string;
|
||||
zoneCode: ZoneCode;
|
||||
offsetSec: number; // секунды от начала сценария
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
personaSeed: number; // одинаковый seed → одинаковый эмбеддинг (узнавание персонажа)
|
||||
embeddingsPerCamera: number;
|
||||
events: ScenarioEvent[];
|
||||
/** Если true, после прогона ходим в /face-service/recognize/embedding и при match создаём Visit. */
|
||||
triggerRecognition: boolean;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@reception/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# polimed-mock
|
||||
|
||||
In-memory мок МИС Полимед для разработки Цифровой рецепции. Заменяется на реальный SDK Полимед в более поздних фазах — нужно поменять только `POLIMED_BASE_URL` в `apps/api`.
|
||||
|
||||
Порт: **4100** (см. `.env`).
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| `GET` | `/health` | Health-чек |
|
||||
| `GET` | `/patients/search?q=&limit=20` | Поиск пациентов по ФИО / № карты / телефону |
|
||||
| `GET` | `/appointments?date=YYYY-MM-DD` | Журнал записей. Без `date` — все. |
|
||||
| `GET` | `/appointments/:id` | Детали записи |
|
||||
| `GET` | `/appointments/:id/events` | События визита (отправленные через POST) |
|
||||
| `POST` | `/visits/:appointmentId/events` | Write-back события визита (`arrived` / `service_started` / `service_ended` / `left_without_service`) |
|
||||
|
||||
## Данные
|
||||
|
||||
Пациенты и шаблоны записей лежат в `seeds/*.json`. На старте записи пересчитываются на сегодняшнюю дату (`hourOffset` + `minuteOffset` относительно 08:00) — журнал «на сегодня» всегда живой.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
pnpm --filter=@reception/polimed-mock dev
|
||||
# curl http://localhost:4100/appointments?date=$(date +%F)
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
{
|
||||
"include": "../seeds/**/*.json",
|
||||
"outDir": "dist/seeds",
|
||||
"watchAssets": true
|
||||
}
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@reception/polimed-mock",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@reception/eslint-config": "workspace:*",
|
||||
"@reception/tsconfig": "workspace:*",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "id": "pol-a-001", "patientId": "pol-p-001", "patientFullName": "Иванов Иван Иванович", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 0, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-002", "patientId": "pol-p-002", "patientFullName": "Петрова Анна Сергеевна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 1, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-003", "patientId": "pol-p-003", "patientFullName": "Смирнов Олег Петрович", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 1, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-004", "patientId": "pol-p-004", "patientFullName": "Кузнецова Мария Андреевна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 2, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-005", "patientId": "pol-p-005", "patientFullName": "Васильев Дмитрий Николаевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 2, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-006", "patientId": "pol-p-006", "patientFullName": "Соколова Елена Викторовна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 3, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-007", "patientId": "pol-p-007", "patientFullName": "Михайлов Сергей Александрович", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 3, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-008", "patientId": "pol-p-008", "patientFullName": "Новикова Ольга Дмитриевна", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 4, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-009", "patientId": "pol-p-009", "patientFullName": "Фёдоров Александр Юрьевич", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 4, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-010", "patientId": "pol-p-010", "patientFullName": "Морозова Татьяна Игоревна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 5, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-011", "patientId": "pol-p-011", "patientFullName": "Волков Артём Сергеевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 5, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-012", "patientId": "pol-p-012", "patientFullName": "Алексеева Наталья Павловна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 6, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-013", "patientId": "pol-p-013", "patientFullName": "Лебедев Григорий Михайлович", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 6, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-014", "patientId": "pol-p-014", "patientFullName": "Семёнова Екатерина Олеговна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 7, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-015", "patientId": "pol-p-015", "patientFullName": "Егоров Виталий Андреевич", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 7, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-016", "patientId": "pol-p-016", "patientFullName": "Павлова Дарья Ивановна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 8, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-017", "patientId": "pol-p-017", "patientFullName": "Степанов Николай Романович", "doctorFullName": "Зайцев Р.В.", "specialty": "Хирург", "status": "scheduled", "hourOffset": 8, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-018", "patientId": "pol-p-018", "patientFullName": "Никитина Юлия Викторовна", "doctorFullName": "Лазарева Е.А.", "specialty": "Терапевт", "status": "scheduled", "hourOffset": 9, "minuteOffset": 0 },
|
||||
{ "id": "pol-a-019", "patientId": "pol-p-019", "patientFullName": "Орлов Максим Денисович", "doctorFullName": "Захаров Д.Ю.", "specialty": "Кардиолог", "status": "scheduled", "hourOffset": 9, "minuteOffset": 30 },
|
||||
{ "id": "pol-a-020", "patientId": "pol-p-020", "patientFullName": "Андреева Светлана Геннадьевна", "doctorFullName": "Виноградова О.С.", "specialty": "Эндокринолог", "status": "scheduled", "hourOffset": 10, "minuteOffset": 0 }
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "id": "pol-p-001", "fullName": "Иванов Иван Иванович", "birthDate": "1980-03-12", "phone": "+79001234501", "cardNumber": "K-001" },
|
||||
{ "id": "pol-p-002", "fullName": "Петрова Анна Сергеевна", "birthDate": "1992-07-23", "phone": "+79001234502", "cardNumber": "K-002" },
|
||||
{ "id": "pol-p-003", "fullName": "Смирнов Олег Петрович", "birthDate": "1975-11-04", "phone": "+79001234503", "cardNumber": "K-003" },
|
||||
{ "id": "pol-p-004", "fullName": "Кузнецова Мария Андреевна", "birthDate": "1988-01-18", "phone": "+79001234504", "cardNumber": "K-004" },
|
||||
{ "id": "pol-p-005", "fullName": "Васильев Дмитрий Николаевич", "birthDate": "1965-09-30", "phone": "+79001234505", "cardNumber": "K-005" },
|
||||
{ "id": "pol-p-006", "fullName": "Соколова Елена Викторовна", "birthDate": "1990-04-14", "phone": "+79001234506", "cardNumber": "K-006" },
|
||||
{ "id": "pol-p-007", "fullName": "Михайлов Сергей Александрович", "birthDate": "1983-12-08", "phone": "+79001234507", "cardNumber": "K-007" },
|
||||
{ "id": "pol-p-008", "fullName": "Новикова Ольга Дмитриевна", "birthDate": "1978-06-27", "phone": "+79001234508", "cardNumber": "K-008" },
|
||||
{ "id": "pol-p-009", "fullName": "Фёдоров Александр Юрьевич", "birthDate": "1995-02-11", "phone": "+79001234509", "cardNumber": "K-009" },
|
||||
{ "id": "pol-p-010", "fullName": "Морозова Татьяна Игоревна", "birthDate": "1972-08-19", "phone": "+79001234510", "cardNumber": "K-010" },
|
||||
{ "id": "pol-p-011", "fullName": "Волков Артём Сергеевич", "birthDate": "1986-05-03", "phone": "+79001234511", "cardNumber": "K-011" },
|
||||
{ "id": "pol-p-012", "fullName": "Алексеева Наталья Павловна", "birthDate": "1969-10-25", "phone": "+79001234512", "cardNumber": "K-012" },
|
||||
{ "id": "pol-p-013", "fullName": "Лебедев Григорий Михайлович", "birthDate": "1991-12-15", "phone": "+79001234513", "cardNumber": "K-013" },
|
||||
{ "id": "pol-p-014", "fullName": "Семёнова Екатерина Олеговна", "birthDate": "1984-03-07", "phone": "+79001234514", "cardNumber": "K-014" },
|
||||
{ "id": "pol-p-015", "fullName": "Егоров Виталий Андреевич", "birthDate": "1977-07-29", "phone": "+79001234515", "cardNumber": "K-015" },
|
||||
{ "id": "pol-p-016", "fullName": "Павлова Дарья Ивановна", "birthDate": "1996-09-12", "phone": "+79001234516", "cardNumber": "K-016" },
|
||||
{ "id": "pol-p-017", "fullName": "Степанов Николай Романович", "birthDate": "1962-11-21", "phone": "+79001234517", "cardNumber": "K-017" },
|
||||
{ "id": "pol-p-018", "fullName": "Никитина Юлия Викторовна", "birthDate": "1989-04-06", "phone": "+79001234518", "cardNumber": "K-018" },
|
||||
{ "id": "pol-p-019", "fullName": "Орлов Максим Денисович", "birthDate": "1993-01-30", "phone": "+79001234519", "cardNumber": "K-019" },
|
||||
{ "id": "pol-p-020", "fullName": "Андреева Светлана Геннадьевна", "birthDate": "1981-08-09", "phone": "+79001234520", "cardNumber": "K-020" }
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PolimedController } from './polimed.controller';
|
||||
import { PolimedStore } from './polimed.store';
|
||||
|
||||
@Module({
|
||||
controllers: [PolimedController],
|
||||
providers: [PolimedStore],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.enableCors({ origin: true });
|
||||
const port = Number(process.env.POLIMED_MOCK_PORT ?? 4100);
|
||||
await app.listen(port);
|
||||
Logger.log(`polimed-mock listening on http://localhost:${port}`, 'Bootstrap');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
|
||||
import { IsEnum, IsISO8601, IsOptional, IsString } from 'class-validator';
|
||||
import { PolimedStore } from './polimed.store';
|
||||
import type { VisitEventType } from './polimed.types';
|
||||
|
||||
class PushVisitEventDto {
|
||||
@IsEnum(['arrived', 'service_started', 'service_ended', 'left_without_service'])
|
||||
type!: VisitEventType;
|
||||
|
||||
@IsISO8601()
|
||||
occurredAt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class PolimedController {
|
||||
constructor(private readonly store: PolimedStore) {}
|
||||
|
||||
@Get('health')
|
||||
health() {
|
||||
return { status: 'ok', service: 'polimed-mock' };
|
||||
}
|
||||
|
||||
@Get('patients/search')
|
||||
searchPatients(@Query('q') q = '', @Query('limit') limit = '20') {
|
||||
return this.store.searchPatients(q, Number(limit) || 20);
|
||||
}
|
||||
|
||||
@Get('appointments')
|
||||
listAppointments(@Query('date') date?: string) {
|
||||
return this.store.listAppointments(date);
|
||||
}
|
||||
|
||||
@Get('appointments/:id')
|
||||
getAppointment(@Param('id') id: string) {
|
||||
return this.store.getAppointment(id);
|
||||
}
|
||||
|
||||
@Get('appointments/:id/events')
|
||||
getAppointmentEvents(@Param('id') id: string) {
|
||||
return this.store.getVisitEvents(id);
|
||||
}
|
||||
|
||||
@Post('visits/:appointmentId/events')
|
||||
@HttpCode(200)
|
||||
pushVisitEvent(@Param('appointmentId') appointmentId: string, @Body() body: PushVisitEventDto) {
|
||||
return this.store.pushVisitEvent(appointmentId, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { PolimedAppointment, PolimedPatient, VisitEvent } from './polimed.types';
|
||||
|
||||
// В dev (nest start) __dirname = .../apps/polimed-mock/src — поднимаемся в корень app.
|
||||
// В prod (node dist/main.js) __dirname = .../apps/polimed-mock/dist — то же самое.
|
||||
const SEEDS_DIR = join(__dirname, '..', 'seeds');
|
||||
|
||||
@Injectable()
|
||||
export class PolimedStore implements OnModuleInit {
|
||||
private readonly logger = new Logger(PolimedStore.name);
|
||||
private patients: PolimedPatient[] = [];
|
||||
private appointments: PolimedAppointment[] = [];
|
||||
private visitEvents = new Map<string, VisitEvent[]>();
|
||||
|
||||
onModuleInit() {
|
||||
this.patients = JSON.parse(readFileSync(join(SEEDS_DIR, 'patients.json'), 'utf-8'));
|
||||
|
||||
const appointmentsTemplate: Array<Omit<PolimedAppointment, 'scheduledFor'> & { hourOffset: number; minuteOffset: number }> =
|
||||
JSON.parse(readFileSync(join(SEEDS_DIR, 'appointments.json'), 'utf-8'));
|
||||
|
||||
// Привязываем appointments к сегодняшней дате на старте — журнал «на сегодня» всегда живой.
|
||||
const today = new Date();
|
||||
today.setHours(8, 0, 0, 0);
|
||||
|
||||
this.appointments = appointmentsTemplate.map((a) => {
|
||||
const dt = new Date(today);
|
||||
dt.setHours(today.getHours() + a.hourOffset, today.getMinutes() + a.minuteOffset, 0, 0);
|
||||
return {
|
||||
id: a.id,
|
||||
patientId: a.patientId,
|
||||
patientFullName: a.patientFullName,
|
||||
doctorFullName: a.doctorFullName,
|
||||
specialty: a.specialty,
|
||||
status: a.status,
|
||||
scheduledFor: dt.toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Seeded ${this.patients.length} patients, ${this.appointments.length} appointments (today)`,
|
||||
);
|
||||
}
|
||||
|
||||
searchPatients(query: string, limit = 20): PolimedPatient[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return this.patients.slice(0, limit);
|
||||
return this.patients
|
||||
.filter(
|
||||
(p) =>
|
||||
p.fullName.toLowerCase().includes(q) ||
|
||||
p.cardNumber.toLowerCase().includes(q) ||
|
||||
p.phone.includes(q),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
listAppointments(dateIso?: string): PolimedAppointment[] {
|
||||
if (!dateIso) return this.appointments;
|
||||
return this.appointments.filter((a) => a.scheduledFor.startsWith(dateIso));
|
||||
}
|
||||
|
||||
getAppointment(id: string): PolimedAppointment {
|
||||
const ap = this.appointments.find((a) => a.id === id);
|
||||
if (!ap) throw new NotFoundException(`Appointment ${id} not found`);
|
||||
return ap;
|
||||
}
|
||||
|
||||
pushVisitEvent(appointmentId: string, event: VisitEvent) {
|
||||
this.getAppointment(appointmentId); // throws 404 if missing
|
||||
const arr = this.visitEvents.get(appointmentId) ?? [];
|
||||
arr.push(event);
|
||||
this.visitEvents.set(appointmentId, arr);
|
||||
this.logger.log(
|
||||
`WRITE-BACK appointment=${appointmentId} event=${event.type} at=${event.occurredAt}`,
|
||||
);
|
||||
return { appointmentId, eventsTotal: arr.length };
|
||||
}
|
||||
|
||||
getVisitEvents(appointmentId: string): VisitEvent[] {
|
||||
return this.visitEvents.get(appointmentId) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface PolimedPatient {
|
||||
id: string;
|
||||
fullName: string;
|
||||
birthDate: string; // YYYY-MM-DD
|
||||
phone: string;
|
||||
cardNumber: string;
|
||||
}
|
||||
|
||||
export interface PolimedAppointment {
|
||||
id: string;
|
||||
patientId: string;
|
||||
patientFullName: string;
|
||||
doctorFullName: string;
|
||||
specialty: string;
|
||||
scheduledFor: string; // ISO datetime
|
||||
status: 'scheduled' | 'completed' | 'cancelled' | 'no_show';
|
||||
}
|
||||
|
||||
export type VisitEventType = 'arrived' | 'service_started' | 'service_ended' | 'left_without_service';
|
||||
|
||||
export interface VisitEvent {
|
||||
type: VisitEventType;
|
||||
occurredAt: string; // ISO datetime
|
||||
source?: string; // например 'reception-video'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@reception/tsconfig/nest.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user