commit
b9f88194d9
182 changed files with 20578 additions and 0 deletions
@ -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 |
||||
@ -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
Loading…
Reference in new issue