diff --git a/.env.example b/.env.example
index ec6802d..64bfb47 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,8 @@ FF_ENV=dev
FF_HTTP_ADDR=:8080
FF_BASE_URL=http://localhost:8080
FF_METRICS_ADDR=127.0.0.1:9090
+# Профилировщик /debug/pprof на адресе метрик — только для диагностики
+FF_PPROF=false
FF_WORKER_METRICS_ADDR=127.0.0.1:9091
# В режиме dev статика SPA проксируется на сервер Vite
FF_DEV_VITE_URL=http://localhost:5173
diff --git a/.github/assets/alerts.webp b/.github/assets/alerts.webp
new file mode 100644
index 0000000..f7f4b48
Binary files /dev/null and b/.github/assets/alerts.webp differ
diff --git a/.github/assets/analytics-dark.webp b/.github/assets/analytics-dark.webp
index a2d64d8..73d1152 100644
Binary files a/.github/assets/analytics-dark.webp and b/.github/assets/analytics-dark.webp differ
diff --git a/.github/assets/dashboard.webp b/.github/assets/dashboard.webp
index 497d072..bf43f0d 100644
Binary files a/.github/assets/dashboard.webp and b/.github/assets/dashboard.webp differ
diff --git a/.github/assets/facility.webp b/.github/assets/facility.webp
index 6aa668e..3ab9e9d 100644
Binary files a/.github/assets/facility.webp and b/.github/assets/facility.webp differ
diff --git a/.github/assets/forecast.webp b/.github/assets/forecast.webp
index 10eddfd..d0c3823 100644
Binary files a/.github/assets/forecast.webp and b/.github/assets/forecast.webp differ
diff --git a/.github/assets/map-dark.webp b/.github/assets/map-dark.webp
index 73cb9c2..97613d7 100644
Binary files a/.github/assets/map-dark.webp and b/.github/assets/map-dark.webp differ
diff --git a/.github/assets/map.webp b/.github/assets/map.webp
index dc390d3..dc446cf 100644
Binary files a/.github/assets/map.webp and b/.github/assets/map.webp differ
diff --git a/.github/assets/registry.webp b/.github/assets/registry.webp
index 71c7155..3dbd057 100644
Binary files a/.github/assets/registry.webp and b/.github/assets/registry.webp differ
diff --git a/.github/assets/reports.webp b/.github/assets/reports.webp
new file mode 100644
index 0000000..daee82c
Binary files /dev/null and b/.github/assets/reports.webp differ
diff --git a/.github/assets/route.webp b/.github/assets/route.webp
index 1393886..474d618 100644
Binary files a/.github/assets/route.webp and b/.github/assets/route.webp differ
diff --git a/.github/assets/scenario.webp b/.github/assets/scenario.webp
index e0cb7a9..5e82f97 100644
Binary files a/.github/assets/scenario.webp and b/.github/assets/scenario.webp differ
diff --git a/.github/assets/search.webp b/.github/assets/search.webp
index ff73e07..ea3f0a6 100644
Binary files a/.github/assets/search.webp and b/.github/assets/search.webp differ
diff --git a/.github/assets/site-selection.webp b/.github/assets/site-selection.webp
index ff4184d..6960449 100644
Binary files a/.github/assets/site-selection.webp and b/.github/assets/site-selection.webp differ
diff --git a/README.md b/README.md
index 25df059..697ca95 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,12 @@
# FreightFlow
[](https://github.com/ht2473/FreightFlow_v2/actions/workflows/ci.yml)
+[](https://github.com/ht2473/FreightFlow_v2/releases)
[](LICENSE)
+[](go.mod)
+[](deploy/compose.dev.yaml)
+[](web/package.json)
+[](web/package.json)
Информационная система мониторинга и анализа логистической инфраструктуры Москвы на открытых
данных: реестр складов, логистических центров и терминалов, дорожная сеть с ограничениями для
@@ -82,11 +87,15 @@ DBSCAN.
уведомлений, на почту (сразу или дайджестом), в Telegram-бот и на вебхуки с подписью
HMAC-SHA256; тихие часы и подписки.
+
+
**Отчёты и выгрузки.** Паспорт объекта, сводка по округу или району, проходимость для профиля
ТС, дорожные работы и влияние, результаты сценария, оценка локации, качество данных — в PDF с
графиками и картами и в XLSX; по расписанию с рассылкой на почту. Выгрузки реестра — CSV, XLSX,
GeoJSON и GeoPackage для QGIS; в каждом файле атрибуция источников и дата данных.
+
+
**API и справка.** Документация API в системе (операции, схемы, примеры `curl`, вебхуки с
проверкой подписи), токены с правами и сроком, лимит запросов. Справка: руководство по разделам,
методики всех показателей, источники и лицензии, словарь; знакомство с системой при первом входе.
@@ -173,7 +182,10 @@ index | forecast`, `freightflow routing accessibility | cells | analyze`.
Готовы реестр, карта, маршрутизация и сетевой анализ, поиск, аналитика и показатели, сценарии
«что если», устойчивость сети и оценка локации (МАИ + TOPSIS), мониторинг и алерты с доставкой
-по каналам, отчёты и выгрузки, документация API и справка. Дальше — качество и выпуск.
+по каналам, отчёты и выгрузки, документация API и справка. Проверены производительность
+(бюджеты под нагрузкой), безопасность (OWASP ASVS уровня 2, матрица прав по всем операциям) и
+доступность (axe во всех разделах в обеих темах, проход с клавиатуры, планшет и телефон); 51
+сквозной тест ключевых потоков. Дальше — развёртывание на сервер.
## Лицензия
diff --git a/Taskfile.yml b/Taskfile.yml
index 0aef7bb..e04f8b5 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -201,6 +201,11 @@ tasks:
cmds:
- npx playwright test {{.CLI_ARGS}}
+ load:
+ desc: "Нагрузочные сценарии по бюджетам производительности (нужен `task serve`); один: task load -- -run TestLoad/tiles"
+ cmds:
+ - go test -tags load -count=1 -v -timeout 15m ./test/load/ {{.CLI_ARGS}}
+
vuln:
desc: Проверка уязвимостей в зависимостях
cmds:
diff --git a/db/migrations/20261103000041_perf_indexes.sql b/db/migrations/20261103000041_perf_indexes.sql
new file mode 100644
index 0000000..887f6b5
--- /dev/null
+++ b/db/migrations/20261103000041_perf_indexes.sql
@@ -0,0 +1,15 @@
+-- +goose Up
+
+-- Точки притяжения по границам районов и округов (список территорий, профиль): поиск по
+-- координатам WGS84 шёл полным просмотром таблицы.
+CREATE INDEX poi_point_idx ON infra.poi USING gist (point);
+
+-- Объекты реестра по району и округу (списки территорий, сводки).
+CREATE INDEX facility_district_idx ON infra.facility (district_id) WHERE status = 'active';
+CREATE INDEX facility_okrug_idx ON infra.facility (okrug_id) WHERE status = 'active';
+
+-- +goose Down
+
+DROP INDEX infra.facility_okrug_idx;
+DROP INDEX infra.facility_district_idx;
+DROP INDEX infra.poi_point_idx;
diff --git a/db/migrations/20261103000042_sessions_revoked.sql b/db/migrations/20261103000042_sessions_revoked.sql
new file mode 100644
index 0000000..bba16d6
--- /dev/null
+++ b/db/migrations/20261103000042_sessions_revoked.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+
+-- Время, раньше которого сеансы пользователя недействительны помимо смены пароля: сброс второго
+-- фактора администратором, блокировка.
+ALTER TABLE core.app_user ADD COLUMN sessions_revoked_at timestamptz;
+
+-- +goose Down
+
+ALTER TABLE core.app_user DROP COLUMN sessions_revoked_at;
diff --git a/db/queries/identity/users.sql b/db/queries/identity/users.sql
index 03ef8d1..d4bb3e3 100644
--- a/db/queries/identity/users.sql
+++ b/db/queries/identity/users.sql
@@ -77,3 +77,6 @@ INSERT INTO core.user_setting (user_id, theme, settings)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET theme = EXCLUDED.theme, settings = EXCLUDED.settings, updated_at = now()
RETURNING *;
+
+-- name: RevokeSessions :exec
+UPDATE core.app_user SET sessions_revoked_at = now(), updated_at = now() WHERE id = $1;
diff --git a/go.mod b/go.mod
index d30bb40..7736faa 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,8 @@ require (
github.com/a-h/templ v0.3.1100
github.com/alexedwards/scs/v2 v2.9.0
github.com/caarlos0/env/v11 v11.4.1
+ github.com/chromedp/cdproto v0.159.0
+ github.com/chromedp/chromedp v0.21.1
github.com/exaring/otelpgx v0.13.0
github.com/go-faster/errors v0.8.0
github.com/go-faster/jx v1.2.0
@@ -24,8 +26,9 @@ require (
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.50.0
github.com/riverqueue/river/rivertype v0.50.0
github.com/riverqueue/rivercontrib/otelriver v0.12.0
+ github.com/tsenart/vegeta/v12 v12.12.0
github.com/wneessen/go-mail v0.8.1
- github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94
+ github.com/xuri/excelize/v2 v2.11.1
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0
@@ -51,8 +54,6 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/chromedp/cdproto v0.159.0 // indirect
- github.com/chromedp/chromedp v0.21.1 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/fatih/color v1.19.0 // indirect
@@ -65,9 +66,12 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
+ github.com/influxdata/tdigest v0.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
@@ -78,10 +82,11 @@ require (
github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
- github.com/richardlehane/mscfb v1.0.8 // indirect
+ github.com/richardlehane/mscfb v1.0.9 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/riverqueue/river/riverdriver v0.50.0 // indirect
github.com/riverqueue/river/rivershared v0.50.0 // indirect
+ github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.5.0 // indirect
github.com/shopspring/decimal v1.5.0 // indirect
@@ -90,7 +95,7 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
- github.com/xuri/efp v0.0.1 // indirect
+ github.com/xuri/efp v0.0.3-0.20260924042702-217b81952dec // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
@@ -102,7 +107,7 @@ require (
go.uber.org/zap v1.28.0 // indirect
golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 // indirect
golang.org/x/mod v0.42.0 // indirect
- golang.org/x/net v0.61.0 // indirect
+ golang.org/x/net v0.62.0 // indirect
golang.org/x/sys v0.49.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect
diff --git a/go.sum b/go.sum
index 1cb520e..fe97505 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,8 @@ github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLj
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bmizerany/perks v0.0.0-20230307044200-03f9df79da1e h1:mWOqoK5jV13ChKf/aF3plwQ96laasTJgZi4f1aSOu+M=
+github.com/bmizerany/perks v0.0.0-20230307044200-03f9df79da1e/go.mod h1:ac9efd0D1fsDb3EJvhqgXRbFx7bs2wqZ10HQPeU8U/Q=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
@@ -27,6 +29,8 @@ github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEf
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-gk v0.0.0-20200319235926-a69029f61654 h1:XOPLOMn/zT4jIgxfxSsoXPxkrzz0FaCHwp33x5POJ+Q=
+github.com/dgryski/go-gk v0.0.0-20200319235926-a69029f61654/go.mod h1:qm+vckxRlDt0aOla0RYJJVeqHZlWfOm2UIxHaqPB46E=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
@@ -59,6 +63,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -66,6 +71,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
+github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY=
+github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -76,6 +83,8 @@ github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds=
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
@@ -88,6 +97,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lmittmann/tint v1.2.1 h1:zOadah+f+nSm3jZM044jOsqh0+vSfgG/d9Ota9a/TLE=
github.com/lmittmann/tint v1.2.1/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -125,8 +136,8 @@ github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0o
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/richardlehane/mscfb v1.0.8 h1:UXdg61fxF69/X9yMYuRHAWSrGXIul/UAPivAsUXMme8=
-github.com/richardlehane/mscfb v1.0.8/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
+github.com/richardlehane/mscfb v1.0.9 h1:8xdd9auUvXbFoCw3L9h1spnQHZgjNsSX+ek46J6A9tE=
+github.com/richardlehane/mscfb v1.0.9/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/riverqueue/river v0.50.0 h1:YKQp8NHpq2IDIE7UIyQIhTxJKwc4XAHyUwr8O38+5lY=
@@ -145,12 +156,16 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 h1:18kd+8ZUlt/ARXhljq+14TwAoKa61q6dX8jtwOf6DH8=
+github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY=
github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc=
github.com/shopspring/decimal v1.5.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/streadway/quantile v0.0.0-20220407130108-4246515d968d h1:X4+kt6zM/OVO6gbJdAfJR60MGPsqCzbtXNnjoGqdfAs=
+github.com/streadway/quantile v0.0.0-20220407130108-4246515d968d/go.mod h1:lbP8tGiBjZ5YWIc2fzuRpTaz0b/53vT6PEs3QuAWzuU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -169,12 +184,14 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
+github.com/tsenart/vegeta/v12 v12.12.0 h1:FKMMNomd3auAElO/TtbXzRFXAKGee6N/GKCGweFVm2U=
+github.com/tsenart/vegeta/v12 v12.12.0/go.mod h1:gpdfR++WHV9/RZh4oux0f6lNPhsOH8pCjIGUlcPQe1M=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
-github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
-github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
-github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94 h1:yPdJeJLC1uTN8zQBHE2nqHjZ15PfxU9xsX75vNcyy/c=
-github.com/xuri/excelize/v2 v2.11.1-0.20260915055537-22f76f9acb94/go.mod h1:WnwweQUkWv/3Y6OBVy0bgqN0zHAbn2N+i2zPUAXF1SI=
+github.com/xuri/efp v0.0.3-0.20260924042702-217b81952dec h1:sZZokRLgr8nyrzkDDCQcvEYBsTrZr6eqXa/R9qALoQA=
+github.com/xuri/efp v0.0.3-0.20260924042702-217b81952dec/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.11.1 h1:tm/FVufArvfUiJXJtGv8jna/zZS1t9Uji2ImsnS9iBo=
+github.com/xuri/excelize/v2 v2.11.1/go.mod h1:NQLBThVu4BB7tYM8wJIBj2dZsptXbDW4WyrFlL8YLZQ=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -226,6 +243,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
+golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 h1:pQo5TwmxKJvI8k39uYS2Gqkk8OXWxX/TMSis2Twp1AQ=
golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
@@ -238,8 +256,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
-golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
+golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
+golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -256,6 +274,7 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
+golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -266,8 +285,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0=
google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o=
@@ -292,3 +313,5 @@ modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
+pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
diff --git a/internal/analytics/dashboard.go b/internal/analytics/dashboard.go
index 0a80088..d90b7b6 100644
--- a/internal/analytics/dashboard.go
+++ b/internal/analytics/dashboard.go
@@ -122,9 +122,10 @@ func (s *Service) Geo(ctx context.Context, code, level string) (json.RawMessage,
var geom string
switch level {
case LevelDistrict, LevelOkrug:
- geom = `(SELECT ST_SimplifyPreserveTopology(a.geom, 0.0003) FROM territory.admin_area a WHERE a.id::text = v.entity)`
+ geom = `(SELECT ST_SimplifyPreserveTopology(a.geom, 0.0003) FROM territory.admin_area a WHERE a.id = ` + entityID + `)`
case LevelH3r8, LevelH3r9:
- geom = `(SELECT c.geom FROM territory.h3_cell c WHERE c.h3::text = v.entity)`
+ // Ключ ячейки — индекс H3: сравнение по типу h3index идёт по первичному ключу.
+ geom = `(SELECT c.geom FROM territory.h3_cell c WHERE c.h3 = v.entity::h3index)`
default:
return nil, fmt.Errorf("%w: карта уровня %s", ErrUnknown, level)
}
diff --git a/internal/analytics/index.go b/internal/analytics/index.go
index 9cc3805..e6ede12 100644
--- a/internal/analytics/index.go
+++ b/internal/analytics/index.go
@@ -306,8 +306,8 @@ func (s *Service) indexInputs(ctx context.Context, ix Index, level string) (map[
collect(ix)
rows, err := s.pool.Query(ctx, `
SELECT v.metric_code, v.entity, v.value,
- CASE v.level WHEN 'district' THEN (SELECT name FROM territory.admin_area WHERE id::text = v.entity)
- WHEN 'okrug' THEN (SELECT coalesce(short_name, name) FROM territory.admin_area WHERE id::text = v.entity)
+ CASE v.level WHEN 'district' THEN (SELECT name FROM territory.admin_area WHERE id = `+entityID+`)
+ WHEN 'okrug' THEN (SELECT coalesce(short_name, name) FROM territory.admin_area WHERE id = `+entityID+`)
ELSE v.entity END
FROM analytics.metric_value v
WHERE v.level = $1 AND v.period IS NULL AND v.metric_code = ANY($2)`, level, refs)
diff --git a/internal/analytics/query.go b/internal/analytics/query.go
index b7f0cb8..5ec73d8 100644
--- a/internal/analytics/query.go
+++ b/internal/analytics/query.go
@@ -60,14 +60,19 @@ type Value struct {
Rank, Of int
}
+// entityID — ключ объекта уровня как uuid (NULL, если ключ не uuid: у части участков сети это
+// составной ключ). Сравнение id = entityID идёт по первичному ключу; id::text = v.entity —
+// полным просмотром таблицы на каждую строку.
+const entityID = `(CASE WHEN v.entity ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN v.entity::uuid END)`
+
// labels — подпись объекта уровня.
const labels = `CASE v.level
WHEN 'city' THEN 'Москва'
- WHEN 'district' THEN (SELECT name FROM territory.admin_area WHERE id::text = v.entity)
- WHEN 'okrug' THEN (SELECT coalesce(short_name, name) FROM territory.admin_area WHERE id::text = v.entity)
+ WHEN 'district' THEN (SELECT name FROM territory.admin_area WHERE id = ` + entityID + `)
+ WHEN 'okrug' THEN (SELECT coalesce(short_name, name) FROM territory.admin_area WHERE id = ` + entityID + `)
WHEN 'facility' THEN (SELECT coalesce(f.name, t.name) FROM infra.facility f JOIN ref.facility_type t ON t.code = f.type_code
- WHERE f.id::text = v.entity)
- WHEN 'segment' THEN coalesce(v.inputs ->> 'name', (SELECT s.name FROM network.road_segment s WHERE s.id::text = v.entity))
+ WHERE f.id = ` + entityID + `)
+ WHEN 'segment' THEN coalesce(v.inputs ->> 'name', (SELECT s.name FROM network.road_segment s WHERE s.id = ` + entityID + `))
ELSE v.entity END`
func scanValues(rows pgx.Rows) ([]Value, error) {
@@ -100,12 +105,15 @@ func (s *Service) Values(ctx context.Context, code, level string, series bool, l
if series {
order, cond = `v.period`, `v.period IS NOT NULL`
}
+ // Подписи — только для возвращаемых строк: место и число считаются по всем значениям уровня.
rows, err := s.pool.Query(ctx, `
- SELECT v.metric_code, v.level, v.entity, `+labels+`, v.period, v.value, v.inputs,
- (rank() OVER (ORDER BY v.value DESC))::int, (count(*) OVER ())::int
- FROM analytics.metric_value v
- WHERE v.metric_code = $1 AND v.level = $2 AND `+cond+`
- ORDER BY `+order+` LIMIT $3`, code, level, limit)
+ SELECT v.metric_code, v.level, v.entity, `+labels+`, v.period, v.value, v.inputs, v.rank, v.n FROM (
+ SELECT v.metric_code, v.level, v.entity, v.period, v.value, v.inputs,
+ (rank() OVER (ORDER BY v.value DESC))::int AS rank, (count(*) OVER ())::int AS n
+ FROM analytics.metric_value v
+ WHERE v.metric_code = $1 AND v.level = $2 AND `+cond+`
+ ORDER BY `+order+` LIMIT $3
+ ) v ORDER BY `+order, code, level, limit)
if err != nil {
return nil, err
}
diff --git a/internal/analytics/spatial.go b/internal/analytics/spatial.go
index 15540f3..82f101d 100644
--- a/internal/analytics/spatial.go
+++ b/internal/analytics/spatial.go
@@ -140,7 +140,7 @@ func (s *Service) Hotspots(ctx context.Context, p HotspotParams) (*HotspotResult
ids[i] = h.H3
}
geoms := map[string]json.RawMessage{}
- grows, err := s.pool.Query(ctx, `SELECT h3::text, ST_AsGeoJSON(geom, 6) FROM territory.h3_cell WHERE h3::text = ANY($1)`, ids)
+ grows, err := s.pool.Query(ctx, `SELECT h3::text, ST_AsGeoJSON(geom, 6) FROM territory.h3_cell WHERE h3 = ANY($1::text[]::h3index[])`, ids)
if err != nil {
return nil, err
}
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index 2b7d904..8ad2191 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -5,10 +5,13 @@ import (
"encoding/json"
"fmt"
"io"
+ "log"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
+ "regexp"
+ "slices"
"strings"
"sync"
"testing"
@@ -1020,3 +1023,85 @@ func TestAPIDashboardAndGeo(t *testing.T) {
t.Errorf("обновления: %d %s", resp.StatusCode, body)
}
}
+
+// sessionOnly — операции только для сеанса браузера: по токену API они запрещены у любой роли.
+var sessionOnly = map[string]bool{
+ "changePassword": true, "beginTotpSetup": true, "confirmTotp": true, "disableTotp": true,
+ "listTokens": true, "createToken": true, "revokeToken": true,
+}
+
+// Матрица «операция × роль»: каждая операция спецификации для каждой встроенной роли. Отказ
+// 403 — ровно тогда, когда у роли нет права операции (или операция только для сеанса); иначе
+// запрос проходит авторизацию (дальше — 2xx, 400, 404, 422 по существу запроса).
+func TestAPIOperationRoleMatrix(t *testing.T) {
+ t.Parallel()
+ f := newFixture(t)
+ // Паники обработчиков без сервисов фикстуры — после авторизации, в журнал теста их не пишем.
+ f.srv.Config.ErrorLog = log.New(io.Discard, "", 0)
+ ops, err := Operations(spec.Spec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ param := regexp.MustCompile(`\{[^}]+\}`)
+ for _, role := range identity.BuiltinRoles {
+ token := f.token(t, role.Code+"@matrix.example.com", role.Code)
+ for _, op := range ops {
+ allowed := op.Permission == PermAuthenticated || slices.Contains(role.Permissions, identity.Permission(op.Permission))
+ if sessionOnly[op.ID] {
+ allowed = false
+ }
+ path := param.ReplaceAllString(op.Path, "01a13230-0000-7000-8000-000000000000")
+ body := ""
+ if op.Method == http.MethodPost || op.Method == http.MethodPut || op.Method == http.MethodPatch {
+ body = "{}"
+ }
+ code, out := f.status(op.Method, "/api/v1"+path, token, body)
+ switch {
+ case code == http.StatusUnauthorized:
+ t.Errorf("%s %s %s: 401 по действующему токену", role.Code, op.Method, op.Path)
+ case sessionOnly[op.ID] && code < 400:
+ t.Errorf("%s %s %s: операция только для сеанса выполнена по токену (%d)", role.Code, op.Method, op.Path, code)
+ case !sessionOnly[op.ID] && !allowed && code != http.StatusForbidden:
+ t.Errorf("%s %s %s (%s): %d, ожидался отказ 403", role.Code, op.Method, op.Path, op.Permission, code)
+ case allowed && code == http.StatusForbidden:
+ t.Errorf("%s %s %s (%s): отказ 403 при наличии права; %s", role.Code, op.Method, op.Path, op.Permission, out)
+ }
+ }
+ }
+}
+
+// status — код ответа без остановки теста. Обрыв соединения — запрос прошёл авторизацию и дошёл
+// до обработчика, которому в фикстуре не подключён сервис (код 0).
+func (f *fixture) status(method, path, token, body string) (int, string) {
+ var r io.Reader
+ if body != "" {
+ r = strings.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(context.Background(), method, f.srv.URL+path, r)
+ if err != nil {
+ return 0, err.Error()
+ }
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return 0, err.Error()
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(b)
+}
+
+func TestAPIResponsesNotCached(t *testing.T) {
+ t.Parallel()
+ f := newFixture(t)
+ token := f.token(t, "cache@example.com", identity.RoleViewer)
+ for _, path := range []string{"/api/v1/me", "/api/v1/layers", "/api/v1/nope"} {
+ resp, _ := f.do(t, http.MethodGet, path, token, "")
+ if got := resp.Header.Get("Cache-Control"); got != "no-store" {
+ t.Errorf("%s: Cache-Control %q, want no-store", path, got)
+ }
+ }
+}
diff --git a/internal/api/scenarios_test.go b/internal/api/scenarios_test.go
new file mode 100644
index 0000000..bf483e5
--- /dev/null
+++ b/internal/api/scenarios_test.go
@@ -0,0 +1,32 @@
+package api
+
+import (
+ "testing"
+
+ "freightflow/internal/scenario"
+)
+
+// Новый объект в базе не существует: времени базы нет, и итог сценария всё равно отдаётся
+// (null в baseMin не проходит схему ответа, и падал бы весь список сценариев).
+func TestToScenarioNewFacilityWithoutBase(t *testing.T) {
+ sm := 12.5
+ sc := &scenario.Scenario{
+ Status: "done",
+ Params: scenario.Params{Profile: "truck_20", Targets: []string{"mkad"}},
+ Summary: &scenario.Summary{
+ Targets: []scenario.TargetSummary{},
+ Closures: []scenario.ClosureInfo{},
+ NewFacility: []scenario.EntityTime{
+ {Kind: "mkad", ScenarioMin: &sm, BaseStatus: "unreachable", ScenarioStatus: "ok"},
+ },
+ },
+ }
+ out, err := toScenario(sc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ nf := out.Summary.Value.NewFacility
+ if len(nf) != 1 || nf[0].BaseMin.Set || nf[0].ScenarioMin.Value != sm {
+ t.Fatalf("newFacility = %+v", nf)
+ }
+}
diff --git a/internal/api/security.go b/internal/api/security.go
index d816c8b..6d17481 100644
--- a/internal/api/security.go
+++ b/internal/api/security.go
@@ -40,7 +40,7 @@ func (s *Server) HandleCookieAuth(ctx context.Context, op gen.OperationName, _ g
if !ok {
return ctx, errUnauthorized
}
- changedAt, err := s.identity.PasswordChangedAt(ctx, uid)
+ changedAt, err := s.identity.SessionsValidAfter(ctx, uid)
if err != nil {
return ctx, errUnauthorized
}
diff --git a/internal/api/server.go b/internal/api/server.go
index 8d0511f..3383292 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -150,7 +150,12 @@ func New(d Deps) (http.Handler, error) {
if err != nil {
return nil, fmt.Errorf("create api server: %w", err)
}
- return srv, nil
+ // Ответы API — данные пользователя: не сохраняются ни браузером, ни общими кешами (ASVS 8.2.1).
+ // Обработчик может задать свой заголовок поверх.
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ srv.ServeHTTP(w, r)
+ }), nil
}
func meta(ctx context.Context) identity.RequestMeta {
diff --git a/internal/app/app.go b/internal/app/app.go
index cd2d740..3bc6689 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -49,6 +49,7 @@ func commands() []command {
{"search", "поиск: reindex [-force] [вид...] (индекс документов)", runSearch},
{"migrate", "миграции базы данных: up | down | status", runMigrate},
{"admin", "администрирование: create-user | reset-2fa", runAdmin},
+ {"demo", "демонстрационный набор для учётной записи: -email ... (нужен запущенный сервер)", runDemo},
{"version", "версия сборки", runVersion},
}
}
diff --git a/internal/app/demo.go b/internal/app/demo.go
new file mode 100644
index 0000000..8f6e682
--- /dev/null
+++ b/internal/app/demo.go
@@ -0,0 +1,540 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "freightflow/internal/identity"
+)
+
+// runDemo наполняет учётную запись демонстрационным набором: дашборд, сохранённые виды,
+// правила алертов, сценарии, оценка локации, отчёты и расписание. Всё создаётся через API
+// запущенного сервера от имени пользователя по временному токену, который в конце отзывается;
+// значения считаются системой по загруженным данным. Повторный запуск добавляет только то,
+// чего ещё нет (по названию).
+func runDemo(ctx context.Context, env *Env, args []string) error {
+ fs := flag.NewFlagSet("demo", flag.ContinueOnError)
+ fs.SetOutput(env.Stderr)
+ email := fs.String("email", "", "адрес электронной почты пользователя")
+ base := fs.String("api", "http://127.0.0.1:8080/api/v1", "адрес API запущенного сервера")
+ if err := fs.Parse(args); err != nil || *email == "" {
+ fmt.Fprintln(env.Stderr, "Использование: freightflow demo -email ... [-api http://127.0.0.1:8080/api/v1]")
+ return ErrUsage
+ }
+ pool, err := openDatabase(ctx, env)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ ids, err := newIdentity(ctx, env, pool)
+ if err != nil {
+ return err
+ }
+ users, err := ids.ListUsers(ctx)
+ if err != nil {
+ return err
+ }
+ target := strings.ToLower(strings.TrimSpace(*email))
+ var p identity.Principal
+ for _, u := range users {
+ if u.Email == target {
+ if p, err = ids.Principal(ctx, u.ID); err != nil {
+ return err
+ }
+ }
+ }
+ if p.UserID == [16]byte{} {
+ return fmt.Errorf("user %s not found", target)
+ }
+ meta := identity.RequestMeta{UserAgent: "freightflow demo"}
+ expires := time.Now().Add(time.Hour)
+ plain, tok, err := ids.CreateAPIToken(ctx, p, "Демонстрационный набор", p.Permissions, &expires, meta)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := ids.RevokeAPIToken(context.WithoutCancel(ctx), p, tok.ID, meta); err != nil {
+ fmt.Fprintf(env.Stderr, "не удалось отозвать временный токен %s: %v\n", tok.ID, err)
+ }
+ }()
+ d := &demo{base: strings.TrimRight(*base, "/"), token: plain, out: env.Stdout, http: &http.Client{Timeout: 2 * time.Minute}}
+ return d.run(ctx)
+}
+
+type demo struct {
+ base, token string
+ out io.Writer
+ http *http.Client
+}
+
+type obj = map[string]any
+
+func (d *demo) call(ctx context.Context, method, path string, body, out any) error {
+ var rd io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ rd = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, d.base+path, rd)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+d.token)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ res, err := d.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ data, err := io.ReadAll(res.Body)
+ if err != nil {
+ return err
+ }
+ if res.StatusCode >= 300 {
+ return fmt.Errorf("%s %s: %d %s", method, path, res.StatusCode, strings.TrimSpace(string(data)))
+ }
+ if out != nil && len(data) > 0 {
+ return json.Unmarshal(data, out)
+ }
+ return nil
+}
+
+func (d *demo) logf(format string, a ...any) {
+ fmt.Fprintf(d.out, format+"\n", a...)
+}
+
+// named — названия уже созданных записей: повторный запуск их не дублирует.
+func named(items []obj) map[string]string {
+ out := map[string]string{}
+ for _, it := range items {
+ name, _ := it["name"].(string)
+ if name == "" {
+ name, _ = it["title"].(string)
+ }
+ id, _ := it["id"].(string)
+ out[name] = id
+ }
+ return out
+}
+
+func (d *demo) run(ctx context.Context) error {
+ steps := []struct {
+ title string
+ fn func(context.Context) error
+ }{
+ {"Дашборд", d.dashboard},
+ {"Виды карты и реестра", d.views},
+ {"Правила алертов", d.rules},
+ {"Сценарии", d.scenarios},
+ {"Оценка локации", d.site},
+ {"Отчёты и расписания", d.reports},
+ }
+ var errs []error
+ for _, st := range steps {
+ d.logf("== %s", st.title)
+ if err := st.fn(ctx); err != nil {
+ d.logf(" ошибка: %v", err)
+ errs = append(errs, fmt.Errorf("%s: %w", st.title, err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// dashboard — набор по умолчанию и рейтинги двух сводных индексов.
+func (d *demo) dashboard(ctx context.Context) error {
+ if err := d.call(ctx, http.MethodDelete, "/me/dashboard", nil, nil); err != nil {
+ return err
+ }
+ var dash struct {
+ Widgets []obj `json:"widgets"`
+ }
+ if err := d.call(ctx, http.MethodGet, "/me/dashboard", nil, &dash); err != nil {
+ return err
+ }
+ have := map[string]bool{}
+ for _, w := range dash.Widgets {
+ if ix, ok := w["index"].(string); ok {
+ have[ix] = true
+ }
+ }
+ for _, ix := range []string{"index.accessibility", "index.restrictions"} {
+ if !have[ix] {
+ dash.Widgets = append(dash.Widgets, obj{"kind": "index", "index": ix, "level": "district"})
+ }
+ }
+ if err := d.call(ctx, http.MethodPut, "/me/dashboard", obj{"widgets": dash.Widgets}, nil); err != nil {
+ return err
+ }
+ d.logf(" виджетов: %d", len(dash.Widgets))
+ return nil
+}
+
+func (d *demo) views(ctx context.Context) error {
+ views := []obj{
+ {"page": "map", "name": "Объекты и дорожные работы", "params": obj{
+ "c": "37.62000,55.75000", "z": 10, "l": "facilities,facility_areas,rings,road_works",
+ }},
+ {"page": "map", "name": "Ограничения для грузовиков в центре", "params": obj{
+ "c": "37.62000,55.75500", "z": 12.5, "l": "facilities,rings,restrictions,cargo_frame",
+ }},
+ {"page": "map", "name": "Критичные мосты и устойчивость сети", "params": obj{
+ "c": "37.62000,55.74000", "z": 10.5, "l": "rings,critical_elements,resilience",
+ }},
+ {"page": "map", "name": "Складские площади по ячейкам H3", "params": obj{
+ "c": "37.62000,55.73000", "z": 10, "l": "rings,h3_cells,industrial_zones",
+ }},
+ {"page": "facilities", "name": "Крупные логистические центры", "params": obj{
+ "type": "logistics_center,terminal,sorting_center", "amin": 20000, "sort": "-area",
+ }},
+ {"page": "facilities", "name": "Склады до 15 минут от МКАД", "params": obj{
+ "type": "warehouse,cold_storage", "mkad": 15, "conf": "high",
+ }},
+ {"page": "facilities", "name": "Объекты с ограничениями подъезда", "params": obj{
+ "restr": "yes", "sort": "-area",
+ }},
+ }
+ for _, page := range []string{"map", "facilities"} {
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/views?page="+page, nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, v := range views {
+ if v["page"] != page {
+ continue
+ }
+ if _, ok := names[v["name"].(string)]; ok {
+ continue
+ }
+ if err := d.call(ctx, http.MethodPost, "/views", v, nil); err != nil {
+ return err
+ }
+ d.logf(" вид «%s»", v["name"])
+ }
+ }
+ return nil
+}
+
+// topFacilities — крупнейшие по площади логистические центры реестра.
+func (d *demo) topFacilities(ctx context.Context, n int) ([]string, error) {
+ var page struct {
+ Items []obj `json:"items"`
+ }
+ q := url.Values{"type": {"logistics_center"}, "sort": {"-area"}, "limit": {fmt.Sprint(n)}}
+ if err := d.call(ctx, http.MethodGet, "/facilities?"+q.Encode(), nil, &page); err != nil {
+ return nil, err
+ }
+ out := make([]string, 0, len(page.Items))
+ for _, it := range page.Items {
+ if id, ok := it["id"].(string); ok {
+ out = append(out, id)
+ }
+ }
+ return out, nil
+}
+
+func (d *demo) rules(ctx context.Context) error {
+ top, err := d.topFacilities(ctx, 8)
+ if err != nil {
+ return err
+ }
+ // Варшавское шоссе от ТТК до МКАД — коридор для правила по работам.
+ varshavka := obj{"type": "LineString", "coordinates": [][]float64{
+ {37.6195, 55.7065}, {37.6230, 55.6870}, {37.6250, 55.6600}, {37.6175, 55.6330}, {37.6060, 55.6000}, {37.5960, 55.5760},
+ }}
+ // Область в пределах Третьего транспортного кольца (контур по основным развязкам).
+ ttk := obj{"type": "Polygon", "coordinates": [][][]float64{{
+ {37.6170, 55.7920},
+ {37.6680, 55.7810},
+ {37.6870, 55.7600},
+ {37.6800, 55.7320},
+ {37.6400, 55.7080},
+ {37.5870, 55.7130},
+ {37.5560, 55.7440},
+ {37.5650, 55.7760},
+ {37.6170, 55.7920},
+ }}}
+ rules := []obj{
+ {
+ "name": "Перекрытия в пределах ТТК", "kind": "roadworks", "scope": obj{"type": "area", "polygon": ttk},
+ "severity": "critical",
+ "params": obj{"closureOnly": true, "changes": []string{"appeared", "started"}}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Работы у крупнейших логистических центров", "kind": "roadworks",
+ "scope": obj{"type": "facilities", "facilityIds": top, "radiusM": 1000}, "severity": "warning",
+ "params": obj{"changes": []string{"appeared", "changed", "started"}}, "channels": []string{"in_app", "email"},
+ },
+ {
+ "name": "Варшавское шоссе: работы на коридоре", "kind": "roadworks",
+ "scope": obj{"type": "corridor", "line": varshavka, "bufferM": 300}, "severity": "warning",
+ "params": obj{"changes": []string{"appeared", "started"}}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Доступность крупнейших центров до МКАД", "kind": "accessibility",
+ "scope": obj{"type": "facilities", "facilityIds": top, "radiusM": 500}, "severity": "critical",
+ "params": obj{"profile": "truck_20", "target": "mkad", "thresholdMin": 5}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Изменения ограничений для грузовиков в пределах ТТК", "kind": "restrictions",
+ "scope": obj{"type": "area", "polygon": ttk},
+ "severity": "info", "params": obj{}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Новые объекты в реестре", "kind": "facilities", "scope": obj{"type": "city"},
+ "severity": "info", "params": obj{}, "channels": []string{"in_app"},
+ },
+ {
+ "name": "Сбои и устаревание источников", "kind": "sources", "scope": obj{"type": "none"},
+ "severity": "warning", "params": obj{"staleFactor": 2}, "channels": []string{"in_app"},
+ },
+ }
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/alert-rules", nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, r := range rules {
+ id, ok := names[r["name"].(string)]
+ if !ok {
+ var created obj
+ if err := d.call(ctx, http.MethodPost, "/alert-rules", r, &created); err != nil {
+ return err
+ }
+ id, _ = created["id"].(string)
+ }
+ var check struct {
+ Hits int `json:"hits"`
+ }
+ if err := d.call(ctx, http.MethodPost, "/alert-rules/"+id+"/check", nil, &check); err != nil {
+ return err
+ }
+ d.logf(" правило «%s»: срабатываний %d", r["name"], check.Hits)
+ }
+ return nil
+}
+
+func (d *demo) scenarios(ctx context.Context) error {
+ var elems []obj
+ if err := d.call(ctx, http.MethodGet, "/scenarios/elements?limit=3", nil, &elems); err != nil {
+ return err
+ }
+ if len(elems) == 0 {
+ return errors.New("нет рейтинга критичности: сначала выполните сетевой анализ")
+ }
+ first := elems[0]
+ list := []obj{
+ {
+ "name": fmt.Sprintf("Перекрытие: %v", first["name"]),
+ "description": "Выпадение элемента с наибольшим ущербом в рейтинге критичности: как меняется время грузовика 20 т до МКАД и ТТК.",
+ "params": obj{
+ "profile": "truck_20", "zones": true, "targets": []string{"mkad", "ttk"},
+ "closures": []obj{{"kind": "element", "element": first["id"], "label": first["name"]}},
+ },
+ },
+ {
+ "name": "Новый логистический центр в промзоне Бирюлёво",
+ "description": "Объект у Липецкой улицы: доступность до центра и МКАД, изохроны 15 и 30 минут.",
+ "params": obj{
+ "profile": "truck_20", "zones": true, "targets": []string{"center", "mkad"},
+ "newFacility": obj{"name": "Логистический центр (проект)", "lon": 37.6720, "lat": 55.5880},
+ "isochroneMinutes": []int{15, 30},
+ },
+ },
+ }
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/scenarios", nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, sc := range list {
+ id, ok := names[sc["name"].(string)]
+ if !ok {
+ var created obj
+ if err := d.call(ctx, http.MethodPost, "/scenarios", sc, &created); err != nil {
+ return err
+ }
+ id, _ = created["id"].(string)
+ }
+ status, err := d.waitScenario(ctx, id)
+ if err != nil {
+ return err
+ }
+ d.logf(" сценарий «%s»: %s", sc["name"], status)
+ }
+ return nil
+}
+
+// waitScenario ждёт окончания расчёта сценария (до 10 минут).
+func (d *demo) waitScenario(ctx context.Context, id string) (string, error) {
+ deadline := time.Now().Add(10 * time.Minute)
+ for {
+ var sc struct {
+ Status string `json:"status"`
+ Error string `json:"error"`
+ }
+ if err := d.call(ctx, http.MethodGet, "/scenarios/"+id, nil, &sc); err != nil {
+ return "", err
+ }
+ switch sc.Status {
+ case "done":
+ return sc.Status, nil
+ case "failed":
+ return "", fmt.Errorf("сценарий %s: %s", id, sc.Error)
+ }
+ if time.Now().After(deadline) {
+ return sc.Status, nil
+ }
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case <-time.After(5 * time.Second):
+ }
+ }
+}
+
+func (d *demo) site(ctx context.Context) error {
+ const name = "Распределительный центр последней мили"
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/site-selection/runs", nil, &have); err != nil {
+ return err
+ }
+ if _, ok := named(have)[name]; ok {
+ return nil
+ }
+ params := obj{
+ "resolution": 8,
+ "criteria": []obj{
+ {"metric": "h3.access_mkad_min", "benefit": false, "weight": 3},
+ {"metric": "h3.population_5km", "benefit": true, "weight": 3},
+ {"metric": "h3.share_industrial", "benefit": true, "weight": 2},
+ {"metric": "h3.infra", "benefit": true, "weight": 1},
+ {"metric": "h3.restricted_km", "benefit": false, "weight": 2},
+ },
+ "weighting": "direct",
+ "exclusions": []string{"residential", "heritage", "parks", "green", "water"},
+ "exclusionShare": 50,
+ "top": 10,
+ "spacingKm": 2,
+ }
+ if err := d.call(ctx, http.MethodPost, "/site-selection/runs", obj{"name": name, "params": params}, nil); err != nil {
+ return err
+ }
+ d.logf(" оценка «%s»", name)
+ return nil
+}
+
+func (d *demo) reports(ctx context.Context) error {
+ var territories []obj
+ if err := d.call(ctx, http.MethodGet, "/territories?kind=district", nil, &territories); err != nil {
+ return err
+ }
+ district := ""
+ for _, t := range territories {
+ if n, _ := t["name"].(string); strings.Contains(strings.ToLower(n), "нагатинский затон") {
+ district, _ = t["id"].(string)
+ }
+ }
+ top, err := d.topFacilities(ctx, 1)
+ if err != nil {
+ return err
+ }
+ var scenarios, runs []obj
+ if err := d.call(ctx, http.MethodGet, "/scenarios", nil, &scenarios); err != nil {
+ return err
+ }
+ if err := d.call(ctx, http.MethodGet, "/site-selection/runs", nil, &runs); err != nil {
+ return err
+ }
+ reports := []obj{
+ {"template": "roadworks", "format": "pdf", "params": obj{"days": "14"}},
+ {"template": "quality", "format": "pdf"},
+ }
+ if district != "" {
+ reports = append(reports,
+ obj{"template": "territory", "format": "pdf", "params": obj{"territoryId": district}},
+ obj{"template": "passability", "format": "pdf", "params": obj{"territoryId": district, "profile": "truck_20"}},
+ )
+ }
+ if len(top) > 0 {
+ reports = append(reports,
+ obj{"template": "facility", "format": "pdf", "params": obj{"facilityId": top[0]}},
+ obj{"template": "facility", "format": "xlsx", "params": obj{"facilityId": top[0]}},
+ )
+ }
+ for _, sc := range scenarios {
+ if sc["status"] == "done" {
+ reports = append(reports, obj{"template": "scenario", "format": "pdf", "params": obj{"scenarioId": sc["id"]}})
+ break
+ }
+ }
+ if len(runs) > 0 {
+ reports = append(reports, obj{"template": "site", "format": "pdf", "params": obj{"siteRunId": runs[0]["id"]}})
+ }
+ // Уже сформированные отчёты (тот же шаблон, формат и параметры) повторно не заказываются.
+ var existing []obj
+ if err := d.call(ctx, http.MethodGet, "/reports", nil, &existing); err != nil {
+ return err
+ }
+ key := func(r obj) string {
+ b, _ := json.Marshal(r["params"])
+ if string(b) == "null" {
+ b = []byte("{}")
+ }
+ return fmt.Sprint(r["template"], "|", r["format"], "|", string(b))
+ }
+ done := map[string]bool{}
+ for _, r := range existing {
+ done[key(r)] = true
+ }
+ for _, r := range reports {
+ if done[key(r)] {
+ continue
+ }
+ var created obj
+ if err := d.call(ctx, http.MethodPost, "/reports", r, &created); err != nil {
+ return err
+ }
+ d.logf(" отчёт «%v»", created["title"])
+ }
+
+ schedules := []obj{
+ {
+ "template": "roadworks", "format": "pdf", "params": obj{"days": "7"}, "title": "Дорожные работы на неделю",
+ "frequency": "weekly", "weekday": 1, "hour": 8,
+ },
+ {
+ "template": "quality", "format": "xlsx", "title": "Качество данных за месяц",
+ "frequency": "monthly", "monthDay": 1, "hour": 9,
+ },
+ }
+ var have []obj
+ if err := d.call(ctx, http.MethodGet, "/report-schedules", nil, &have); err != nil {
+ return err
+ }
+ names := named(have)
+ for _, sc := range schedules {
+ if _, ok := names[sc["title"].(string)]; ok {
+ continue
+ }
+ if err := d.call(ctx, http.MethodPost, "/report-schedules", sc, nil); err != nil {
+ return err
+ }
+ d.logf(" расписание «%s»", sc["title"])
+ }
+ return nil
+}
diff --git a/internal/app/runtime.go b/internal/app/runtime.go
index 24de29c..a65db8f 100644
--- a/internal/app/runtime.go
+++ b/internal/app/runtime.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
+ "net/http/pprof"
"net/url"
"os"
"time"
@@ -80,6 +81,13 @@ func (rt *runtime) close(ctx context.Context) {
func (rt *runtime) serveMetrics(ctx context.Context, env *Env, addr string) error {
mux := http.NewServeMux()
mux.Handle("GET /metrics", rt.tel.Handler())
+ if env.Config.Pprof {
+ mux.HandleFunc("GET /debug/pprof/", pprof.Index)
+ mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
+ mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
+ mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
+ mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
+ }
return httpx.Serve(ctx, env.Log, "metrics", addr, mux)
}
diff --git a/internal/identity/password.go b/internal/identity/password.go
index f33a586..f49ac28 100644
--- a/internal/identity/password.go
+++ b/internal/identity/password.go
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"errors"
"fmt"
+ "slices"
"strings"
"unicode/utf8"
@@ -34,10 +35,72 @@ func ValidatePassword(password string) error {
return &ValidationError{Field: "password", Message: fmt.Sprintf("Пароль должен содержать не менее %d символов", minPasswordLen)}
case n > maxPasswordLen:
return &ValidationError{Field: "password", Message: fmt.Sprintf("Пароль должен содержать не более %d символов", maxPasswordLen)}
+ case weakPassword(password):
+ return &ValidationError{Field: "password", Message: "Пароль слишком простой: повтор одного символа или ряд клавиш или цифр"}
}
return nil
}
+// sequences — ряды, из которых составляют простые пароли: цифры, латиница и кириллица по
+// алфавиту и по клавиатуре.
+var sequences = []string{
+ "012345678901234567890", "abcdefghijklmnopqrstuvwxyz", "абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
+ "qwertyuiopasdfghjklzxcvbnm", "йцукенгшщзхъфывапролджэячсмитьбю", "1qaz2wsx3edc4rfv5tgb6yhn7ujm",
+}
+
+// weakPassword — пароль из одного повторяющегося символа или из отрезка (в любом направлении)
+// одного ряда, возможно повторённого: «aaaaaaaaaaaa», «123456789012», «qwertyqwerty».
+func weakPassword(password string) bool {
+ p := []rune(strings.ToLower(password))
+ if len(p) == 0 {
+ return false
+ }
+ // Повтор короткого фрагмента — проверяется сам фрагмент.
+ for k := 1; k <= len(p)/2; k++ {
+ periodic := true
+ for i := k; i < len(p) && periodic; i++ {
+ periodic = p[i] == p[i%k]
+ }
+ if periodic {
+ p = p[:k]
+ break
+ }
+ }
+ same := true
+ for _, r := range p[1:] {
+ same = same && r == p[0]
+ }
+ if same {
+ return true
+ }
+ for _, seq := range sequences {
+ s := []rune(seq)
+ rev := slices.Clone(s)
+ slices.Reverse(rev)
+ for _, row := range [][]rune{s, rev} {
+ if onRow(p, row) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// onRow — каждый следующий символ пароля стоит в ряду сразу за предыдущим (по кругу).
+func onRow(p, row []rune) bool {
+ pos := slices.Index(row, p[0])
+ if pos < 0 {
+ return false
+ }
+ for _, r := range p[1:] {
+ pos = (pos + 1) % len(row)
+ if row[pos] != r {
+ return false
+ }
+ }
+ return true
+}
+
// HashPassword возвращает хеш в формате PHC: $argon2id$v=19$m=…,t=…,p=…$соль$хеш.
func HashPassword(password string) string {
salt := make([]byte, argonSaltLen)
diff --git a/internal/identity/password_test.go b/internal/identity/password_test.go
index b6b13a9..1b1ea0b 100644
--- a/internal/identity/password_test.go
+++ b/internal/identity/password_test.go
@@ -49,7 +49,7 @@ func TestValidatePassword(t *testing.T) {
if err := ValidatePassword("короткий"); err == nil {
t.Error("short password must be rejected")
}
- if err := ValidatePassword(strings.Repeat("я", 12)); err != nil {
+ if err := ValidatePassword("ёжикитуманов"); err != nil {
t.Errorf("12 Cyrillic characters must be accepted: %v", err)
}
if err := ValidatePassword(strings.Repeat("a", 129)); err == nil {
@@ -103,3 +103,17 @@ func TestBuiltinRolesUseKnownPermissions(t *testing.T) {
}
}
}
+
+func TestWeakPasswordRejected(t *testing.T) {
+ t.Parallel()
+ for _, p := range []string{"aaaaaaaaaaaa", "123456789012", "210987654321", "qwertyuiopas", "QwertyQwerty", "йцукенгшщзхъ", "abcdefghijkl", "1qaz2wsx3edc"} {
+ if err := ValidatePassword(p); err == nil {
+ t.Errorf("%q: простой пароль принят", p)
+ }
+ }
+ for _, p := range []string{"надёжный пароль 2026", "пароль для тестов 1", "Correct horse battery"} {
+ if err := ValidatePassword(p); err != nil {
+ t.Errorf("%q: отклонён: %v", p, err)
+ }
+ }
+}
diff --git a/internal/identity/service.go b/internal/identity/service.go
index 68f1c27..ba69c35 100644
--- a/internal/identity/service.go
+++ b/internal/identity/service.go
@@ -254,12 +254,16 @@ func (s *Service) Principal(ctx context.Context, userID uuid.UUID) (Principal, e
}, nil
}
-// PasswordChangedAt возвращает время последней смены пароля: сессии, начатые раньше, недействительны.
-func (s *Service) PasswordChangedAt(ctx context.Context, userID uuid.UUID) (time.Time, error) {
+// SessionsValidAfter возвращает время, раньше которого сеансы пользователя недействительны:
+// последняя смена пароля или отзыв сеансов (сброс второго фактора администратором, блокировка).
+func (s *Service) SessionsValidAfter(ctx context.Context, userID uuid.UUID) (time.Time, error) {
u, err := s.q.GetUserByID(ctx, userID)
if err != nil {
return time.Time{}, mapNotFound(err)
}
+ if u.SessionsRevokedAt != nil && u.SessionsRevokedAt.After(u.PasswordChangedAt) {
+ return *u.SessionsRevokedAt, nil
+ }
return u.PasswordChangedAt, nil
}
@@ -333,6 +337,13 @@ func (s *Service) DisableTOTP(ctx context.Context, actor *Principal, userID uuid
if err := q.DeleteRecoveryCodes(ctx, userID); err != nil {
return fmt.Errorf("delete recovery codes: %w", err)
}
+ // Сброс чужого второго фактора (администратор, командная строка) завершает открытые сеансы
+ // пользователя: войти снова можно только паролем, и администратору — с подключением 2FA.
+ if actor == nil || actor.UserID != userID {
+ if err := q.RevokeSessions(ctx, userID); err != nil {
+ return fmt.Errorf("revoke sessions: %w", err)
+ }
+ }
return s.audit(ctx, q, actor, AuditEntry{Action: "auth.totp_disabled", EntityType: "user", EntityID: userID.String()}, meta)
})
}
diff --git a/internal/identity/service_test.go b/internal/identity/service_test.go
index 033b470..fd3d6d3 100644
--- a/internal/identity/service_test.go
+++ b/internal/identity/service_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"time"
+ "github.com/google/uuid"
"github.com/pquerna/otp/totp"
"freightflow/internal/platform/dbtest"
@@ -194,7 +195,7 @@ func TestInviteAndPasswordReset(t *testing.T) {
t.Fatalf("invite reuse: got %v", err)
}
- changedBefore, _ := s.PasswordChangedAt(ctx, u.ID)
+ changedBefore, _ := s.SessionsValidAfter(ctx, u.ID)
resetToken, _, ok, err := s.RequestPasswordReset(ctx, "new@example.com", RequestMeta{})
if err != nil || !ok {
t.Fatalf("request reset: ok=%v err=%v", ok, err)
@@ -202,7 +203,7 @@ func TestInviteAndPasswordReset(t *testing.T) {
if err := s.ResetPassword(ctx, resetToken, "совсем новый пароль", RequestMeta{}); err != nil {
t.Fatal(err)
}
- changedAfter, _ := s.PasswordChangedAt(ctx, u.ID)
+ changedAfter, _ := s.SessionsValidAfter(ctx, u.ID)
if !changedAfter.After(changedBefore) {
t.Fatal("password_changed_at must advance after reset")
}
@@ -218,3 +219,41 @@ func TestInviteAndPasswordReset(t *testing.T) {
t.Fatalf("audit records: %d %v", len(records), err)
}
}
+
+// Сброс второго фактора администратором и блокировка завершают открытые сеансы пользователя,
+// отключение 2FA самим пользователем — нет (его текущий сеанс остаётся).
+func TestSessionsRevokedByAdminReset(t *testing.T) {
+ t.Parallel()
+ s := newTestService(t)
+ ctx := context.Background()
+ admin := createUser(t, s, "root@example.com", RoleAdmin)
+ user := createUser(t, s, "user@example.com", RoleAnalyst)
+ other := createUser(t, s, "other@example.com", RoleAnalyst)
+ ap, err := s.Principal(ctx, admin.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ up, err := s.Principal(ctx, user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ advanced := func(id uuid.UUID, act func()) bool {
+ before, _ := s.SessionsValidAfter(ctx, id)
+ time.Sleep(10 * time.Millisecond)
+ act()
+ after, _ := s.SessionsValidAfter(ctx, id)
+ return after.After(before)
+ }
+ if advanced(user.ID, func() { _ = s.DisableTOTP(ctx, &up, user.ID, RequestMeta{}) }) {
+ t.Error("отключение 2FA самим пользователем не должно завершать его сеансы")
+ }
+ if !advanced(user.ID, func() { _ = s.DisableTOTP(ctx, &ap, user.ID, RequestMeta{}) }) {
+ t.Error("сброс 2FA администратором должен завершать сеансы пользователя")
+ }
+ if !advanced(user.ID, func() { _ = s.DisableTOTP(ctx, nil, user.ID, RequestMeta{}) }) {
+ t.Error("сброс 2FA из командной строки должен завершать сеансы пользователя")
+ }
+ if !advanced(other.ID, func() { _ = s.SetUserStatus(ctx, ap, other.ID, StatusBlocked, RequestMeta{}) }) {
+ t.Error("блокировка должна завершать сеансы пользователя")
+ }
+}
diff --git a/internal/identity/sqlc/models.go b/internal/identity/sqlc/models.go
index b78b7bd..cdbfae6 100644
--- a/internal/identity/sqlc/models.go
+++ b/internal/identity/sqlc/models.go
@@ -13,6 +13,144 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
+type AlertingAlert struct {
+ ID uuid.UUID
+ RuleID uuid.UUID
+ Entity string
+ Severity string
+ Status string
+ Title string
+ Summary string
+ Details json.RawMessage
+ Geom interface{}
+ Occurrences int32
+ AssigneeID *uuid.UUID
+ AcknowledgedBy *uuid.UUID
+ AcknowledgedAt *time.Time
+ ClosedBy *uuid.UUID
+ ClosedAt *time.Time
+ Resolution *string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ LastSeenAt time.Time
+}
+
+type AlertingAlertLog struct {
+ ID int64
+ AlertID uuid.UUID
+ At time.Time
+ ActorID *uuid.UUID
+ Action string
+ Comment *string
+ Details json.RawMessage
+}
+
+type AlertingHit struct {
+ RuleID uuid.UUID
+ Key string
+ AlertID *uuid.UUID
+ At time.Time
+}
+
+type AlertingRule struct {
+ ID uuid.UUID
+ OwnerID uuid.UUID
+ Name string
+ Kind string
+ Scope json.RawMessage
+ Area interface{}
+ Params json.RawMessage
+ Severity string
+ Channels []string
+ Enabled bool
+ State []byte
+ CheckedAt *time.Time
+ CheckError *string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// физическая доступность по сети: ограничения участков для профиля учитываются, зоны пропусков — нет (как с пропусками всех зон)
+type AnalyticsAccessibility struct {
+ FacilityID uuid.UUID
+ ProfileCode string
+ TargetKind string
+ Minutes *float32
+ Km *float32
+ TargetID *int32
+ GraphBuiltAt time.Time
+ ComputedAt time.Time
+}
+
+type AnalyticsAccessibilityTarget struct {
+ ID int32
+ Kind string
+ Name string
+ Point interface{}
+}
+
+type AnalyticsCellAccess struct {
+ H3 interface{}
+ TargetKind string
+ // время до ближайшей цели, мин; пусто — центр ячейки не привязан к сети или путь не найден
+ Minutes *float32
+ Km *float32
+ GraphBuiltAt time.Time
+}
+
+type AnalyticsDashboard struct {
+ OwnerID uuid.UUID
+ Widgets json.RawMessage
+ UpdatedAt time.Time
+}
+
+type AnalyticsIndexConfig struct {
+ IndexCode string
+ OwnerID *uuid.UUID
+ Weights json.RawMessage
+ Normalization string
+ UpdatedBy *uuid.UUID
+ UpdatedAt time.Time
+}
+
+type AnalyticsMetricDef struct {
+ Code string
+ Title string
+ Unit string
+ Levels []string
+ Formula string
+ Method string
+ Domains []string
+ Depends []string
+ Version int32
+ SyncedAt time.Time
+}
+
+type AnalyticsMetricState struct {
+ Code string
+ Signature string
+ Rows int32
+ DurationMs int32
+ ComputedAt time.Time
+}
+
+type AnalyticsMetricValue struct {
+ MetricCode string
+ Level string
+ Entity string
+ Period pgtype.Date
+ Value float64
+ Inputs json.RawMessage
+ ComputedAt time.Time
+}
+
+type AnalyticsPrepareState struct {
+ Step string
+ Signature string
+ Rows int32
+ PreparedAt time.Time
+}
+
type CoreApiToken struct {
ID uuid.UUID
UserID uuid.UUID
@@ -40,6 +178,7 @@ type CoreAppUser struct {
LastLoginAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
+ SessionsRevokedAt *time.Time
}
type CoreAuditLog struct {
@@ -87,6 +226,18 @@ type CoreRolePermission struct {
Permission string
}
+type CoreSavedView struct {
+ ID uuid.UUID
+ OwnerID uuid.UUID
+ Page string
+ Name string
+ // параметры адреса страницы
+ Params json.RawMessage
+ // общий вид для роли; пусто — личный
+ RoleCode *string
+ CreatedAt time.Time
+}
+
type CoreSession struct {
Token string
Data []byte
@@ -112,6 +263,32 @@ type CoreUserSetting struct {
UpdatedAt time.Time
}
+type InfraCommissioning struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ PermitNumber *string
+ PermitDate pgtype.Date
+ ObjectName *string
+ Address *string
+ AdmArea *string
+ District *string
+ WorkType *string
+ Purpose *string
+ Funding *string
+ AreaM2 pgtype.Numeric
+ LengthM pgtype.Numeric
+ // объект складского или логистического назначения (по назначению и названию)
+ Logistics bool
+ AddressUnom *int64
+ // по адресному реестру: точка здания с совпадающим адресом
+ Point interface{}
+ FacilityID *uuid.UUID
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
type InfraFacility struct {
ID uuid.UUID
TypeCode string
@@ -144,6 +321,49 @@ type InfraFacility struct {
FirstSeenAt time.Time
LastSeenAt time.Time
UpdatedAt time.Time
+ Territory3857 interface{}
+ AddressUnom *int64
+ // адрес по адресному реестру Москвы; address — из разметки OSM
+ OfficialAddress *string
+ // поля, значения которых заданы редактором
+ Curated []string
+}
+
+// правки реестра редактором; действует последняя неотменённая правка поля
+type InfraFacilityCuration struct {
+ ID uuid.UUID
+ FacilityID uuid.UUID
+ Field string
+ Value *string
+ // значение по источникам до первой правки поля — возвращается при отмене всех правок
+ SourceValue *string
+ Reason string
+ AuthorID *uuid.UUID
+ CreatedAt time.Time
+ RevokedAt *time.Time
+ RevokedBy *uuid.UUID
+}
+
+// сообщения о неточностях записи реестра — очередь редактора
+type InfraFacilityReport struct {
+ ID uuid.UUID
+ FacilityID uuid.UUID
+ AuthorID *uuid.UUID
+ Message string
+ Status string
+ Resolution *string
+ ResolvedBy *uuid.UUID
+ CreatedAt time.Time
+ ResolvedAt *time.Time
+}
+
+// address — объект адресного реестра (ref — UNOM); permit — разрешение на ввод (ref — global_id набора 64028)
+type InfraFacilitySource struct {
+ FacilityID uuid.UUID
+ Relation string
+ Ref string
+ Details json.RawMessage
+ MatchedAt time.Time
}
type InfraIndustrialZone struct {
@@ -170,12 +390,17 @@ type InfraPoi struct {
Point3857 interface{}
Area interface{}
Tags json.RawMessage
- OsmType string
- OsmID int64
+ OsmType *string
+ OsmID *int64
SourceCode string
SnapshotID *uuid.UUID
FirstSeenAt time.Time
LastSeenAt time.Time
+ MosDataset *int32
+ MosGlobalID *int64
+ // у записи OSM — совпадающая официальная запись портала Москвы; на карте показывается официальная
+ OfficialID *uuid.UUID
+ Address *string
}
type IngestDataVersion struct {
@@ -255,6 +480,13 @@ type IngestSource struct {
SyncedAt time.Time
}
+type MosStageRecord struct {
+ Dataset int32
+ GlobalID int64
+ Cells json.RawMessage
+ Geometry []byte
+}
+
type NetworkBridge struct {
ID uuid.UUID
SegmentID *uuid.UUID
@@ -270,6 +502,115 @@ type NetworkBridge struct {
SnapshotID *uuid.UUID
}
+type NetworkBridgeRegistry struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ MosID *int64
+ Name string
+ Location *string
+ Crossing *string
+ Commissioned *int16
+ District *string
+ Geom interface{}
+ OsmBridgeIds []uuid.UUID
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type NetworkEdgeMetric struct {
+ VersionID int32
+ EdgeID int32
+ // доля упорядоченных пар узлов, кратчайший по времени путь между которыми проходит через ребро
+ Centrality float64
+ CentralitySe float64
+ Rank int32
+ // место в рейтинге, доля рёбер (0 — первое)
+ Percentile float32
+ // центральность в графе без пределов массы и габаритов
+ FreeCentrality float64
+ FreeRank int32
+ FreePercentile float32
+ // узкое место: проезд закрыт пределом массы или габарита, без него через ребро шли бы пути
+ Bottleneck bool
+ // дополнительное время объезда узкого места, мин; пусто — объезда нет
+ DetourMin *float32
+}
+
+// мост (проезжие части одного сооружения) или участок дороги (линия OSM)
+type NetworkElementCriticality struct {
+ VersionID int32
+ ID int32
+ Kind string
+ Name string
+ SegmentIds []uuid.UUID
+ EdgeIds []int32
+ Centrality float64
+ // место по ущербу (прирост времени плюс 60 мин за объект без доступа): первые 30 по графу — по проверке матрицами, далее по графу
+ Rank int32
+ Facilities int32
+ // суммарный прирост времени до МКАД по объектам, объекто-минуты
+ DamageMin float32
+ MaxDelayMin float32
+ // объектов, теряющих доступ к МКАД
+ Disconnected int32
+ // проверено матрицами Valhalla с исключением участков элемента
+ Confirmed bool
+ ValhallaFacilities *int32
+ ValhallaDamageMin *float32
+ ValhallaMaxDelayMin *float32
+ ValhallaDisconnected *int32
+ Geom interface{}
+ Geom3857 interface{}
+}
+
+type NetworkGraphEdge struct {
+ VersionID int32
+ ID int32
+ SegmentID uuid.UUID
+ FromNode int32
+ ToNode int32
+ LengthM float32
+ // время проезда профилем, мин (без множителя участков «только для подъезда»)
+ Minutes float32
+ // проезд профилю по направлению линии открыт
+ Forward bool
+ Backward bool
+ // ограничение, закрывающее проезд профилю хотя бы в одном направлении (кроме одностороннего движения)
+ ClosedBy *string
+ ClosedValue *float32
+ // закрыто только пределом массы или габарита
+ DimsOnly bool
+ Destination bool
+ Geom interface{}
+ Geom3857 interface{}
+}
+
+type NetworkGraphNode struct {
+ VersionID int32
+ ID int32
+ Point interface{}
+}
+
+type NetworkGraphVersion struct {
+ ID int32
+ ProfileCode string
+ // версия домена network, по которой построен граф
+ NetworkVersion int64
+ BuiltAt time.Time
+ Nodes int32
+ Edges int32
+ Arcs int32
+ // доля узлов в наибольшей сильно связной компоненте
+ LargestComponent float32
+ // точность выборочной центральности для профиля (profile) и без пределов габаритов (free): источники, медиана относительной ошибки верхней сотни рёбер, ранговая устойчивость половин выборки
+ Accuracy json.RawMessage
+ DurationMs int32
+ // привязка объектов и точек МКАД к графу, число оценённых и проверенных матрицами элементов
+ Criticality json.RawMessage
+}
+
type NetworkRestriction struct {
ID uuid.UUID
SegmentID uuid.UUID
@@ -284,6 +625,48 @@ type NetworkRestriction struct {
Confidence float32
SourceCode string
SnapshotID *uuid.UUID
+ // знак, которым подтверждено ограничение (origin = sign)
+ SignID *uuid.UUID
+}
+
+type NetworkRestrictionConflict struct {
+ ID uuid.UUID
+ SegmentID uuid.UUID
+ Kind string
+ // sign_not_in_osm — знак есть, в OSM ограничения этого вида на участке нет; osm_not_signed — в OSM ограничение есть, знака этого вида рядом с участком нет
+ Type string
+ SignID *uuid.UUID
+ Details json.RawMessage
+ DetectedAt time.Time
+}
+
+type NetworkRestrictionEffective struct {
+ SegmentID uuid.UUID
+ Kind string
+ Origin string
+ Direction int16
+ Condition *string
+ Confidence float32
+ Value pgtype.Numeric
+ Origins interface{}
+}
+
+type NetworkRoadAsset struct {
+ ID uuid.UUID
+ Kind string
+ MosDataset int32
+ MosGlobalID int64
+ Name *string
+ Address *string
+ District *string
+ Attrs json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+ Status string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
}
type NetworkRoadSegment struct {
@@ -310,11 +693,234 @@ type NetworkRoadSegment struct {
LastSeenAt time.Time
}
+type NetworkRoadSign struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ MosID *int64
+ // номер знака по ПДД (3.11, 8.4.1); NULL — не распознан
+ Code *string
+ // тип знака как в наборе («3.11 Ограничение массы»)
+ SignType string
+ Temporary *bool
+ PlacedOn pgtype.Date
+ Location *string
+ AdmArea *string
+ District *string
+ Point interface{}
+ Point3857 interface{}
+ SegmentID *uuid.UUID
+ MatchDistanceM *float32
+ MatchStatus string
+ Status string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+ Plates []string
+}
+
+type NetworkSignWork struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ WorkType *string
+ Place *string
+ Contractor *string
+ WorkOn pgtype.Date
+ District *string
+ Point interface{}
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type NotifyConsumer struct {
+ Name string
+ LastXid pgtype.Uint64
+ LastID int64
+ UpdatedAt time.Time
+}
+
+type NotifyDelivery struct {
+ ID uuid.UUID
+ NotificationID *uuid.UUID
+ UserID uuid.UUID
+ Channel string
+ WebhookID *uuid.UUID
+ Event string
+ Payload json.RawMessage
+ Status string
+ Attempts int32
+ ResponseCode *int32
+ LastError *string
+ ScheduledAt time.Time
+ SentAt *time.Time
+ CreatedAt time.Time
+}
+
+type NotifyNotification struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Kind string
+ Severity string
+ Title string
+ Body string
+ Link *string
+ AlertID *uuid.UUID
+ ReadAt *time.Time
+ CreatedAt time.Time
+}
+
type NotifyOutbox struct {
ID int64
Type string
Payload json.RawMessage
CreatedAt time.Time
+ Xid pgtype.Uint64
+}
+
+type NotifyPreference struct {
+ UserID uuid.UUID
+ EmailMode string
+ DigestHour int16
+ QuietFrom *int16
+ QuietTo *int16
+ MinSeverity string
+ SubscribeAll bool
+ SubscribeSeverity string
+ UpdatedAt time.Time
+}
+
+type NotifyTelegramBinding struct {
+ UserID uuid.UUID
+ ChatID int64
+ Username *string
+ BoundAt time.Time
+}
+
+type NotifyTelegramCode struct {
+ CodeHash string
+ UserID uuid.UUID
+ ExpiresAt time.Time
+}
+
+type NotifyTelegramState struct {
+ ID bool
+ OffsetID int64
+}
+
+type NotifyWebhook struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Name string
+ Url string
+ Secret string
+ Enabled bool
+ CreatedAt time.Time
+}
+
+// ежедневные агрегаты для временных рядов (активные работы по видам и значимости)
+type OperationsDailySnapshot struct {
+ Day pgtype.Date
+ Metric string
+ Scope string
+ Value pgtype.Numeric
+}
+
+type OperationsIncident struct {
+ ID uuid.UUID
+ SourceID int64
+ OccurredAt time.Time
+ Category *string
+ // fatal — с погибшими; severe — тяжёлый вред здоровью; light — лёгкий; none — без пострадавших (нет данных)
+ Severity string
+ Dead int16
+ Injured int16
+ Participants int16
+ Light *string
+ Weather []string
+ RoadConditions []string
+ Nearby []string
+ Address *string
+ District *string
+ VehicleCategories []string
+ // участвовал грузовой автомобиль или грузовой фургон
+ Freight bool
+ // участвовал грузовик, тягач, самосвал, прицеп или спецтехника (без фургонов)
+ HeavyFreight bool
+ Point interface{}
+ Point3857 interface{}
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type OperationsRoadWork struct {
+ ID uuid.UUID
+ MosDataset int32
+ MosGlobalID int64
+ // repair — плановый ремонт (62101 и история 2016–2019), emergency — аварийно-восстановительный ремонт (62461), closure — ограничение движения на период работ (2527)
+ Kind string
+ Title string
+ Place *string
+ AdmArea *string
+ District *string
+ SourceStatus *string
+ PlannedStart pgtype.Date
+ PlannedEnd pgtype.Date
+ ActualStart pgtype.Date
+ ActualEnd pgtype.Date
+ // действие: фактические даты, а без них плановые; для перекрытий — время перекрытия
+ Period pgtype.Range[pgtype.Timestamptz]
+ Customer *string
+ Contractor *string
+ Closure bool
+ LanesClosed *string
+ Attrs json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+ // по сегодняшней дате и статусу источника; removed — запись исчезла с портала
+ State string
+ Significance *string
+ // хеш содержательных полей: изменение — событие «изменилась»
+ ContentHash string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+ ChangedAt time.Time
+ RemovedAt *time.Time
+}
+
+type OperationsRoadWorkEvent struct {
+ ID int64
+ WorkID uuid.UUID
+ At time.Time
+ Type string
+ Details json.RawMessage
+}
+
+type OperationsRoadWorkFacility struct {
+ WorkID uuid.UUID
+ FacilityID uuid.UUID
+ DistanceM float32
+}
+
+type OperationsRoadWorkImpact struct {
+ WorkID uuid.UUID
+ SegmentID uuid.UUID
+ Class string
+ Bridge bool
+ LengthM float32
+}
+
+// workday — рабочий; dayoff — выходной или праздничный; short — сокращённый предпраздничный; nonworking_paid — нерабочий оплачиваемый (указы 2020–2021 гг.)
+type RefCalendarDay struct {
+ Day pgtype.Date
+ Kind string
+ // год ещё не наступил: переносы выходных утверждаются постановлением Правительства, обычно осенью
+ Preliminary bool
+ SourceCode string
+ SnapshotID *uuid.UUID
}
type RefFacilityType struct {
@@ -325,6 +931,359 @@ type RefFacilityType struct {
Ord int16
}
+// act — правовой акт; amendment — акт о внесении изменений (amends — изменяемый акт); page — официальная страница органа власти
+type RefLegalDocument struct {
+ Code string
+ Kind string
+ Title string
+ Number *string
+ SignedOn pgtype.Date
+ EffectiveOn pgtype.Date
+ Authority string
+ Url *string
+ Status string
+ Amends *string
+ Summary *string
+ // дата сверки правил с текстом документа
+ VerifiedOn pgtype.Date
+}
+
+type RefRoadSignType struct {
+ Code string
+ Name string
+ SignGroup int16
+ // вид ограничения для грузового движения, если знак его вводит
+ RestrictionKind *string
+}
+
+type RefVehicleProfile struct {
+ Code string
+ Name string
+ // разрешённая максимальная масса, т
+ GrossT pgtype.Numeric
+ PayloadT pgtype.Numeric
+ AxleLoadT pgtype.Numeric
+ HeightM pgtype.Numeric
+ WidthM pgtype.Numeric
+ LengthM pgtype.Numeric
+ // экологический класс (0 — не указан)
+ EcoClass int16
+ Hazmat bool
+ Ord int16
+}
+
+type RegulationCargoFrameSegment struct {
+ SegmentID uuid.UUID
+ StreetID int32
+ Partial bool
+}
+
+type RegulationCargoFrameStreet struct {
+ ID int32
+ Okrug string
+ // строка перечня как опубликована
+ Entry string
+ Street string
+ // границы участка («от … до …»); участки сети отмечаются по всей улице в округе
+ Extent *string
+ // строка перечня — кольцо (СК, ТТК, МКАД): участки по линии кольца
+ Ring *string
+ Segments int32
+ LengthKm float64
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type RegulationOfficialPage struct {
+ Code string
+ Title string
+ Content string
+ ContentHash string
+ // хеш текста на момент сверки правил; отличие от content_hash — страница изменилась после сверки
+ VerifiedHash *string
+ FetchedAt time.Time
+ ChangedAt time.Time
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+// valid — действующих на первое число месяца; issued — начавших действовать в месяце; cancelled — аннулированных в месяце
+type RegulationPassStat struct {
+ Month pgtype.Date
+ Zone string
+ Series string
+ Metric string
+ Value int32
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type RegulationSpecialPermit struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ IssuedOn pgtype.Date
+ ValidFrom pgtype.Date
+ ValidTo pgtype.Date
+ Trips *int32
+ TransportType *string
+ CarrierType *string
+ LengthM pgtype.Numeric
+ WidthM pgtype.Numeric
+ HeightM pgtype.Numeric
+ MassEmptyT pgtype.Numeric
+ MassLoadedT pgtype.Numeric
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type RegulationZoneRule struct {
+ Code string
+ Zone string
+ Title string
+ Effect string
+ // gross — разрешённая максимальная масса; payload — грузоподъёмность (РММ минус масса без нагрузки)
+ MassBasis *string
+ MassOverT pgtype.Numeric
+ EcoClassMin *int16
+ // начало действия; интервал через полночь — time_from > time_to; NULL — круглосуточно
+ TimeFrom pgtype.Time
+ TimeTo pgtype.Time
+ PassKind *string
+ PassZones []string
+ ValidFrom pgtype.Date
+ ValidTo pgtype.Date
+ DocumentCode string
+ Clause *string
+ // правило сверено с официальным источником confirmed_by; неподтверждённые используются с предупреждением
+ Confirmed bool
+ ConfirmedBy *string
+ Note *string
+ Ord int16
+}
+
+type ReportExport struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Kind string
+ Format string
+ // фильтр и сортировка выборки на момент запроса
+ Params json.RawMessage
+ Status string
+ Rows *int32
+ // путь файла в хранилище (blob)
+ FilePath *string
+ FileSize *int64
+ Error *string
+ CreatedAt time.Time
+ FinishedAt *time.Time
+ ExpiresAt time.Time
+}
+
+type ReportReport struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Template string
+ Format string
+ Params json.RawMessage
+ Title string
+ Status string
+ FilePath *string
+ FileSize *int64
+ Pages *int32
+ Error *string
+ ScheduleID *uuid.UUID
+ CreatedAt time.Time
+ FinishedAt *time.Time
+ ExpiresAt time.Time
+}
+
+type ReportSchedule struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Template string
+ Format string
+ Params json.RawMessage
+ Title string
+ Frequency string
+ Weekday *int16
+ MonthDay *int16
+ Hour int16
+ Recipients []string
+ Enabled bool
+ NextRunAt time.Time
+ LastRunAt *time.Time
+ CreatedAt time.Time
+}
+
+type ScenarioFacilityDeltum struct {
+ ScenarioID uuid.UUID
+ Entity string
+ TargetKind string
+ BaseMin *float32
+ ScenarioMin *float32
+ BaseStatus string
+ ScenarioStatus string
+}
+
+type ScenarioIsochrone struct {
+ ScenarioID uuid.UUID
+ Origin string
+ Minutes int32
+ Variant string
+ Geom interface{}
+ AreaKm2 float64
+}
+
+type ScenarioResilience struct {
+ RunID int32
+ ElementID int32
+ Kind string
+ Name string
+ CriticalityRank int32
+ SegmentIds []uuid.UUID
+ Candidates int32
+ Affected int32
+ Lost int32
+ DelayMin float32
+ MaxDelayMin float32
+ Damage float32
+ Rank int32
+ // по виду цели: затронуто, потеряли доступ, прирост и наибольший прирост, ущерб
+ ByTarget json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+}
+
+type ScenarioResilienceRun struct {
+ ID int32
+ // версия сетевого анализа (network.element_criticality.version_id)
+ GraphVersion int32
+ GraphBuiltAt time.Time
+ ProfileCode string
+ Targets []string
+ // объекты в этом радиусе от элемента пересчитываются, дальние считаются незатронутыми
+ RadiusKm float32
+ Status string
+ Elements int32
+ Requests int32
+ Error *string
+ StartedAt time.Time
+ FinishedAt *time.Time
+}
+
+type ScenarioScenario struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Name string
+ Description string
+ // профиль ТС, учёт зон и пропуска, время, переопределения зон, перекрытия, новый объект, цели, изохроны
+ Params json.RawMessage
+ Status string
+ // ход расчёта: этап, выполнено и всего запросов
+ Progress json.RawMessage
+ // итог: затронутые объекты, прирост времени, потеря доступа, перекрытые участки
+ Summary []byte
+ Error *string
+ // время сборки графа маршрутизации, на котором посчитан сценарий
+ GraphBuiltAt *time.Time
+ CreatedAt time.Time
+ StartedAt *time.Time
+ FinishedAt *time.Time
+}
+
+type ScenarioSiteSelection struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Name string
+ Params json.RawMessage
+ Summary json.RawMessage
+ CreatedAt time.Time
+}
+
+type SearchDocument struct {
+ Kind string
+ Ref string
+ Title string
+ Subtitle *string
+ Body *string
+ Point interface{}
+ Weight float32
+ OwnerID *uuid.UUID
+ RoleCode *string
+ UpdatedAt time.Time
+ TitleNorm *string
+ SubtitleNorm *string
+ Tsv interface{}
+}
+
+type SearchQueryLog struct {
+ ID int64
+ UserID *uuid.UUID
+ Mode string
+ Query string
+ Filters json.RawMessage
+ Results int32
+ DurationMs int32
+ At time.Time
+}
+
+type SearchState struct {
+ Kind string
+ Signature string
+ Documents int32
+ DurationMs int32
+ IndexedAt time.Time
+}
+
+type StatisticsIndicator struct {
+ Code string
+ Name string
+ Unit string
+ Periodicity string
+ SourceCode string
+ Description *string
+}
+
+type StatisticsObservation struct {
+ IndicatorCode string
+ AreaID *uuid.UUID
+ AreaName string
+ // начало периода (год — 1 января)
+ Period pgtype.Date
+ Value pgtype.Numeric
+ SourceCode string
+ SnapshotID *uuid.UUID
+ UpdatedAt time.Time
+}
+
+// адресный реестр Москвы (набор 60562): основа адресов объектов и геокодера
+type TerritoryAddress struct {
+ Unom int64
+ MosGlobalID int64
+ Address string
+ SimpleAddress *string
+ ObjType *string
+ Municipal *string
+ AdmArea *string
+ Settlement *string
+ Street *string
+ House *string
+ Korpus *string
+ Building *string
+ Status *string
+ Fias *string
+ Cadastral []string
+ Geom interface{}
+ Point interface{}
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+ AddressKey *string
+}
+
type TerritoryAdminArea struct {
ID uuid.UUID
// region — Москва, Московская область; okrug — административный округ Москвы; municipal_district — городской округ области; district — район или поселение Москвы
@@ -344,6 +1303,57 @@ type TerritoryAdminArea struct {
LastSeenAt time.Time
}
+type TerritoryConstraintZone struct {
+ ID uuid.UUID
+ Kind string
+ MosDataset int32
+ MosGlobalID int64
+ Name *string
+ Attrs json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+ AreaM2 float64
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type TerritoryH3Cell struct {
+ H3 interface{}
+ Res int16
+ DistrictID *uuid.UUID
+ OkrugID *uuid.UUID
+ Geom interface{}
+ Geom3857 interface{}
+ AreaKm2 float64
+}
+
+type TerritoryLandCover struct {
+ ID uuid.UUID
+ Kind string
+ Name *string
+ Geom interface{}
+ AreaM2 float64
+ OsmType string
+ OsmID int64
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type TerritoryPopulation struct {
+ AreaID *uuid.UUID
+ AreaName string
+ Year int16
+ Value int32
+ // average — среднегодовая численность, january1 — на 1 января
+ Kind string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ // код ОКТМО муниципального образования (Росстат)
+ Oktmo *string
+}
+
type TerritoryRingZone struct {
Code string
Name string
diff --git a/internal/identity/sqlc/users.sql.go b/internal/identity/sqlc/users.sql.go
index d612872..d5b96f9 100644
--- a/internal/identity/sqlc/users.sql.go
+++ b/internal/identity/sqlc/users.sql.go
@@ -38,7 +38,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
const createUser = `-- name: CreateUser :one
INSERT INTO core.app_user (id, email, name, password_hash, status)
VALUES ($1, $2, $3, $4, $5)
-RETURNING id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at
+RETURNING id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at, sessions_revoked_at
`
type CreateUserParams struct {
@@ -72,6 +72,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CoreApp
&i.LastLoginAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.SessionsRevokedAt,
)
return i, err
}
@@ -104,7 +105,7 @@ func (q *Queries) EnableTOTP(ctx context.Context, id uuid.UUID) error {
}
const getUserByEmail = `-- name: GetUserByEmail :one
-SELECT id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at FROM core.app_user WHERE email = $1
+SELECT id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at, sessions_revoked_at FROM core.app_user WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (CoreAppUser, error) {
@@ -124,12 +125,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (CoreAppUser
&i.LastLoginAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.SessionsRevokedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
-SELECT id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at FROM core.app_user WHERE id = $1
+SELECT id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at, sessions_revoked_at FROM core.app_user WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (CoreAppUser, error) {
@@ -149,6 +151,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (CoreAppUser, e
&i.LastLoginAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.SessionsRevokedAt,
)
return i, err
}
@@ -184,7 +187,7 @@ func (q *Queries) InsertRecoveryCode(ctx context.Context, arg InsertRecoveryCode
}
const listUsers = `-- name: ListUsers :many
-SELECT u.id, u.email, u.name, u.password_hash, u.password_changed_at, u.status, u.totp_secret, u.totp_enabled, u.failed_logins, u.locked_until, u.last_login_at, u.created_at, u.updated_at,
+SELECT u.id, u.email, u.name, u.password_hash, u.password_changed_at, u.status, u.totp_secret, u.totp_enabled, u.failed_logins, u.locked_until, u.last_login_at, u.created_at, u.updated_at, u.sessions_revoked_at,
coalesce(array_agg(ur.role_code ORDER BY ur.role_code) FILTER (WHERE ur.role_code IS NOT NULL),
'{}')::text[] AS roles
FROM core.app_user u
@@ -207,6 +210,7 @@ type ListUsersRow struct {
LastLoginAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
+ SessionsRevokedAt *time.Time
Roles []string
}
@@ -233,6 +237,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
&i.LastLoginAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.SessionsRevokedAt,
&i.Roles,
); err != nil {
return nil, err
@@ -296,6 +301,15 @@ func (q *Queries) RehashPassword(ctx context.Context, arg RehashPasswordParams)
return err
}
+const revokeSessions = `-- name: RevokeSessions :exec
+UPDATE core.app_user SET sessions_revoked_at = now(), updated_at = now() WHERE id = $1
+`
+
+func (q *Queries) RevokeSessions(ctx context.Context, id uuid.UUID) error {
+ _, err := q.db.Exec(ctx, revokeSessions, id)
+ return err
+}
+
const setPasswordHash = `-- name: SetPasswordHash :exec
UPDATE core.app_user
SET password_hash = $2, password_changed_at = now(), failed_logins = 0, locked_until = NULL, updated_at = now()
@@ -327,7 +341,7 @@ func (q *Queries) SetTOTPSecret(ctx context.Context, arg SetTOTPSecretParams) er
}
const updateUserProfile = `-- name: UpdateUserProfile :one
-UPDATE core.app_user SET name = $2, updated_at = now() WHERE id = $1 RETURNING id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at
+UPDATE core.app_user SET name = $2, updated_at = now() WHERE id = $1 RETURNING id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at, sessions_revoked_at
`
type UpdateUserProfileParams struct {
@@ -352,12 +366,13 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.LastLoginAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.SessionsRevokedAt,
)
return i, err
}
const updateUserStatus = `-- name: UpdateUserStatus :one
-UPDATE core.app_user SET status = $2, updated_at = now() WHERE id = $1 RETURNING id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at
+UPDATE core.app_user SET status = $2, updated_at = now() WHERE id = $1 RETURNING id, email, name, password_hash, password_changed_at, status, totp_secret, totp_enabled, failed_logins, locked_until, last_login_at, created_at, updated_at, sessions_revoked_at
`
type UpdateUserStatusParams struct {
@@ -382,6 +397,7 @@ func (q *Queries) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusPara
&i.LastLoginAt,
&i.CreatedAt,
&i.UpdatedAt,
+ &i.SessionsRevokedAt,
)
return i, err
}
diff --git a/internal/identity/users.go b/internal/identity/users.go
index 52bb17d..c981782 100644
--- a/internal/identity/users.go
+++ b/internal/identity/users.go
@@ -142,6 +142,12 @@ func (s *Service) SetUserStatus(ctx context.Context, actor Principal, userID uui
if _, err := q.UpdateUserStatus(ctx, sqlc.UpdateUserStatusParams{ID: userID, Status: status}); err != nil {
return mapNotFound(err)
}
+ // Сеансы заблокированного не оживают и после разблокировки — вход заново.
+ if status == StatusBlocked {
+ if err := q.RevokeSessions(ctx, userID); err != nil {
+ return fmt.Errorf("revoke sessions: %w", err)
+ }
+ }
return s.audit(ctx, q, &actor, AuditEntry{
Action: "user.set_status", EntityType: "user", EntityID: userID.String(),
Details: map[string]any{"status": status},
diff --git a/internal/ingest/sqlc/models.go b/internal/ingest/sqlc/models.go
index b78b7bd..cdbfae6 100644
--- a/internal/ingest/sqlc/models.go
+++ b/internal/ingest/sqlc/models.go
@@ -13,6 +13,144 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
+type AlertingAlert struct {
+ ID uuid.UUID
+ RuleID uuid.UUID
+ Entity string
+ Severity string
+ Status string
+ Title string
+ Summary string
+ Details json.RawMessage
+ Geom interface{}
+ Occurrences int32
+ AssigneeID *uuid.UUID
+ AcknowledgedBy *uuid.UUID
+ AcknowledgedAt *time.Time
+ ClosedBy *uuid.UUID
+ ClosedAt *time.Time
+ Resolution *string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ LastSeenAt time.Time
+}
+
+type AlertingAlertLog struct {
+ ID int64
+ AlertID uuid.UUID
+ At time.Time
+ ActorID *uuid.UUID
+ Action string
+ Comment *string
+ Details json.RawMessage
+}
+
+type AlertingHit struct {
+ RuleID uuid.UUID
+ Key string
+ AlertID *uuid.UUID
+ At time.Time
+}
+
+type AlertingRule struct {
+ ID uuid.UUID
+ OwnerID uuid.UUID
+ Name string
+ Kind string
+ Scope json.RawMessage
+ Area interface{}
+ Params json.RawMessage
+ Severity string
+ Channels []string
+ Enabled bool
+ State []byte
+ CheckedAt *time.Time
+ CheckError *string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// физическая доступность по сети: ограничения участков для профиля учитываются, зоны пропусков — нет (как с пропусками всех зон)
+type AnalyticsAccessibility struct {
+ FacilityID uuid.UUID
+ ProfileCode string
+ TargetKind string
+ Minutes *float32
+ Km *float32
+ TargetID *int32
+ GraphBuiltAt time.Time
+ ComputedAt time.Time
+}
+
+type AnalyticsAccessibilityTarget struct {
+ ID int32
+ Kind string
+ Name string
+ Point interface{}
+}
+
+type AnalyticsCellAccess struct {
+ H3 interface{}
+ TargetKind string
+ // время до ближайшей цели, мин; пусто — центр ячейки не привязан к сети или путь не найден
+ Minutes *float32
+ Km *float32
+ GraphBuiltAt time.Time
+}
+
+type AnalyticsDashboard struct {
+ OwnerID uuid.UUID
+ Widgets json.RawMessage
+ UpdatedAt time.Time
+}
+
+type AnalyticsIndexConfig struct {
+ IndexCode string
+ OwnerID *uuid.UUID
+ Weights json.RawMessage
+ Normalization string
+ UpdatedBy *uuid.UUID
+ UpdatedAt time.Time
+}
+
+type AnalyticsMetricDef struct {
+ Code string
+ Title string
+ Unit string
+ Levels []string
+ Formula string
+ Method string
+ Domains []string
+ Depends []string
+ Version int32
+ SyncedAt time.Time
+}
+
+type AnalyticsMetricState struct {
+ Code string
+ Signature string
+ Rows int32
+ DurationMs int32
+ ComputedAt time.Time
+}
+
+type AnalyticsMetricValue struct {
+ MetricCode string
+ Level string
+ Entity string
+ Period pgtype.Date
+ Value float64
+ Inputs json.RawMessage
+ ComputedAt time.Time
+}
+
+type AnalyticsPrepareState struct {
+ Step string
+ Signature string
+ Rows int32
+ PreparedAt time.Time
+}
+
type CoreApiToken struct {
ID uuid.UUID
UserID uuid.UUID
@@ -40,6 +178,7 @@ type CoreAppUser struct {
LastLoginAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
+ SessionsRevokedAt *time.Time
}
type CoreAuditLog struct {
@@ -87,6 +226,18 @@ type CoreRolePermission struct {
Permission string
}
+type CoreSavedView struct {
+ ID uuid.UUID
+ OwnerID uuid.UUID
+ Page string
+ Name string
+ // параметры адреса страницы
+ Params json.RawMessage
+ // общий вид для роли; пусто — личный
+ RoleCode *string
+ CreatedAt time.Time
+}
+
type CoreSession struct {
Token string
Data []byte
@@ -112,6 +263,32 @@ type CoreUserSetting struct {
UpdatedAt time.Time
}
+type InfraCommissioning struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ PermitNumber *string
+ PermitDate pgtype.Date
+ ObjectName *string
+ Address *string
+ AdmArea *string
+ District *string
+ WorkType *string
+ Purpose *string
+ Funding *string
+ AreaM2 pgtype.Numeric
+ LengthM pgtype.Numeric
+ // объект складского или логистического назначения (по назначению и названию)
+ Logistics bool
+ AddressUnom *int64
+ // по адресному реестру: точка здания с совпадающим адресом
+ Point interface{}
+ FacilityID *uuid.UUID
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
type InfraFacility struct {
ID uuid.UUID
TypeCode string
@@ -144,6 +321,49 @@ type InfraFacility struct {
FirstSeenAt time.Time
LastSeenAt time.Time
UpdatedAt time.Time
+ Territory3857 interface{}
+ AddressUnom *int64
+ // адрес по адресному реестру Москвы; address — из разметки OSM
+ OfficialAddress *string
+ // поля, значения которых заданы редактором
+ Curated []string
+}
+
+// правки реестра редактором; действует последняя неотменённая правка поля
+type InfraFacilityCuration struct {
+ ID uuid.UUID
+ FacilityID uuid.UUID
+ Field string
+ Value *string
+ // значение по источникам до первой правки поля — возвращается при отмене всех правок
+ SourceValue *string
+ Reason string
+ AuthorID *uuid.UUID
+ CreatedAt time.Time
+ RevokedAt *time.Time
+ RevokedBy *uuid.UUID
+}
+
+// сообщения о неточностях записи реестра — очередь редактора
+type InfraFacilityReport struct {
+ ID uuid.UUID
+ FacilityID uuid.UUID
+ AuthorID *uuid.UUID
+ Message string
+ Status string
+ Resolution *string
+ ResolvedBy *uuid.UUID
+ CreatedAt time.Time
+ ResolvedAt *time.Time
+}
+
+// address — объект адресного реестра (ref — UNOM); permit — разрешение на ввод (ref — global_id набора 64028)
+type InfraFacilitySource struct {
+ FacilityID uuid.UUID
+ Relation string
+ Ref string
+ Details json.RawMessage
+ MatchedAt time.Time
}
type InfraIndustrialZone struct {
@@ -170,12 +390,17 @@ type InfraPoi struct {
Point3857 interface{}
Area interface{}
Tags json.RawMessage
- OsmType string
- OsmID int64
+ OsmType *string
+ OsmID *int64
SourceCode string
SnapshotID *uuid.UUID
FirstSeenAt time.Time
LastSeenAt time.Time
+ MosDataset *int32
+ MosGlobalID *int64
+ // у записи OSM — совпадающая официальная запись портала Москвы; на карте показывается официальная
+ OfficialID *uuid.UUID
+ Address *string
}
type IngestDataVersion struct {
@@ -255,6 +480,13 @@ type IngestSource struct {
SyncedAt time.Time
}
+type MosStageRecord struct {
+ Dataset int32
+ GlobalID int64
+ Cells json.RawMessage
+ Geometry []byte
+}
+
type NetworkBridge struct {
ID uuid.UUID
SegmentID *uuid.UUID
@@ -270,6 +502,115 @@ type NetworkBridge struct {
SnapshotID *uuid.UUID
}
+type NetworkBridgeRegistry struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ MosID *int64
+ Name string
+ Location *string
+ Crossing *string
+ Commissioned *int16
+ District *string
+ Geom interface{}
+ OsmBridgeIds []uuid.UUID
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type NetworkEdgeMetric struct {
+ VersionID int32
+ EdgeID int32
+ // доля упорядоченных пар узлов, кратчайший по времени путь между которыми проходит через ребро
+ Centrality float64
+ CentralitySe float64
+ Rank int32
+ // место в рейтинге, доля рёбер (0 — первое)
+ Percentile float32
+ // центральность в графе без пределов массы и габаритов
+ FreeCentrality float64
+ FreeRank int32
+ FreePercentile float32
+ // узкое место: проезд закрыт пределом массы или габарита, без него через ребро шли бы пути
+ Bottleneck bool
+ // дополнительное время объезда узкого места, мин; пусто — объезда нет
+ DetourMin *float32
+}
+
+// мост (проезжие части одного сооружения) или участок дороги (линия OSM)
+type NetworkElementCriticality struct {
+ VersionID int32
+ ID int32
+ Kind string
+ Name string
+ SegmentIds []uuid.UUID
+ EdgeIds []int32
+ Centrality float64
+ // место по ущербу (прирост времени плюс 60 мин за объект без доступа): первые 30 по графу — по проверке матрицами, далее по графу
+ Rank int32
+ Facilities int32
+ // суммарный прирост времени до МКАД по объектам, объекто-минуты
+ DamageMin float32
+ MaxDelayMin float32
+ // объектов, теряющих доступ к МКАД
+ Disconnected int32
+ // проверено матрицами Valhalla с исключением участков элемента
+ Confirmed bool
+ ValhallaFacilities *int32
+ ValhallaDamageMin *float32
+ ValhallaMaxDelayMin *float32
+ ValhallaDisconnected *int32
+ Geom interface{}
+ Geom3857 interface{}
+}
+
+type NetworkGraphEdge struct {
+ VersionID int32
+ ID int32
+ SegmentID uuid.UUID
+ FromNode int32
+ ToNode int32
+ LengthM float32
+ // время проезда профилем, мин (без множителя участков «только для подъезда»)
+ Minutes float32
+ // проезд профилю по направлению линии открыт
+ Forward bool
+ Backward bool
+ // ограничение, закрывающее проезд профилю хотя бы в одном направлении (кроме одностороннего движения)
+ ClosedBy *string
+ ClosedValue *float32
+ // закрыто только пределом массы или габарита
+ DimsOnly bool
+ Destination bool
+ Geom interface{}
+ Geom3857 interface{}
+}
+
+type NetworkGraphNode struct {
+ VersionID int32
+ ID int32
+ Point interface{}
+}
+
+type NetworkGraphVersion struct {
+ ID int32
+ ProfileCode string
+ // версия домена network, по которой построен граф
+ NetworkVersion int64
+ BuiltAt time.Time
+ Nodes int32
+ Edges int32
+ Arcs int32
+ // доля узлов в наибольшей сильно связной компоненте
+ LargestComponent float32
+ // точность выборочной центральности для профиля (profile) и без пределов габаритов (free): источники, медиана относительной ошибки верхней сотни рёбер, ранговая устойчивость половин выборки
+ Accuracy json.RawMessage
+ DurationMs int32
+ // привязка объектов и точек МКАД к графу, число оценённых и проверенных матрицами элементов
+ Criticality json.RawMessage
+}
+
type NetworkRestriction struct {
ID uuid.UUID
SegmentID uuid.UUID
@@ -284,6 +625,48 @@ type NetworkRestriction struct {
Confidence float32
SourceCode string
SnapshotID *uuid.UUID
+ // знак, которым подтверждено ограничение (origin = sign)
+ SignID *uuid.UUID
+}
+
+type NetworkRestrictionConflict struct {
+ ID uuid.UUID
+ SegmentID uuid.UUID
+ Kind string
+ // sign_not_in_osm — знак есть, в OSM ограничения этого вида на участке нет; osm_not_signed — в OSM ограничение есть, знака этого вида рядом с участком нет
+ Type string
+ SignID *uuid.UUID
+ Details json.RawMessage
+ DetectedAt time.Time
+}
+
+type NetworkRestrictionEffective struct {
+ SegmentID uuid.UUID
+ Kind string
+ Origin string
+ Direction int16
+ Condition *string
+ Confidence float32
+ Value pgtype.Numeric
+ Origins interface{}
+}
+
+type NetworkRoadAsset struct {
+ ID uuid.UUID
+ Kind string
+ MosDataset int32
+ MosGlobalID int64
+ Name *string
+ Address *string
+ District *string
+ Attrs json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+ Status string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
}
type NetworkRoadSegment struct {
@@ -310,11 +693,234 @@ type NetworkRoadSegment struct {
LastSeenAt time.Time
}
+type NetworkRoadSign struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ MosID *int64
+ // номер знака по ПДД (3.11, 8.4.1); NULL — не распознан
+ Code *string
+ // тип знака как в наборе («3.11 Ограничение массы»)
+ SignType string
+ Temporary *bool
+ PlacedOn pgtype.Date
+ Location *string
+ AdmArea *string
+ District *string
+ Point interface{}
+ Point3857 interface{}
+ SegmentID *uuid.UUID
+ MatchDistanceM *float32
+ MatchStatus string
+ Status string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+ Plates []string
+}
+
+type NetworkSignWork struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ WorkType *string
+ Place *string
+ Contractor *string
+ WorkOn pgtype.Date
+ District *string
+ Point interface{}
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type NotifyConsumer struct {
+ Name string
+ LastXid pgtype.Uint64
+ LastID int64
+ UpdatedAt time.Time
+}
+
+type NotifyDelivery struct {
+ ID uuid.UUID
+ NotificationID *uuid.UUID
+ UserID uuid.UUID
+ Channel string
+ WebhookID *uuid.UUID
+ Event string
+ Payload json.RawMessage
+ Status string
+ Attempts int32
+ ResponseCode *int32
+ LastError *string
+ ScheduledAt time.Time
+ SentAt *time.Time
+ CreatedAt time.Time
+}
+
+type NotifyNotification struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Kind string
+ Severity string
+ Title string
+ Body string
+ Link *string
+ AlertID *uuid.UUID
+ ReadAt *time.Time
+ CreatedAt time.Time
+}
+
type NotifyOutbox struct {
ID int64
Type string
Payload json.RawMessage
CreatedAt time.Time
+ Xid pgtype.Uint64
+}
+
+type NotifyPreference struct {
+ UserID uuid.UUID
+ EmailMode string
+ DigestHour int16
+ QuietFrom *int16
+ QuietTo *int16
+ MinSeverity string
+ SubscribeAll bool
+ SubscribeSeverity string
+ UpdatedAt time.Time
+}
+
+type NotifyTelegramBinding struct {
+ UserID uuid.UUID
+ ChatID int64
+ Username *string
+ BoundAt time.Time
+}
+
+type NotifyTelegramCode struct {
+ CodeHash string
+ UserID uuid.UUID
+ ExpiresAt time.Time
+}
+
+type NotifyTelegramState struct {
+ ID bool
+ OffsetID int64
+}
+
+type NotifyWebhook struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Name string
+ Url string
+ Secret string
+ Enabled bool
+ CreatedAt time.Time
+}
+
+// ежедневные агрегаты для временных рядов (активные работы по видам и значимости)
+type OperationsDailySnapshot struct {
+ Day pgtype.Date
+ Metric string
+ Scope string
+ Value pgtype.Numeric
+}
+
+type OperationsIncident struct {
+ ID uuid.UUID
+ SourceID int64
+ OccurredAt time.Time
+ Category *string
+ // fatal — с погибшими; severe — тяжёлый вред здоровью; light — лёгкий; none — без пострадавших (нет данных)
+ Severity string
+ Dead int16
+ Injured int16
+ Participants int16
+ Light *string
+ Weather []string
+ RoadConditions []string
+ Nearby []string
+ Address *string
+ District *string
+ VehicleCategories []string
+ // участвовал грузовой автомобиль или грузовой фургон
+ Freight bool
+ // участвовал грузовик, тягач, самосвал, прицеп или спецтехника (без фургонов)
+ HeavyFreight bool
+ Point interface{}
+ Point3857 interface{}
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type OperationsRoadWork struct {
+ ID uuid.UUID
+ MosDataset int32
+ MosGlobalID int64
+ // repair — плановый ремонт (62101 и история 2016–2019), emergency — аварийно-восстановительный ремонт (62461), closure — ограничение движения на период работ (2527)
+ Kind string
+ Title string
+ Place *string
+ AdmArea *string
+ District *string
+ SourceStatus *string
+ PlannedStart pgtype.Date
+ PlannedEnd pgtype.Date
+ ActualStart pgtype.Date
+ ActualEnd pgtype.Date
+ // действие: фактические даты, а без них плановые; для перекрытий — время перекрытия
+ Period pgtype.Range[pgtype.Timestamptz]
+ Customer *string
+ Contractor *string
+ Closure bool
+ LanesClosed *string
+ Attrs json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+ // по сегодняшней дате и статусу источника; removed — запись исчезла с портала
+ State string
+ Significance *string
+ // хеш содержательных полей: изменение — событие «изменилась»
+ ContentHash string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+ ChangedAt time.Time
+ RemovedAt *time.Time
+}
+
+type OperationsRoadWorkEvent struct {
+ ID int64
+ WorkID uuid.UUID
+ At time.Time
+ Type string
+ Details json.RawMessage
+}
+
+type OperationsRoadWorkFacility struct {
+ WorkID uuid.UUID
+ FacilityID uuid.UUID
+ DistanceM float32
+}
+
+type OperationsRoadWorkImpact struct {
+ WorkID uuid.UUID
+ SegmentID uuid.UUID
+ Class string
+ Bridge bool
+ LengthM float32
+}
+
+// workday — рабочий; dayoff — выходной или праздничный; short — сокращённый предпраздничный; nonworking_paid — нерабочий оплачиваемый (указы 2020–2021 гг.)
+type RefCalendarDay struct {
+ Day pgtype.Date
+ Kind string
+ // год ещё не наступил: переносы выходных утверждаются постановлением Правительства, обычно осенью
+ Preliminary bool
+ SourceCode string
+ SnapshotID *uuid.UUID
}
type RefFacilityType struct {
@@ -325,6 +931,359 @@ type RefFacilityType struct {
Ord int16
}
+// act — правовой акт; amendment — акт о внесении изменений (amends — изменяемый акт); page — официальная страница органа власти
+type RefLegalDocument struct {
+ Code string
+ Kind string
+ Title string
+ Number *string
+ SignedOn pgtype.Date
+ EffectiveOn pgtype.Date
+ Authority string
+ Url *string
+ Status string
+ Amends *string
+ Summary *string
+ // дата сверки правил с текстом документа
+ VerifiedOn pgtype.Date
+}
+
+type RefRoadSignType struct {
+ Code string
+ Name string
+ SignGroup int16
+ // вид ограничения для грузового движения, если знак его вводит
+ RestrictionKind *string
+}
+
+type RefVehicleProfile struct {
+ Code string
+ Name string
+ // разрешённая максимальная масса, т
+ GrossT pgtype.Numeric
+ PayloadT pgtype.Numeric
+ AxleLoadT pgtype.Numeric
+ HeightM pgtype.Numeric
+ WidthM pgtype.Numeric
+ LengthM pgtype.Numeric
+ // экологический класс (0 — не указан)
+ EcoClass int16
+ Hazmat bool
+ Ord int16
+}
+
+type RegulationCargoFrameSegment struct {
+ SegmentID uuid.UUID
+ StreetID int32
+ Partial bool
+}
+
+type RegulationCargoFrameStreet struct {
+ ID int32
+ Okrug string
+ // строка перечня как опубликована
+ Entry string
+ Street string
+ // границы участка («от … до …»); участки сети отмечаются по всей улице в округе
+ Extent *string
+ // строка перечня — кольцо (СК, ТТК, МКАД): участки по линии кольца
+ Ring *string
+ Segments int32
+ LengthKm float64
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type RegulationOfficialPage struct {
+ Code string
+ Title string
+ Content string
+ ContentHash string
+ // хеш текста на момент сверки правил; отличие от content_hash — страница изменилась после сверки
+ VerifiedHash *string
+ FetchedAt time.Time
+ ChangedAt time.Time
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+// valid — действующих на первое число месяца; issued — начавших действовать в месяце; cancelled — аннулированных в месяце
+type RegulationPassStat struct {
+ Month pgtype.Date
+ Zone string
+ Series string
+ Metric string
+ Value int32
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type RegulationSpecialPermit struct {
+ ID uuid.UUID
+ MosGlobalID int64
+ IssuedOn pgtype.Date
+ ValidFrom pgtype.Date
+ ValidTo pgtype.Date
+ Trips *int32
+ TransportType *string
+ CarrierType *string
+ LengthM pgtype.Numeric
+ WidthM pgtype.Numeric
+ HeightM pgtype.Numeric
+ MassEmptyT pgtype.Numeric
+ MassLoadedT pgtype.Numeric
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type RegulationZoneRule struct {
+ Code string
+ Zone string
+ Title string
+ Effect string
+ // gross — разрешённая максимальная масса; payload — грузоподъёмность (РММ минус масса без нагрузки)
+ MassBasis *string
+ MassOverT pgtype.Numeric
+ EcoClassMin *int16
+ // начало действия; интервал через полночь — time_from > time_to; NULL — круглосуточно
+ TimeFrom pgtype.Time
+ TimeTo pgtype.Time
+ PassKind *string
+ PassZones []string
+ ValidFrom pgtype.Date
+ ValidTo pgtype.Date
+ DocumentCode string
+ Clause *string
+ // правило сверено с официальным источником confirmed_by; неподтверждённые используются с предупреждением
+ Confirmed bool
+ ConfirmedBy *string
+ Note *string
+ Ord int16
+}
+
+type ReportExport struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Kind string
+ Format string
+ // фильтр и сортировка выборки на момент запроса
+ Params json.RawMessage
+ Status string
+ Rows *int32
+ // путь файла в хранилище (blob)
+ FilePath *string
+ FileSize *int64
+ Error *string
+ CreatedAt time.Time
+ FinishedAt *time.Time
+ ExpiresAt time.Time
+}
+
+type ReportReport struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Template string
+ Format string
+ Params json.RawMessage
+ Title string
+ Status string
+ FilePath *string
+ FileSize *int64
+ Pages *int32
+ Error *string
+ ScheduleID *uuid.UUID
+ CreatedAt time.Time
+ FinishedAt *time.Time
+ ExpiresAt time.Time
+}
+
+type ReportSchedule struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Template string
+ Format string
+ Params json.RawMessage
+ Title string
+ Frequency string
+ Weekday *int16
+ MonthDay *int16
+ Hour int16
+ Recipients []string
+ Enabled bool
+ NextRunAt time.Time
+ LastRunAt *time.Time
+ CreatedAt time.Time
+}
+
+type ScenarioFacilityDeltum struct {
+ ScenarioID uuid.UUID
+ Entity string
+ TargetKind string
+ BaseMin *float32
+ ScenarioMin *float32
+ BaseStatus string
+ ScenarioStatus string
+}
+
+type ScenarioIsochrone struct {
+ ScenarioID uuid.UUID
+ Origin string
+ Minutes int32
+ Variant string
+ Geom interface{}
+ AreaKm2 float64
+}
+
+type ScenarioResilience struct {
+ RunID int32
+ ElementID int32
+ Kind string
+ Name string
+ CriticalityRank int32
+ SegmentIds []uuid.UUID
+ Candidates int32
+ Affected int32
+ Lost int32
+ DelayMin float32
+ MaxDelayMin float32
+ Damage float32
+ Rank int32
+ // по виду цели: затронуто, потеряли доступ, прирост и наибольший прирост, ущерб
+ ByTarget json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+}
+
+type ScenarioResilienceRun struct {
+ ID int32
+ // версия сетевого анализа (network.element_criticality.version_id)
+ GraphVersion int32
+ GraphBuiltAt time.Time
+ ProfileCode string
+ Targets []string
+ // объекты в этом радиусе от элемента пересчитываются, дальние считаются незатронутыми
+ RadiusKm float32
+ Status string
+ Elements int32
+ Requests int32
+ Error *string
+ StartedAt time.Time
+ FinishedAt *time.Time
+}
+
+type ScenarioScenario struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Name string
+ Description string
+ // профиль ТС, учёт зон и пропуска, время, переопределения зон, перекрытия, новый объект, цели, изохроны
+ Params json.RawMessage
+ Status string
+ // ход расчёта: этап, выполнено и всего запросов
+ Progress json.RawMessage
+ // итог: затронутые объекты, прирост времени, потеря доступа, перекрытые участки
+ Summary []byte
+ Error *string
+ // время сборки графа маршрутизации, на котором посчитан сценарий
+ GraphBuiltAt *time.Time
+ CreatedAt time.Time
+ StartedAt *time.Time
+ FinishedAt *time.Time
+}
+
+type ScenarioSiteSelection struct {
+ ID uuid.UUID
+ UserID uuid.UUID
+ Name string
+ Params json.RawMessage
+ Summary json.RawMessage
+ CreatedAt time.Time
+}
+
+type SearchDocument struct {
+ Kind string
+ Ref string
+ Title string
+ Subtitle *string
+ Body *string
+ Point interface{}
+ Weight float32
+ OwnerID *uuid.UUID
+ RoleCode *string
+ UpdatedAt time.Time
+ TitleNorm *string
+ SubtitleNorm *string
+ Tsv interface{}
+}
+
+type SearchQueryLog struct {
+ ID int64
+ UserID *uuid.UUID
+ Mode string
+ Query string
+ Filters json.RawMessage
+ Results int32
+ DurationMs int32
+ At time.Time
+}
+
+type SearchState struct {
+ Kind string
+ Signature string
+ Documents int32
+ DurationMs int32
+ IndexedAt time.Time
+}
+
+type StatisticsIndicator struct {
+ Code string
+ Name string
+ Unit string
+ Periodicity string
+ SourceCode string
+ Description *string
+}
+
+type StatisticsObservation struct {
+ IndicatorCode string
+ AreaID *uuid.UUID
+ AreaName string
+ // начало периода (год — 1 января)
+ Period pgtype.Date
+ Value pgtype.Numeric
+ SourceCode string
+ SnapshotID *uuid.UUID
+ UpdatedAt time.Time
+}
+
+// адресный реестр Москвы (набор 60562): основа адресов объектов и геокодера
+type TerritoryAddress struct {
+ Unom int64
+ MosGlobalID int64
+ Address string
+ SimpleAddress *string
+ ObjType *string
+ Municipal *string
+ AdmArea *string
+ Settlement *string
+ Street *string
+ House *string
+ Korpus *string
+ Building *string
+ Status *string
+ Fias *string
+ Cadastral []string
+ Geom interface{}
+ Point interface{}
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+ AddressKey *string
+}
+
type TerritoryAdminArea struct {
ID uuid.UUID
// region — Москва, Московская область; okrug — административный округ Москвы; municipal_district — городской округ области; district — район или поселение Москвы
@@ -344,6 +1303,57 @@ type TerritoryAdminArea struct {
LastSeenAt time.Time
}
+type TerritoryConstraintZone struct {
+ ID uuid.UUID
+ Kind string
+ MosDataset int32
+ MosGlobalID int64
+ Name *string
+ Attrs json.RawMessage
+ Geom interface{}
+ Geom3857 interface{}
+ AreaM2 float64
+ SourceCode string
+ SnapshotID *uuid.UUID
+ FirstSeenAt time.Time
+ LastSeenAt time.Time
+}
+
+type TerritoryH3Cell struct {
+ H3 interface{}
+ Res int16
+ DistrictID *uuid.UUID
+ OkrugID *uuid.UUID
+ Geom interface{}
+ Geom3857 interface{}
+ AreaKm2 float64
+}
+
+type TerritoryLandCover struct {
+ ID uuid.UUID
+ Kind string
+ Name *string
+ Geom interface{}
+ AreaM2 float64
+ OsmType string
+ OsmID int64
+ SourceCode string
+ SnapshotID *uuid.UUID
+}
+
+type TerritoryPopulation struct {
+ AreaID *uuid.UUID
+ AreaName string
+ Year int16
+ Value int32
+ // average — среднегодовая численность, january1 — на 1 января
+ Kind string
+ SourceCode string
+ SnapshotID *uuid.UUID
+ // код ОКТМО муниципального образования (Росстат)
+ Oktmo *string
+}
+
type TerritoryRingZone struct {
Code string
Name string
diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go
index 08ed1d1..2c406e0 100644
--- a/internal/platform/config/config.go
+++ b/internal/platform/config/config.go
@@ -20,7 +20,9 @@ type Config struct {
MetricsAddr string `env:"FF_METRICS_ADDR" envDefault:"127.0.0.1:9090"`
// WorkerMetricsAddr — метрики отдельного процесса worker.
WorkerMetricsAddr string `env:"FF_WORKER_METRICS_ADDR" envDefault:"127.0.0.1:9091"`
- DevViteURL string `env:"FF_DEV_VITE_URL" envDefault:"http://localhost:5173"`
+ // Pprof — профилировщик /debug/pprof на адресе метрик (только для диагностики).
+ Pprof bool `env:"FF_PPROF" envDefault:"false"`
+ DevViteURL string `env:"FF_DEV_VITE_URL" envDefault:"http://localhost:5173"`
DatabaseURL string `env:"FF_DATABASE_URL,required"`
DataDir string `env:"FF_DATA_DIR" envDefault:"./var"`
diff --git a/internal/platform/db/db.go b/internal/platform/db/db.go
index 4e9d1cc..4d89cce 100644
--- a/internal/platform/db/db.go
+++ b/internal/platform/db/db.go
@@ -32,6 +32,9 @@ func Open(ctx context.Context, url string) (*pgxpool.Pool, error) {
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.ConnConfig.RuntimeParams["application_name"] = "freightflow"
cfg.ConnConfig.RuntimeParams["timezone"] = "Europe/Moscow"
+ // Без JIT: стоимость пространственных запросов оценивается с большим запасом, и компиляция
+ // (сотни миллисекунд) срабатывала на коротких запросах интерфейса дольше самого выполнения.
+ cfg.ConnConfig.RuntimeParams["jit"] = "off"
// Трассы и метрики запросов берут глобальные провайдеры OpenTelemetry (platform/telemetry).
cfg.ConnConfig.Tracer = otelpgx.NewTracer(
otelpgx.WithSpanNameCtxFunc(queryName),
diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go
index a9fa5f5..18fde01 100644
--- a/internal/scenario/scenario.go
+++ b/internal/scenario/scenario.go
@@ -277,8 +277,8 @@ type ClosureInfo struct {
// EntityTime — время до цели: база и сценарий (nil — недостижимо или в закрытой зоне).
type EntityTime struct {
Kind string `json:"kind"`
- BaseMin *float64 `json:"baseMin"`
- ScenarioMin *float64 `json:"scenarioMin"`
+ BaseMin *float64 `json:"baseMin,omitempty"`
+ ScenarioMin *float64 `json:"scenarioMin,omitempty"`
BaseStatus string `json:"baseStatus"`
ScenarioStatus string `json:"scenarioStatus"`
}
diff --git a/internal/territory/territory.go b/internal/territory/territory.go
index 137cd18..66ee2b1 100644
--- a/internal/territory/territory.go
+++ b/internal/territory/territory.go
@@ -100,7 +100,7 @@ const rowsFrom = `
) op ON true
LEFT JOIN LATERAL (
SELECT count(*)::int AS n, coalesce(sum(f.building_area_m2), 0)::float8 AS m2 FROM infra.facility f
- WHERE f.status = 'active' AND (CASE a.kind WHEN 'district' THEN f.district_id ELSE f.okrug_id END) = a.id
+ WHERE f.status = 'active' AND (a.kind = 'district' AND f.district_id = a.id OR a.kind <> 'district' AND f.okrug_id = a.id)
) fc ON true
LEFT JOIN LATERAL (
SELECT count(*)::int AS n FROM infra.poi x WHERE x.point && a.geom AND ST_Intersects(x.point, a.geom)
diff --git a/internal/tiles/auth.go b/internal/tiles/auth.go
index 382f3e6..1b5076a 100644
--- a/internal/tiles/auth.go
+++ b/internal/tiles/auth.go
@@ -17,7 +17,7 @@ import (
// Identity — проверка учётных записей (модуль доступа).
type Identity interface {
Principal(ctx context.Context, userID uuid.UUID) (identity.Principal, error)
- PasswordChangedAt(ctx context.Context, userID uuid.UUID) (time.Time, error)
+ SessionsValidAfter(ctx context.Context, userID uuid.UUID) (time.Time, error)
AuthenticateAPIToken(ctx context.Context, plain string) (identity.Principal, error)
}
@@ -72,7 +72,7 @@ func (a *Auth) Principal(r *http.Request) (identity.Principal, bool) {
if p, ok := a.cache.GetIfPresent(key); ok {
return p, true
}
- changedAt, err := a.ids.PasswordChangedAt(ctx, uid)
+ changedAt, err := a.ids.SessionsValidAfter(ctx, uid)
if err != nil || authAt.Before(changedAt.Add(-time.Second)) {
return identity.Principal{}, false
}
diff --git a/internal/tiles/data.go b/internal/tiles/data.go
index 7c52e3e..0760fb5 100644
--- a/internal/tiles/data.go
+++ b/internal/tiles/data.go
@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"log/slog"
+ "math"
"strconv"
"strings"
"sync"
@@ -54,10 +55,23 @@ func NewService(pool *pgxpool.Pool, versions VersionSource, log *slog.Logger) *S
}
}
-// Watch обновляет версии при публикации данных; работает до отмены контекста.
+// Watch обновляет версии при публикации данных и прогревает тайлы мелких масштабов — при
+// запуске и после каждой публикации; работает до отмены контекста.
func (s *Service) Watch(ctx context.Context, bus *events.Bus) error {
sub := bus.Subscribe(16, events.DataPublished)
defer sub.Close()
+ warm := make(chan struct{}, 1)
+ warm <- struct{}{}
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-warm:
+ s.Warm(ctx)
+ }
+ }
+ }()
for {
select {
case <-ctx.Done():
@@ -69,10 +83,61 @@ func (s *Service) Watch(ctx context.Context, bus *events.Bus) error {
s.mu.Lock()
s.loadedAt = time.Time{}
s.mu.Unlock()
+ // Публикации идут пачками: прогрев ставится один раз и подхватывает последние версии.
+ select {
+ case warm <- struct{}{}:
+ default:
+ }
}
}
}
+// warmBounds — рамка подложки (basemap.BBox): обзорные масштабы карты не выходят за неё.
+var warmBounds = [4]float64{36.30, 54.90, 39.00, 56.55}
+
+// warmMaxZoom — последний прогреваемый масштаб: дальше тайлов слишком много, а каждый строится
+// быстро.
+const warmMaxZoom = 11
+
+// Warm строит в кеш тайлы слоёв, включённых по умолчанию, на масштабах от их минимального до
+// warmMaxZoom: первый просмотр карты после запуска или обновления данных не ждёт сборки.
+func (s *Service) Warm(ctx context.Context) {
+ start := time.Now()
+ n := 0
+ for _, l := range Registry {
+ if !l.Visible {
+ continue
+ }
+ for zi := max(l.MinZoom, 0); zi <= warmMaxZoom && zi <= l.MaxZoom; zi++ {
+ z := uint8(zi)
+ x0, y0 := tileXY(warmBounds[0], warmBounds[3], z)
+ x1, y1 := tileXY(warmBounds[2], warmBounds[1], z)
+ for x := x0; x <= x1; x++ {
+ for y := y0; y <= y1; y++ {
+ if ctx.Err() != nil {
+ return
+ }
+ if _, _, err := s.Tile(ctx, l, z, x, y); err != nil {
+ s.log.WarnContext(ctx, "warm tile", "layer", l.Code, "z", z, "x", x, "y", y, "error", err)
+ continue
+ }
+ n++
+ }
+ }
+ }
+ }
+ s.log.InfoContext(ctx, "tiles warmed", "tiles", n, "duration", time.Since(start).Round(time.Millisecond))
+}
+
+// tileXY — номер тайла, содержащего точку, на масштабе z (веб-Меркатор).
+func tileXY(lon, lat float64, z uint8) (uint32, uint32) {
+ n := math.Exp2(float64(z))
+ r := lat * math.Pi / 180
+ x := (lon + 180) / 360 * n
+ y := (1 - math.Log(math.Tan(r)+1/math.Cos(r))/math.Pi) / 2 * n
+ return uint32(x), uint32(y)
+}
+
// domainVersions возвращает версии доменов, перечитывая их по событию или по сроку.
func (s *Service) domainVersions(ctx context.Context) (map[string]int64, error) {
s.mu.Lock()
diff --git a/internal/tiles/data_test.go b/internal/tiles/data_test.go
index 9430b79..d037562 100644
--- a/internal/tiles/data_test.go
+++ b/internal/tiles/data_test.go
@@ -136,7 +136,7 @@ func (f fakeIdentity) Principal(context.Context, uuid.UUID) (identity.Principal,
return f.p, nil
}
-func (fakeIdentity) PasswordChangedAt(context.Context, uuid.UUID) (time.Time, error) {
+func (fakeIdentity) SessionsValidAfter(context.Context, uuid.UUID) (time.Time, error) {
return time.Time{}, nil
}
@@ -205,3 +205,16 @@ func TestDataTileAccess(t *testing.T) {
t.Fatalf("headers: %v", resp.Header)
}
}
+
+func TestTileXY(t *testing.T) {
+ // Кремль на масштабе 10 — тайл 619/320 (схема XYZ, как у OpenStreetMap).
+ if x, y := tileXY(37.6176, 55.7520, 10); x != 619 || y != 320 {
+ t.Fatalf("tileXY = %d/%d, want 619/320", x, y)
+ }
+ // Северо-западный угол рамки — меньшие номера, чем юго-восточный.
+ x0, y0 := tileXY(warmBounds[0], warmBounds[3], 11)
+ x1, y1 := tileXY(warmBounds[2], warmBounds[1], 11)
+ if x0 >= x1 || y0 >= y1 {
+ t.Fatalf("рамка %d/%d — %d/%d", x0, y0, x1, y1)
+ }
+}
diff --git a/internal/tiles/layers.go b/internal/tiles/layers.go
index e963479..7b2d93a 100644
--- a/internal/tiles/layers.go
+++ b/internal/tiles/layers.go
@@ -18,7 +18,8 @@ type Layer struct {
// Domains — домены данных: их версии входят в адрес тайлов и ключ кеша.
Domains []string
Attribution string
- // Visible — включён по умолчанию.
+ // Visible — включён по умолчанию. При открытии карты — объекты, их контуры, кольца и работы на
+ // сегодня; остальное включается в «Слоях», чтобы карта не была перегружена.
Visible bool
Style Style
Properties []Property
@@ -137,8 +138,8 @@ var Registry = []Layer{
Code: "boundaries", Group: "territory", Title: "Округа и районы",
Description: "Границы Москвы, административных округов и районов", Geometry: "polygon",
MinZoom: 7, MaxZoom: 14, Permission: identity.PermMapView, Domains: []string{"territory"},
- Attribution: osmAttribution, Visible: true,
- Style: Style{Kind: "single", Color: "n10"},
+ Attribution: osmAttribution,
+ Style: Style{Kind: "single", Color: "n10"},
Properties: []Property{
{Name: "name", Title: "Название", Kind: "text"},
{Name: "kind", Title: "Уровень", Kind: "enum", Labels: map[string]string{
@@ -294,7 +295,7 @@ var Registry = []Layer{
Code: "restrictions", Group: "network", Title: "Ограничения для грузовиков",
Description: "Запреты и пределы массы и габаритов: знаки Москвы и разметка OSM", Geometry: "line",
MinZoom: 10, MaxZoom: 15, Permission: identity.PermMapView, Domains: []string{"network"},
- Attribution: mixedAttribution, Visible: true,
+ Attribution: mixedAttribution,
Style: Style{
Kind: "category", Property: "severity", DashedBy: "conditional",
Note: "Пунктир — ограничение действует по времени",
diff --git a/internal/web/help.go b/internal/web/help.go
index b2aed9a..f08d98f 100644
--- a/internal/web/help.go
+++ b/internal/web/help.go
@@ -50,7 +50,7 @@ var helpPages = []helpPage{
// helpAuth — справка доступна после входа, как и рабочее пространство.
func (h *handler) helpAuth(w http.ResponseWriter, r *http.Request) bool {
- if _, _, ok := h.Sessions.UserID(r.Context()); ok {
+ if _, ok := h.userID(r); ok {
return true
}
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
diff --git a/internal/web/help.templ b/internal/web/help.templ
index 481e81b..74c9da9 100644
--- a/internal/web/help.templ
+++ b/internal/web/help.templ
@@ -23,7 +23,11 @@ templ helpLayout(p page, current string) {
К содержанию
@@ -226,8 +230,8 @@ var guide = []guideSection{
{"Транспортная сеть", "/network", "Маршрут грузовика с учётом ограничений и зон, изохроны, матрица времени, сетевой анализ: значимость участков, узкие места, критичные элементы."},
{"Регулирование", "/regulation", "Правила зон ограничения с основаниями, грузовой каркас, статистика пропусков и спецразрешений."},
{"Аналитика", "/analytics", "Каталог показателей с картограммами, сводные индексы с весами, ряды и прогнозы, горячие точки и кластеры."},
- {"Сценарии", "/scenarios", "Расчёты «что если»: перекрытия, правила зон, новый объект — сравнение на двух картах; устойчивость сети."},
- {"Оценка локации", "/site-selection", "Подбор мест для нового объекта: критерии по ячейкам H3, исключающие зоны, веса (вручную или МАИ), рейтинг TOPSIS."},
+ {"Сценарии", "/scenarios", "Расчёты «что если»: перекрытия, правила зон, новый объект — сравнение на двух картах; если цель закрыта правилами зон для выбранного ТС, это указано в итогах; устойчивость сети."},
+ {"Оценка локации", "/site-selection", "Подбор мест для нового объекта: критерии по ячейкам H3, исключающие зоны, веса (вручную или МАИ), рейтинг TOPSIS; сохранённая оценка открывается сразу с картой пригодности и кандидатами."},
{"Отчёты", "/reports", "Отчёты PDF и XLSX по шаблонам, расписания с рассылкой на почту. Отчёт хранится неделю."},
{"API", "/api-docs", "Документация программного доступа, токены, вебхуки."},
{"Профиль", "/profile", "Имя, тема, пароль, двухфакторная защита, уведомления (почта, Telegram, вебхуки), токены API."},
diff --git a/internal/web/help_templ.go b/internal/web/help_templ.go
index 31111ce..bc4469a 100644
--- a/internal/web/help_templ.go
+++ b/internal/web/help_templ.go
@@ -83,7 +83,7 @@ func helpLayout(p page, current string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">
К содержанию
diff --git a/web/src/features/alerts/alerts.module.css b/web/src/features/alerts/alerts.module.css
index 0221284..a14a6f8 100644
--- a/web/src/features/alerts/alerts.module.css
+++ b/web/src/features/alerts/alerts.module.css
@@ -14,7 +14,7 @@
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(380px, 1.15fr);
gap: 16px;
- align-items: start;
+ align-items: stretch;
}
@media (max-width: 1100px) {
@@ -193,3 +193,8 @@
border: 0;
min-width: 0;
}
+
+/* Карточки в одном ряду сетки — без отступа сверху, чтобы верхние края совпадали. */
+.feed > * {
+ margin-top: 0;
+}
diff --git a/web/src/features/alerts/search.ts b/web/src/features/alerts/search.ts
new file mode 100644
index 0000000..bfdabef
--- /dev/null
+++ b/web/src/features/alerts/search.ts
@@ -0,0 +1,12 @@
+/** Состояние раздела алертов в адресе: вкладка и открытый алерт. */
+export interface AlertsSearch {
+ tab?: "feed" | "rules";
+ alert?: string;
+}
+
+export function alertsSearchSchema(search: Record
): AlertsSearch {
+ return {
+ tab: search.tab === "rules" ? "rules" : undefined,
+ alert: typeof search.alert === "string" ? search.alert : undefined,
+ };
+}
diff --git a/web/src/features/analytics/AnalyticsPage.tsx b/web/src/features/analytics/AnalyticsPage.tsx
index 0cd243f..ac14693 100644
--- a/web/src/features/analytics/AnalyticsPage.tsx
+++ b/web/src/features/analytics/AnalyticsPage.tsx
@@ -1,6 +1,5 @@
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { BookOpen } from "lucide-react";
-import { z } from "zod";
import { Guard } from "@/app/Guard";
import { PageHeader, Section } from "@/shared/ui";
import { Tabs } from "@/shared/ui/navigation";
@@ -9,18 +8,7 @@ import { MetricsPanel } from "./MetricsPanel";
import type { Level } from "./metrics";
import { SeriesPanel } from "./SeriesPanel";
import { SpatialPanel } from "./SpatialPanel";
-
-/** Состояние раздела в адресе: вкладка, уровень и показатель, индекс и его уровень, ряд. */
-export const analyticsSearchSchema = z.object({
- tab: z.enum(["metrics", "indexes", "series", "spatial"]).optional().catch(undefined),
- lv: z.enum(["district", "okrug", "h3_8", "h3_9", "facility", "segment"]).optional().catch(undefined),
- m: z.string().max(80).optional().catch(undefined),
- ix: z.string().max(80).optional().catch(undefined),
- il: z.enum(["district", "h3_8"]).optional().catch(undefined),
- sm: z.string().max(80).optional().catch(undefined),
-});
-
-type AnalyticsSearch = z.infer;
+import type { AnalyticsSearch } from "./search";
export function AnalyticsPage() {
return (
diff --git a/web/src/features/analytics/IndexPanel.tsx b/web/src/features/analytics/IndexPanel.tsx
index 325dc7b..1767e0b 100644
--- a/web/src/features/analytics/IndexPanel.tsx
+++ b/web/src/features/analytics/IndexPanel.tsx
@@ -141,6 +141,7 @@ export function IndexPanel({
Чувствительность: вес каждой составляющей ±20 %, корреляция Спирмена с исходными рангами
-
-
-
- | Составляющая |
- ρ при −20 % |
- ρ при +20 % |
- Наибольший сдвиг места |
-
-
-
- {res.data.sensitivity.map((x) => (
-
- | {title(x.ref)} |
- {num(x.rhoMinus, 3)} |
- {num(x.rhoPlus, 3)} |
- {num(x.maxShift)} |
+
+
+
+
+ | Составляющая |
+ ρ при −20 % |
+ ρ при +20 % |
+ Макс. сдвиг места |
- ))}
-
-
+
+
+ {res.data.sensitivity.map((x) => (
+
+ | {title(x.ref)} |
+ {num(x.rhoMinus, 3)} |
+ {num(x.rhoPlus, 3)} |
+ {num(x.maxShift)} |
+
+ ))}
+
+
+
diff --git a/web/src/features/analytics/analytics.module.css b/web/src/features/analytics/analytics.module.css
index 3ff558c..666e687 100644
--- a/web/src/features/analytics/analytics.module.css
+++ b/web/src/features/analytics/analytics.module.css
@@ -97,10 +97,15 @@
margin-bottom: 16px;
}
-.toolbar > * {
+/* Поля панели — не уже 200 px; кнопки-иконки (справка «?») — своего размера, по центру поля. */
+.toolbar > :not(button) {
min-width: 200px;
}
+.toolbar > button {
+ margin-bottom: 1px;
+}
+
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 520px);
@@ -125,8 +130,8 @@
padding: 0;
border: 0;
display: grid;
- grid-template-columns: minmax(0, 1fr) 110px;
- gap: 8px 12px;
+ grid-template-columns: minmax(0, 1fr) 120px;
+ gap: 14px 16px;
align-items: center;
margin-bottom: 12px;
}
@@ -209,3 +214,15 @@
text-decoration: underline;
text-underline-offset: 3px;
}
+
+/* Карточки в одном ряду сетки — без отступа сверху, чтобы верхние края совпадали. */
+.layout > * {
+ margin-top: 0;
+}
+
+/* Таблица в узкой колонке рядом с картой — прокручивается сама, не залезает под карту. */
+.tableScroll {
+ position: relative;
+ max-width: 100%;
+ overflow-x: auto;
+}
diff --git a/web/src/features/analytics/search.ts b/web/src/features/analytics/search.ts
new file mode 100644
index 0000000..a2ba162
--- /dev/null
+++ b/web/src/features/analytics/search.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+
+/** Состояние раздела в адресе: вкладка, уровень и показатель, индекс и его уровень, ряд. */
+export const analyticsSearchSchema = z.object({
+ tab: z.enum(["metrics", "indexes", "series", "spatial"]).optional().catch(undefined),
+ lv: z.enum(["district", "okrug", "h3_8", "h3_9", "facility", "segment"]).optional().catch(undefined),
+ m: z.string().max(80).optional().catch(undefined),
+ ix: z.string().max(80).optional().catch(undefined),
+ il: z.enum(["district", "h3_8"]).optional().catch(undefined),
+ sm: z.string().max(80).optional().catch(undefined),
+});
+
+export type AnalyticsSearch = z.infer;
diff --git a/web/src/features/apidocs/apidocs.module.css b/web/src/features/apidocs/apidocs.module.css
index f18f8a0..e33fc79 100644
--- a/web/src/features/apidocs/apidocs.module.css
+++ b/web/src/features/apidocs/apidocs.module.css
@@ -164,3 +164,8 @@
.columns > div {
min-width: 0;
}
+
+/* Карточки в одном ряду сетки — без отступа сверху, чтобы верхние края совпадали. */
+.columns > * {
+ margin-top: 0;
+}
diff --git a/web/src/features/dashboard/DashboardPage.tsx b/web/src/features/dashboard/DashboardPage.tsx
index 0a5af35..376bd1b 100644
--- a/web/src/features/dashboard/DashboardPage.tsx
+++ b/web/src/features/dashboard/DashboardPage.tsx
@@ -1,17 +1,37 @@
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
-import { Plus, RotateCcw, Settings2, X } from "lucide-react";
+import {
+ ChevronLeft,
+ ChevronRight,
+ GripVertical,
+ Plus,
+ RotateCcw,
+ Settings2,
+ SlidersHorizontal,
+ X,
+} from "lucide-react";
import { useState } from "react";
import { Guard } from "@/app/Guard";
import { $api, problemMessage, type Schemas } from "@/shared/api/client";
import { dateTime, num, plural } from "@/shared/format";
-import { Button, EmptyState, IconButton, Modal, PageHeader, Section, Select, Skeleton } from "@/shared/ui";
+import {
+ Button,
+ EmptyState,
+ IconButton,
+ iconButtonClass,
+ Modal,
+ PageHeader,
+ Section,
+ Select,
+ Skeleton,
+} from "@/shared/ui";
import { KpiTile } from "@/shared/ui/KpiTile";
+import { Tooltip } from "@/shared/ui/overlays";
import { toast } from "@/shared/ui/toast";
-import s from "../analytics/analytics.module.css";
import { MetricHelp } from "../analytics/MetricHelp";
import { type Level, levelTitles, metricValue, useMetrics } from "../analytics/metrics";
import { SeriesView } from "../analytics/SeriesPanel";
+import s from "./dashboard.module.css";
type Widget = Schemas["Widget"];
@@ -28,12 +48,15 @@ const widgetKey = (w: Widget) => `${w.kind}:${w.metric ?? ""}:${w.index ?? ""}:$
function Dashboard() {
const qc = useQueryClient();
const dash = $api.useQuery("get", "/me/dashboard");
+ const metrics = useMetrics();
const [editing, setEditing] = useState(false);
const [adding, setAdding] = useState(false);
const refresh = () => qc.invalidateQueries({ queryKey: ["get", "/me/dashboard"] });
const save = $api.useMutation("put", "/me/dashboard", {
- onSuccess: () => void refresh(),
- onError: (e) => toast.error(problemMessage(e)),
+ onError: (e) => {
+ toast.error(problemMessage(e));
+ void refresh();
+ },
});
const reset = $api.useMutation("delete", "/me/dashboard", {
onSuccess: () => {
@@ -42,7 +65,32 @@ function Dashboard() {
},
});
const widgets = dash.data?.widgets ?? [];
- const store = (next: Widget[]) => save.mutate({ body: { widgets: next } });
+ // Новый набор сразу записывается в кеш запроса: порядок не откатывается, пока сервер сохраняет;
+ // при ошибке набор перечитывается.
+ const store = (next: Widget[]) => {
+ qc.setQueriesData({ queryKey: ["get", "/me/dashboard"] }, (old) =>
+ old ? { ...old, widgets: next, own: true } : old,
+ );
+ save.mutate({ body: { widgets: next } });
+ };
+ const move = (from: number, to: number) => {
+ if (from === to || to < 0 || to >= widgets.length) {
+ return;
+ }
+ const next = [...widgets];
+ const [w] = next.splice(from, 1);
+ if (w) {
+ next.splice(to, 0, w);
+ store(next);
+ }
+ };
+ // Перетаскивание в режиме настройки: откуда взят виджет и над каким он сейчас.
+ const [dragFrom, setDragFrom] = useState(null);
+ const [dragOver, setDragOver] = useState(null);
+ const endDrag = () => {
+ setDragFrom(null);
+ setDragOver(null);
+ };
return (
<>
@@ -50,7 +98,7 @@ function Dashboard() {
title="Дашборд"
subtitle="Логистическая инфраструктура Москвы: показатели, динамика, рейтинги, обновления данных"
actions={
-
+ <>
{editing && (
<>
+ >
}
/>
+ {editing && (
+
+ Порядок карточек меняется перетаскиванием или стрелками на панели над карточкой. Изменения сохраняются сразу.
+
+ )}
{dash.isPending ? (
) : (
-
+
{widgets.map((w, i) => (
- store(widgets.filter((_, j) => j !== i)) : undefined}
- />
+ className={[
+ s.cell,
+ w.kind === "series" ? s.wide : "",
+ editing ? s.editing : "",
+ dragFrom === i ? s.dragging : "",
+ dragOver === i && dragFrom !== i ? s.dropTarget : "",
+ ].join(" ")}
+ draggable={editing}
+ onDragStart={(e) => {
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", String(i));
+ setDragFrom(i);
+ }}
+ onDragOver={(e) => {
+ if (dragFrom !== null) {
+ e.preventDefault();
+ setDragOver(i);
+ }
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ if (dragFrom !== null) {
+ move(dragFrom, i);
+ }
+ endDrag();
+ }}
+ onDragEnd={endDrag}
+ >
+
+ {editing && (
+ move(i, i + d)}
+ onRemove={() => store(widgets.filter((_, j) => j !== i))}
+ />
+ )}
+
))}
)}
-
-
-
+
{adding && (
setAdding(false)}
onAdd={(w) => {
setAdding(false);
@@ -99,38 +194,79 @@ function Dashboard() {
);
}
+/**
+ * Кнопки виджета в режиме настройки — панелью на верхней кромке карточки, чтобы не теснить
+ * заголовок: перетаскивание, сдвиг раньше и позже (с клавиатуры), удаление.
+ */
+function EditTools({
+ title,
+ first,
+ last,
+ onMove,
+ onRemove,
+}: {
+ title: string;
+ first: boolean;
+ last: boolean;
+ onMove: (delta: number) => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+
+ onMove(-1)}>
+
+
+ onMove(1)}>
+
+
+
+
+
+
+ );
+}
+
+const kindTitles: Record = {
+ kpi: "Плитка",
+ series: "Ряд",
+ rating: "Рейтинг",
+ index: "Индекс",
+};
+
+/** Подпись виджета для панели настройки: вид и название показателя. */
+function widgetTitle(w: Widget, defs: Map): string {
+ const name = w.metric ? (defs.get(w.metric)?.title ?? w.metric) : (w.index ?? "");
+ return `${kindTitles[w.kind]}: ${name}`;
+}
+
/** Значения показателей города для плиток. */
function useCity() {
return $api.useQuery("get", "/metrics/entity", { params: { query: { level: "city", entity: "moscow" } } });
}
-function WidgetCard({ w, onRemove }: { w: Widget; onRemove?: () => void }) {
+function WidgetCard({ w }: { w: Widget }) {
const metrics = useMetrics();
const def = w.metric ? metrics.byCode.get(w.metric) : undefined;
- const remove = onRemove && (
-
-
-
- );
if (w.kind === "kpi") {
- return ;
+ return ;
}
if (w.kind === "series") {
return (
-
-
-
+
);
}
if (w.kind === "index") {
- return ;
+ return ;
}
- return ;
+ return ;
}
-function KpiWidget({ code, remove }: { code: string; remove?: React.ReactNode }) {
+function KpiWidget({ code }: { code: string }) {
const metrics = useMetrics();
const city = useCity();
const def = metrics.byCode.get(code);
@@ -140,22 +276,19 @@ function KpiWidget({ code, remove }: { code: string; remove?: React.ReactNode })
}
const model = code === "territory.population_coverage" || code.startsWith("h3.population");
return (
-
-
- {remove &&
{remove}
}
-
+
);
}
-function RatingWidget({ code, level, remove }: { code: string; level: Level; remove?: React.ReactNode }) {
+function RatingWidget({ code, level }: { code: string; level: Level }) {
const metrics = useMetrics();
const def = metrics.byCode.get(code);
const q = $api.useQuery("get", "/metrics/{code}", { params: { path: { code }, query: { level, limit: 8 } } });
@@ -164,12 +297,7 @@ function RatingWidget({ code, level, remove }: { code: string; level: Level; rem
-
- {remove}
- >
- }
+ actions={}
>
{q.isPending ? (
@@ -180,7 +308,7 @@ function RatingWidget({ code, level, remove }: { code: string; level: Level; rem
{values.map((v) => (
- {metricValue(v.value, def, false)}
+ {metricValue(v.value, def, false)}
))}
@@ -207,7 +335,7 @@ function EntityLink({ level, entity, label }: { level: string; entity: string; l
return {label};
}
-function IndexWidget({ code, level, remove }: { code: string; level: "district" | "h3_8"; remove?: React.ReactNode }) {
+function IndexWidget({ code, level }: { code: string; level: "district" | "h3_8" }) {
const list = $api.useQuery("get", "/indexes", {}, { staleTime: 60_000 });
const ix = list.data?.find((x) => x.code === code);
const q = $api.useQuery("post", "/indexes/{code}/compute", { params: { path: { code } }, body: { level } });
@@ -216,12 +344,16 @@ function IndexWidget({ code, level, remove }: { code: string; level: "district"
title={ix?.title ?? "Индекс"}
description={`${levelTitles[level]}, 0–100`}
actions={
- <>
-
- Настроить веса
+
+
+
- {remove}
- >
+
}
>
{q.isPending ? (
@@ -231,7 +363,7 @@ function IndexWidget({ code, level, remove }: { code: string; level: "district"
{(q.data?.scores ?? []).slice(0, 8).map((x) => (
- {num(x.score, 1)}
+ {num(x.score, 1)}
))}
@@ -250,12 +382,16 @@ function Attention() {
body: { level: "district" },
});
const crit = $api.useQuery("get", "/metrics/{code}", {
- params: { path: { code: "segment.criticality" }, query: { level: "segment", limit: 5 } },
+ params: { path: { code: "segment.criticality" }, query: { level: "segment", limit: 20 } },
});
const busy = (works.data?.values ?? []).filter((v) => v.value > 0);
+ // Участки одной улицы (две проезжие части, соседние перегоны) — одной строкой с наибольшим ростом.
+ const critical = [...new Map([...(crit.data?.values ?? [])].reverse().map((v) => [v.label, v])).values()]
+ .sort((a, b) => b.value - a.value)
+ .slice(0, 5);
return (
- Объекты с идущими дорожными работами в 500 м
+ Объекты с идущими дорожными работами в 500 м
{busy.length === 0 ? (
Сейчас таких нет.
) : (
@@ -263,26 +399,26 @@ function Attention() {
{busy.map((v) => (
- {plural(v.value, "работа", "работы", "работ")}
+ {plural(v.value, "работа", "работы", "работ")}
))}
)}
- Районы с наибольшим индексом ограничений
+ Районы с наибольшим индексом ограничений
{(restr.data?.scores ?? []).slice(0, 5).map((x) => (
-
- {num(x.score, 1)}
+ {num(x.score, 1)}
))}
- Критичные мосты и участки (рост времени доступа при выпадении)
+ Критичные мосты и участки — рост времени доступа при выпадении
- {(crit.data?.values ?? []).map((v) => (
+ {critical.map((v) => (
-
{v.label}
- {num(v.value)} мин
+ {num(v.value)} мин
))}
@@ -299,16 +435,16 @@ function Updates() {
) : (q.data ?? []).length === 0 ? (
Загрузки ещё не выполнялись.
) : (
-
+
{(q.data ?? []).map((u) => (
- -
+
-
{u.title}
· {u.publisher}
{dateTime(u.finishedAt)}
- {u.records !== undefined && ` · ${num(u.records)} записей`}
+ {u.records !== undefined && ` · ${plural(u.records, "запись", "записи", "записей")}`}
))}
@@ -325,7 +461,16 @@ const kinds = [
{ id: "index", label: "Рейтинг индекса" },
];
-function AddWidget({ onAdd, onClose }: { onAdd: (w: Widget) => void; onClose: () => void }) {
+function AddWidget({
+ present,
+ onAdd,
+ onClose,
+}: {
+ /** Ключи виджетов, уже стоящих на дашборде: повторно не добавляются. */
+ present: Set;
+ onAdd: (w: Widget) => void;
+ onClose: () => void;
+}) {
const metrics = useMetrics();
const indexes = $api.useQuery("get", "/indexes", {}, { staleTime: 60_000 });
const [kind, setKind] = useState("kpi");
@@ -333,15 +478,27 @@ function AddWidget({ onAdd, onClose }: { onAdd: (w: Widget) => void; onClose: ()
const [index, setIndex] = useState(null);
const [level, setLevel] = useState("district");
const all = metrics.data ?? [];
- const metricOptions =
+ const candidate = (): Widget =>
+ kind === "index"
+ ? { kind, index: index ?? "", level }
+ : kind === "rating"
+ ? { kind, metric: metric ?? "", level }
+ : { kind, metric: metric ?? "" };
+ const isPresent = (w: Widget) => present.has(widgetKey(w));
+ const metricOptions = (
kind === "kpi"
? all.filter((m) => m.levels.includes("city") && !m.code.startsWith("city."))
: kind === "series"
? all.filter((m) => m.code.startsWith("city."))
- : all.filter((m) => m.levels.includes(level) && !m.code.startsWith("city."));
+ : all.filter((m) => m.levels.includes(level) && !m.code.startsWith("city."))
+ ).filter((m) => !isPresent(kind === "rating" ? { kind, metric: m.code, level } : { kind, metric: m.code }));
+ const indexOptions = (indexes.data ?? []).filter((x) => !isPresent({ kind: "index", index: x.code, level }));
const levels: Level[] =
kind === "index" ? ["district", "h3_8"] : ["district", "okrug", "facility", "h3_8", "segment"];
- const ok = kind === "index" ? !!index : !!metric && metricOptions.some((m) => m.code === metric);
+ const ok =
+ kind === "index"
+ ? indexOptions.some((x) => x.code === index)
+ : !!metric && metricOptions.some((m) => m.code === metric);
return (
void; onClose: ()
actions={
<>
Отмена
-
- onAdd(
- kind === "index"
- ? { kind, index: index ?? "", level }
- : kind === "rating"
- ? { kind, metric: metric ?? "", level }
- : { kind, metric: metric ?? "" },
- )
- }
- >
+ onAdd(candidate())}>
Добавить
>
}
>
-
+