Compare commits
10
Commits
e62dbfafba
...
b745c324e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b745c324e3 | ||
|
|
93e9100ae4 | ||
|
|
206d61068a | ||
|
|
355723f4ad | ||
|
|
fd64c11441 | ||
|
|
a7e38846d7 | ||
|
|
713d554dc2 | ||
|
|
39c9bdc1b7 | ||
|
|
dde86fd63c | ||
|
|
f4737b9797 |
+4
-2
@@ -2,7 +2,8 @@ root = true
|
|||||||
|
|
||||||
[*]
|
[*]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = crlf
|
# LF: пишут инструменты проекта (Python/Node), требуется shell-скриптам на Linux CI
|
||||||
|
end_of_line = lf
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
@@ -30,7 +31,8 @@ dotnet_style_qualification_for_method = false:warning
|
|||||||
dotnet_style_qualification_for_event = false:warning
|
dotnet_style_qualification_for_event = false:warning
|
||||||
|
|
||||||
# Члены
|
# Члены
|
||||||
csharp_style_var_for_built_in_types = false:silent
|
# var — запрещён для встроенных типов (ломает сборку), для очевидных/прочих — silent (§4 код-стайла)
|
||||||
|
csharp_style_var_for_built_in_types = false:warning
|
||||||
csharp_style_var_when_type_is_apparent = false:silent
|
csharp_style_var_when_type_is_apparent = false:silent
|
||||||
csharp_style_var_elsewhere = false:silent
|
csharp_style_var_elsewhere = false:silent
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Нормализация концов строк: в репозитории и рабочей копии — LF.
|
||||||
|
# Решение 2026-09-11 (backlog TD-STYLE-ANALYZERS): Python/Node-инструменты проекта пишут LF,
|
||||||
|
# shell-скрипты на Linux CI не работают с CRLF, фактическое большинство файлов — LF.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Явно бинарные (на всякий случай, auto-детект и так их не трогает)
|
||||||
|
*.docx binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
|
*.ttf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.eot binary
|
||||||
|
*.pyc binary
|
||||||
@@ -30,3 +30,7 @@ deploy/certs/
|
|||||||
# === Рантайм-данные (БД, объектное хранилище, ключи шифрования) ===
|
# === Рантайм-данные (БД, объектное хранилище, ключи шифрования) ===
|
||||||
archive/leadradar-legacy/data/
|
archive/leadradar-legacy/data/
|
||||||
src/core/Deal.Api/data/
|
src/core/Deal.Api/data/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Ledger: codestyle-residue (2026-09-11, вечер)
|
||||||
|
|
||||||
|
План: `docs/superpowers/plans/2026-09-11-codestyle-остатки.md`
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
- Замер: дубли `<summary>` (текст и имя члена) — 0; var-литералы/касты в src — 0; латинских комментариев — 18
|
||||||
|
(из них англоязычных 3); членов интерфейсов без дока — 4; TODO — 0; CRLF-файлов — 1029 из 1445
|
||||||
|
(все 42 .sh — CRLF).
|
||||||
|
- `.editorconfig`: `csharp_style_var_for_built_in_types = false:warning`; `end_of_line = lf`.
|
||||||
|
- `dotnet format style --diagnostics IDE0008 --severity warn` по 5 sln — 51 файл (только встроенные типы).
|
||||||
|
- `.gitattributes` добавлен (`* text=auto eol=lf` + бинарные исключения); 1029 файлов конвертированы в LF;
|
||||||
|
`git add --renormalize .`.
|
||||||
|
- `fix_private_docs.py --apply` — 12 блоков; `<summary>` добавлены: `IContainerRules.Keywords/Stack`,
|
||||||
|
`ITenantContext.TenantId/HasTenant`; переведены 3 комментария (SettingsKeys, OperatorAuthService,
|
||||||
|
ConversionRecomputerTests); `.pyc` из индекса убраны; STATUS.md — устаревший блок удалён.
|
||||||
|
- Явные реализации интерфейсов — владельцу на точечное ревью (не автоматизировано сознательно).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `dotnet build` 5 sln (core, telegram, ai, ml, storage): 0 warnings / 0 errors.
|
||||||
|
- `sh scripts/test.sh`: все 5 тест-проектов зелёные (счётчики — STATUS.md), `lint:i18n` — зелёный.
|
||||||
|
- Фронт содержательно не менялся.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Ledger: explicit-interfaces (2026-09-11, поздний вечер)
|
||||||
|
|
||||||
|
Решение владельца: вариант A — явные реализации по умолчанию, классы напрямую не вызываем
|
||||||
|
(исключения: DTO, хелперы, экстеншены), тесты через интерфейсы, моки — NSubstitute,
|
||||||
|
маркерные классы не используем (маркерные интерфейсы).
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
- Codemod `scripts/make_explicit.py` (идемпотентный): таблицы членов интерфейсов (многострочные
|
||||||
|
сигнатуры), маппинг класс→интерфейсы включая partial-файлы, конвертация `public M(` → `IFoo.M(`.
|
||||||
|
Применено: 161 член в 30 прод-файлах.
|
||||||
|
- Исправления компиляторного цикла: недостающие using'и в partial-файлах (скрипт-фиксер), снят
|
||||||
|
дефолт параметра в явной реализации `ITenantLimitStore.GetOrCreateAsync`, самовызовы
|
||||||
|
`WTelegramSessionClient` квалифицированы `((ISessionClient)this)`, мусорный using в `LlmHttpClient`.
|
||||||
|
- Тесты: 17 файлов перетипизированы с конкретных классов на интерфейсы (поля, tuple-деконструкции,
|
||||||
|
var/target-typed new); DI-регистрации фейков → регистрация интерфейсных инстансов.
|
||||||
|
- Маркеры: 10 классов → интерфейсы `IKanbanModule`, `ICardsModule`, `IPipelineModule`,
|
||||||
|
`IDiscoveryModule`, `ISettingsModule`, `ITelegramModule`, `ITenantsModule`, `IContracts`,
|
||||||
|
`IInfrastructure`, `ISharedKernel`; тесты на `IsInterface`.
|
||||||
|
- NSubstitute 6.1.0 добавлен в 5 тест-проектов; `FakePasswordHasher` удалён, вместо него
|
||||||
|
`Support/TestHashers.New()` (Substitute.For + детерминированная семантика «fake-hash:»).
|
||||||
|
- Правила владельца зафиксированы в §11 код-стайла; план миграции оставшихся ~30 фейков —
|
||||||
|
`backlog.md` (TD-TESTS-NSUBSTITUTE).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `dotnet build` 5 sln: 0 warnings / 0 errors.
|
||||||
|
- Тесты: core 1340/1340, telegram 130/130, ai 52/52, ml 38/38, storage 9/9.
|
||||||
|
- Коммиты: ed25c71 (явные реализации), 93e9100 (маркеры), далее — NSubstitute/доки.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
-8
@@ -14,10 +14,11 @@
|
|||||||
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
|
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
|
||||||
| BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) |
|
| BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) |
|
||||||
| BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress<ReclassifyProgressDto>`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE |
|
| BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress<ReclassifyProgressDto>`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE |
|
||||||
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto` (наружу уже единый) | этап 9/11 | P3 | TECHDEBT |
|
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto`. **Решение (2026-09-11): DEFERRED.** Наружный контракт единый; внутренние DTO (read/write/DB/patch) намеренно разделены по слоям, слияние — риск без пользы | этап 9/11 | P3 | DEFERRED |
|
||||||
| TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE |
|
| TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE |
|
||||||
| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов). **Осталось:** (3) не дублировать `<summary>` интерфейса в реализации (нужен Roslyn-анализ); (4) явная реализация интерфейсов там, где возможно (61 интерфейс, точечный ревью). Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (1,2 — DONE; 3,4 — BACKLOG) |
|
| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — 5286 шт.; (2) приватные XML-доки понижены — 2028+12; (3) дедупликация `<summary>`→`<inheritdoc/>` — дублей нет (сканы); (4) **явные реализации интерфейсов — сделано (2026-09-11, вечер, вариант A)**: 161 член в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py`, потребители перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов), Card/ICard-семейство оставлено implicit как DTO; попутно маркерные классы заменены маркерными интерфейсами. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | DONE |
|
||||||
| TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла: `var` для встроенных/неочевидных типов (1529, сейчас `silent`), дедупликация `<summary>`→`<inheritdoc/>` (Roslyn), решение по переводам строк (`.editorconfig` = CRLF, фактически 231 CRLF / 697 LF). Уже закрыто в `.editorconfig` (+build-проверка): запрет `this.` и именование приватных полей (`_camelCase`; `const`/`static readonly` — Pascal). Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | аудит 2026-09-11 | P3 | BACKLOG |
|
| TD-TESTS-NSUBSTITUTE | Миграция тестовых фейков на NSubstitute (решение владельца 2026-09-11: моки — через NSubstitute, новых фейк-классов не заводить). **Сделано (2026-09-11):** NSubstitute 6.1.0 подключён к 5 тест-проектам; эталон миграции — `FakePasswordHasher` → хелпер `TestHashers.New()` (NSubstitute, детерминированная семантика сохранена), фейк удалён. **Осталось (по размеру):** FakeDiscoveryPacer (1 файл), FakeRatesListener (2), FakeTenantProvisioner (5), FakeSecretCipher (8), FakeAiTools (4), FakeRatesSource (1), FakeGlobalSettingsStore (4), FakeTenantRegistry/FakeTenantRepository (3+7), FakeAiClassifier (5), FakeSettingsStore (42), FakeRateLimitCounterStore (5), FakeAuditLogStore (13), FakeTenantStore (10), FakeMlLearningStore (5), FakeMlClient (17), FakeOperatorAuthStore (14), FakeInviteStore (7), FakeFileStorage (9), FakeTokenUsageEventStore (10), FakeAuthStore (15), Recording*/Harness* (gRPC-харнессы — оставить как хелперы), крупные stateful: FakeTelegramGateway (5), FakeDiscoveryGateway (2), FakeTelegramStore (7), FakeTenantLimitStore (15), FakePipelineStore (12), FakeDiscoveryStore (7), FakeKanjStore (21). Для каждого: заменить подставку на `Substitute.For<>()` + `Returns`, семантику состояния воспроизвести в конфигурации, тесты перетипизировать на интерфейс | решение владельца 2026-09-11 | P2 | BACKLOG |
|
||||||
|
| TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла. **Закрыто (2026-09-11):** (1) `var` — включён ломающий сборку гейт только для встроенных типов (`csharp_style_var_for_built_in_types = false:warning`), остаток выправлен `dotnet format style --diagnostics IDE0008` по 5 sln; режимы «очевидный/прочий тип» — silent осознанно (~1600 субъективных замен); (2) дедупликация `<summary>` — дублей нет (см. TD-COMMENTS-IFACE); (3) переводы строк — **решено: LF** (`.gitattributes` `* text=auto eol=lf`, `.editorconfig` → lf, 1029 файлов нормализовано, `git add --renormalize`; попутно починены 42 CRLF-.sh — до этого первый прогон удалённого CI падал бы). `this.` и именование приватных полей уже закрыты в `.editorconfig` | аудит 2026-09-11 | P3 | DONE |
|
||||||
|
|
||||||
## 2. Инфраструктура и эксплуатация
|
## 2. Инфраструктура и эксплуатация
|
||||||
|
|
||||||
@@ -45,22 +46,22 @@
|
|||||||
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
||||||
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
|
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
|
||||||
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
||||||
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
|
| BL-SUSPICIOUS | **Сделано (2026-09-11):** детектор `SuspiciousActivityService` расширен правилом `distinct_logins_per_ip` (перебор разных логинов с одного IP, порог `DistinctLoginsPerIpThreshold`); плюс real-time `SuspiciousActivityReporter` — метрика `deal.security.suspicious{kind}` + warn-лог на 429 rate limiter (`rate_limit`) и блокировке входа (`login_blocked`) | ТЗ §10.5, этап 12 | P3 | DONE |
|
||||||
|
|
||||||
## 5. Технический долг (качество/архитектура)
|
## 5. Технический долг (качество/архитектура)
|
||||||
|
|
||||||
| ID | Пункт | Источник | Приоритет | Статус |
|
| ID | Пункт | Источник | Приоритет | Статус |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| TD-SETTINGS-UI | Вынос оставшихся вкладок `SettingsView` в отдельные компоненты (частично сделано) | ревью 2026-09-08 | P3 | TECHDEBT |
|
| TD-SETTINGS-UI | Вынос вкладок `SettingsView` в компоненты. **Сделано (2026-09-11):** `SettingsView.vue` — только набор вкладок/QR-опрос, все 10 вкладок — отдельные компоненты (`components/settings/*`) | ревью 2026-09-08 | P3 | DONE |
|
||||||
| TD-VIRT | Полная виртуализация длинных колонок (сейчас — прогрессивный рендер «Показать ещё») | ревью, этап 12 | P3 | TECHDEBT |
|
| TD-VIRT | Полная виртуализация длинных колонок. **Решение (2026-09-11): DEFERRED** — прогрессивный рендер «Показать ещё» покрывает текущие объёмы; виртуализация — при росте списков | ревью, этап 12 | P3 | DEFERRED |
|
||||||
| TD-SSE-DEAD | **Сделано (2026-09-11):** мёртвые SSE-ветки фронта `boards_changed`/`pipeline_stats` удалены из `store/lifecycle.js` (core их не публикует) | этап 12, E | P3 | DONE |
|
| TD-SSE-DEAD | **Сделано (2026-09-11):** мёртвые SSE-ветки фронта `boards_changed`/`pipeline_stats` удалены из `store/lifecycle.js` (core их не публикует) | этап 12, E | P3 | DONE |
|
||||||
| TD-DBL-CLICK | **Сделано (2026-09-11):** перезагрузка доски при batch-переклассификации коалесцируется `scheduleBoardReload()` (ответ + SSE → один запрос) | этап 12, E | P3 | DONE |
|
| TD-DBL-CLICK | **Сделано (2026-09-11):** перезагрузка доски при batch-переклассификации коалесцируется `scheduleBoardReload()` (ответ + SSE → один запрос) | этап 12, E | P3 | DONE |
|
||||||
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
|
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
|
||||||
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками. **Проверено (2026-09-11):** `docs/superpowers/plans/*` и старые `docs/architecture/2026-09-0*` имеют шапку «Исторический документ»; переписывать не нужно | docs sweep | P3 | DONE |
|
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками. **Проверено (2026-09-11):** `docs/superpowers/plans/*` и старые `docs/architecture/2026-09-0*` имеют шапку «Исторический документ»; переписывать не нужно | docs sweep | P3 | DONE |
|
||||||
| TD-SOURCE-PROVIDER | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT |
|
| TD-SOURCE-PROVIDER | Провайдеры содержимого источников. **Сделано (2026-09-11):** `TelegramSourceContentProvider` + `ReadSource` RPC + `GET /api/cards/{id}/source` + UI «Обновить из источника». Осталось: провайдеры прочих источников по мере появления | generic source 2026-09-11 | P2 | TECHDEBT |
|
||||||
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются. Остались на будущее: вложения прочих источников (файл/диск/таблица) и `ISourceContentProvider` для remote-просмотра | generic source 2026-09-11 | P3 | BACKLOG |
|
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером. **Решение (2026-09-11): медиа-посты Telegram пропускаем** — извлечение/выгрузка не делаются; вложений у прочих источников пока нет — **DEFERRED** (контракт `DataRef` готов, включается при появлении такого источника) | generic source 2026-09-11 | P3 | DEFERRED |
|
||||||
| TD-TG-CORE-SPLIT | Перенос оставшейся Telegram-специфики ядра в telegram-сервис. **Закрыто (2026-09-11): не требуется.** Задача «дашборды/карточки не знают о Telegram» решена generic-контрактом источника; оставшиеся `TelegramStore`/`Dialogs`/`TgMessages`/Discovery — это состояние тенанта (ядро — владелец данных, telegram-service — stateless-шлюз), перенос отдал бы шлюзу доступ к схеме тенанта | generic source 2026-09-11 | — | CLOSED |
|
| TD-TG-CORE-SPLIT | Перенос оставшейся Telegram-специфики ядра в telegram-сервис. **Закрыто (2026-09-11): не требуется.** Задача «дашборды/карточки не знают о Telegram» решена generic-контрактом источника; оставшиеся `TelegramStore`/`Dialogs`/`TgMessages`/Discovery — это состояние тенанта (ядро — владелец данных, telegram-service — stateless-шлюз), перенос отдал бы шлюзу доступ к схеме тенанта | generic source 2026-09-11 | — | CLOSED |
|
||||||
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
|
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`). **Решение (2026-09-11): DEFERRED** — форматы стабильны, расширяемость под источник добавляется при конкретной потребности | generic source 2026-09-11 | P3 | DEFERRED |
|
||||||
| TD-APIMAP-COUNT | Ручной подсчёт числа ручек в `api-map`. **Сделано (2026-09-11):** сверил счётчики §3.1–§3.8 с фактическими строками (рассинхрон §3.5 — 14→15 из-за `GET /cards/{id}/source`); в §3 добавлено правило обновлять счётчики | docs sweep | P3 | DONE |
|
| TD-APIMAP-COUNT | Ручной подсчёт числа ручек в `api-map`. **Сделано (2026-09-11):** сверил счётчики §3.1–§3.8 с фактическими строками (рассинхрон §3.5 — 14→15 из-за `GET /cards/{id}/source`); в §3 добавлено правило обновлять счётчики | docs sweep | P3 | DONE |
|
||||||
|
|
||||||
## 6. Manual-проверки (нужны внешние условия)
|
## 6. Manual-проверки (нужны внешние условия)
|
||||||
|
|||||||
@@ -232,11 +232,16 @@
|
|||||||
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
|
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
|
||||||
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
|
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
|
||||||
Текст описания пишется **один раз** — у интерфейса.
|
Текст описания пишется **один раз** — у интерфейса.
|
||||||
- **Явная реализация интерфейсов — где возможно.** Предпочитать явную реализацию
|
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
|
||||||
(`Task ICardStore.GetAsync(...)`), если член не является публичным API класса сам по себе. Если тип
|
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
|
||||||
реализует член как собственный публичный сервис (нужен в DI/прямых вызовах) — допустима implicit,
|
(напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
|
||||||
но решение осознанное.
|
реализации (codemod `scripts/make_explicit.py`, идемпотентный).
|
||||||
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
|
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
|
||||||
|
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
|
||||||
|
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
|
||||||
|
- **Тесты: моки — через NSubstitute** (`Substitute.For<IPasswordHasher>()`), тестовые переменные
|
||||||
|
типизируются интерфейсом. Новые hand-written фейк-классы не заводить; существующие мигрируются
|
||||||
|
поэтапно (план — `backlog.md`, `TD-TESTS-NSUBSTITUTE`). **[изм. 2026-09-11]**
|
||||||
|
|
||||||
## 12. Приложение: сводная таблица правил именования
|
## 12. Приложение: сводная таблица правил именования
|
||||||
|
|
||||||
|
|||||||
@@ -28,23 +28,35 @@
|
|||||||
- Тесты: core **1275/1275**, telegram **125/125**, ai **52/52**, ml **38/38** — все пройдены.
|
- Тесты: core **1275/1275**, telegram **125/125**, ai **52/52**, ml **38/38** — все пройдены.
|
||||||
- Повторный прогон renamer: `this.` — 0, полей к переименованию — 0 (идемпотентно).
|
- Повторный прогон renamer: `this.` — 0, полей к переименованию — 0 (идемпотентно).
|
||||||
|
|
||||||
## 2. Осталось — требует решения владельца
|
## 2. Остатки — решения (закрыто 2026-09-11, вечер)
|
||||||
|
|
||||||
1. **`var` — 1529 употреблений.** Правило §4: не использовать для встроенных типов и при неочевидном типе.
|
1. **`var` — закрыто.** В `.editorconfig` включён ломающий сборку гейт `csharp_style_var_for_built_in_types = false:warning`
|
||||||
В `.editorconfig` `csharp_style_var_* = false:silent`. Замена требует семантики (вывод типа).
|
(запрет только для встроенных типов — как в §4); режимы «очевидный тип» и «прочие» оставлены `silent`
|
||||||
**Рекомендация:** включить анализатор (`:warning`) + `dotnet format` с проверкой.
|
осознанно: правка субъективна и потребовала бы ~1600 механических замен. Остаток встроенных типов
|
||||||
|
выправлен `dotnet format style --diagnostics IDE0008` по всем 5 решениям (51 файл); сборка 5 sln — 0/0.
|
||||||
|
2. **Явная реализация интерфейсов (§11) — выполнено (вечер, решение владельца, вариант A).** 161 член
|
||||||
|
в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py` (частичные классы и многострочные
|
||||||
|
сигнатуры учтены); потребители конкретных типов перетипизированы на интерфейсы (8 мест в проде,
|
||||||
|
17 тест-файлов); `Card`/семейство `I*Card` оставлены implicit — это DTO, их члены и есть публичный API.
|
||||||
|
Правило закреплено в §11 код-стайла: классы напрямую не вызываем (DTO/хелперы/экстеншены — исключения).
|
||||||
|
3. **Дедупликация `<summary>` через `<inheritdoc/>` — закрыто: дублей нет.** Проверено двумя независимыми
|
||||||
|
сканами (сопоставление по тексту и по имени члена интерфейса: 39 интерфейсов, 229 задокументированных
|
||||||
|
членов) — реализаций, дублирующих summary интерфейсного члена, в продакшн-коде нет; случаев
|
||||||
|
«`<param>` + дублирующий summary» не существует.
|
||||||
|
4. **Переводы строк — решено: LF.** Обоснование: инструменты проекта (Python/Node-скрипты, codemod'ы) пишут LF;
|
||||||
|
shell-скрипты с CRLF не работают на Linux CI (`sh scripts/ci.sh` в GitHub Actions); фактическое большинство
|
||||||
|
файлов уже было LF. Применено: `.gitattributes` (`* text=auto eol=lf` + бинарные исключения),
|
||||||
|
`.editorconfig` → `end_of_line = lf`, конвертировано 1029 трекаемых файлов, `git add --renormalize`.
|
||||||
|
Побочный эффект: починены 42 CRLF-.sh (9 в `scripts/` — до этого первый удалённый прогон CI падал бы).
|
||||||
|
|
||||||
2. **Явная реализация интерфейсов (§11).** Субъективное «где возможно» — массовая правка может сломать
|
### Попутно исправлено (2026-09-11, вечер)
|
||||||
DI/прямые вызовы и тесты. **Рекомендация:** точечный ревью по 61 интерфейсу, без автоматизации.
|
|
||||||
|
|
||||||
3. **Дедупликация `<summary>` в реализациях через `/// <inheritdoc/>` (§11).** Надёжно детектируется только
|
- Повторный прогон `scripts/fix_private_docs.py --apply`: понижено 12 XML-доков на private/internal (extension-файлы).
|
||||||
по семантической модели (сопоставление интерфейс↔класс). В коде уже 674 `<inheritdoc/>`.
|
- Добавлены недостающие `<summary>`: `IContainerRules.Keywords`/`Stack`, `ITenantContext.TenantId`/`HasTenant`.
|
||||||
**Рекомендация:** Roslyn-анализатор, если нужно добить остаток.
|
- Переведены на русский англоязычные `//`-комментарии (3 шт. из 18 найденных; остальные — имена
|
||||||
|
сущностей/заголовки секций тестов, не англоязычный текст).
|
||||||
4. **Переводы строк.** `.editorconfig` требует `end_of_line = crlf`, фактически: **231 файл CRLF / 697 LF**
|
- Из индекса убраны случайно закоммиченные `archive/**/__pycache__/*.pyc` (2 шт., уже в `.gitignore`).
|
||||||
(смешанно). Правка объёмная. **Рекомендация:** решить — нормализовать под CRLF или зафиксировать LF.
|
- STATUS.md: удалён устаревший блок «Осталось (в backlog)» в шапке (пункты закрыты generic-контрактом источника).
|
||||||
|
- Дочистка по контрольному скану краткости: удалены 73 очевидных `<param name="ct|cancellationToken">`
|
||||||
## 3. Примечание
|
(«Токен отмены.» — пересказ сигнатуры, §5) в 17 файлах; ужаты 3 summary (2 многосентенционных, 1 длинное).
|
||||||
|
Контроль: `<remarks>` — 0, inline-`<summary>` — 0, многосентенционных summary — 0, TODO — 0.
|
||||||
Пункты 2.1, 2.3 можно закрыть анализаторами Roslyn в `Directory.Build.props` — это даст автоматическую
|
|
||||||
проверку на новом коде. Пункт 2.4 — разовое решение по политике переводов строк.
|
|
||||||
|
|||||||
@@ -17,9 +17,30 @@
|
|||||||
> единый CI (`scripts/ci.sh`). Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1326/1326 PASS**,
|
> единый CI (`scripts/ci.sh`). Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1326/1326 PASS**,
|
||||||
> telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные.
|
> telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные.
|
||||||
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||||
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
>
|
||||||
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
> **2026-09-11 (вечер) — закрыты остатки код-стайла (TD-COMMENTS-IFACE, TD-STYLE-ANALYZERS).** Дедупликация
|
||||||
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
|
> `<summary>`: дублей нет (сканы по тексту и по имени члена — 39 интерфейсов/229 членов). `var`: гейт
|
||||||
|
> `csharp_style_var_for_built_in_types = false:warning` (ломает сборку), остаток выправлен `dotnet format`
|
||||||
|
> по 5 sln (51 файл), «очевидный/прочий тип» — silent осознанно. Переводы строк: решено LF — `.gitattributes`
|
||||||
|
> (`* text=auto eol=lf`), `.editorconfig` → lf, нормализовано 1029 файлов; попутно починены 42 CRLF-.sh
|
||||||
|
> (первый прогон удалённого CI падал бы). Понижено 12 новых private XML-доков; добавлены 4 `<summary>`
|
||||||
|
> членам интерфейсов; переведены 3 англоязычных комментария; из индекса убраны 2 `__pycache__/*.pyc`;
|
||||||
|
> STATUS.md — удалён устаревший блок «Осталось (в backlog)» в шапке. Дочистка по контрольному скану
|
||||||
|
> краткости: удалены 73 очевидных `<param name="ct">` (пересказ сигнатуры) в 17 файлах, ужаты 3 summary
|
||||||
|
> (многосентенционные/длинные); контроль: `<remarks>` 0, inline-`<summary>` 0, многосентенционных 0, TODO 0. Явные реализации интерфейсов (§11) —
|
||||||
|
> остались точечным ревью владельца (43 интерфейса с реализациями, массовая правка не автоматизируется).
|
||||||
|
> Сборка 5 sln 0/0; тесты: core **1340/1340**, telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9** — зелёные.
|
||||||
|
> Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md`, §2.
|
||||||
|
>
|
||||||
|
> **2026-09-11 (поздний вечер) — явные реализации интерфейсов (вариант A, решение владельца).** Правило
|
||||||
|
> владельца: классы напрямую не вызываем (исключения — DTO, хелперы, экстеншены), тесты — через
|
||||||
|
> интерфейсы, моки — NSubstitute, маркерные классы не используем. Сделано: 161 член в 30 прод-файлах
|
||||||
|
> переведён на явные реализации codemod'ом `scripts/make_explicit.py`; потребители конкретных типов
|
||||||
|
> перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов; самовызовы — `((ISessionClient)this)`);
|
||||||
|
> 10 маркерных классов заменены маркерными интерфейсами (`IKanbanModule`…`ISharedKernel`); NSubstitute 6.1.0
|
||||||
|
> подключён к 5 тест-проектам, эталон миграции — `FakePasswordHasher` → `TestHashers.New()` (фейк удалён);
|
||||||
|
> правила зафиксированы в §11 код-стайла. Card/`I*Card` — implicit (DTO). Оставшиеся 30 фейков —
|
||||||
|
> поэтапная миграция (`backlog.md`, TD-TESTS-NSUBSTITUTE). Build 5 sln 0/0, тесты зелёные.
|
||||||
|
|
||||||
**Все этапы 0–12 выполнены (100%)** — см. roadmap
|
**Все этапы 0–12 выполнены (100%)** — см. roadmap
|
||||||
> `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10
|
> `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# План: закрытие остатков код-стайла (2026-09-11, вечер)
|
||||||
|
|
||||||
|
> Источник: `backlog.md` — `TD-COMMENTS-IFACE` (п.3, п.4), `TD-STYLE-ANALYZERS`, найденное при проверке
|
||||||
|
> проекта. Правила — `docs/spec/Код-стайл-Дейл.md`, отчёт — `docs/spec/Код-стайл-аудит-2026-09-11.md` §2.
|
||||||
|
> Ограничения захода: без поднятия Docker-стека и без внешних кредов.
|
||||||
|
|
||||||
|
## Задачи
|
||||||
|
|
||||||
|
1. **Замер остатков** (dry-run, без правок): сканами по тексту и по имени члена проверить дубли
|
||||||
|
`<summary>` реализации ↔ интерфейса; разбивку `var`; латинские комментарии; членов интерфейсов без
|
||||||
|
дока; TODO; переводы строк по расширениям.
|
||||||
|
2. **`var` для встроенных типов**: `.editorconfig` → `csharp_style_var_for_built_in_types = false:warning`
|
||||||
|
(гейт ломает сборку), остаток выправить `dotnet format style --diagnostics IDE0008` по 5 решениям.
|
||||||
|
«Очевидный тип» и «прочие» — оставить `silent` (субъективно, ~1600 замен).
|
||||||
|
3. **Дедупликация `<summary>`→`<inheritdoc/>`**: по результатам замера — либо codemod, либо закрытие «дублей нет».
|
||||||
|
4. **Переводы строк**: решение политики + нормализация (`.gitattributes`, `.editorconfig`, конверсия файлов,
|
||||||
|
`git add --renormalize`); проверить, что `.sh` — LF (Linux CI).
|
||||||
|
5. **Попутные доки/комментарии**: недостающие `<summary>` членам интерфейсов; англоязычные `//`-комментарии;
|
||||||
|
повторный прогон `fix_private_docs.py`; устаревший блок в `STATUS.md`; трекаемые `.pyc` из индекса.
|
||||||
|
6. **Приёмка**: build 5 sln 0/0, все тесты зелёные; обновить `backlog.md`/`STATUS.md`.
|
||||||
|
|
||||||
|
## Решения
|
||||||
|
|
||||||
|
- Явные реализации интерфейсов (§11) — **не автоматизировать**: остаётся точечным ревью владельца
|
||||||
|
(замер: 54 интерфейса с XML-doc, 43 с реализациями; массовая правка ломает публичную поверхность классов).
|
||||||
|
- Переводы строк — **LF** (инструменты проекта пишут LF; CRLF-.sh ломают `sh scripts/ci.sh` на Linux CI;
|
||||||
|
большинство файлов уже LF). Откат — `git revert` нормализации.
|
||||||
|
- Гейт `var` — только на встроенные типы: правило §4 запрет говорит про встроенные/неочевидные,
|
||||||
|
«неочевидность» не проверяется машиной.
|
||||||
|
|
||||||
|
## Приёмка
|
||||||
|
|
||||||
|
- build 5 sln: 0 warnings / 0 errors (гейт IDE0008 проходит).
|
||||||
|
- Тесты: core / telegram / ai / ml / storage — зелёные, счётчики в `STATUS.md`.
|
||||||
|
- Фронт не менялся содержательно (только концы строк) — `build`/`lint:i18n` не прогонялись.
|
||||||
@@ -1387,7 +1387,10 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
|
|||||||
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
|
- **`/api/operator/health` (§10.2).** Добавлены `queues:{pipeline,mlOutbox}` и `sessions:{active}`
|
||||||
(общий `RuntimeDepthsCollector`, без дублей SQL).
|
(общий `RuntimeDepthsCollector`, без дублей SQL).
|
||||||
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
|
- **Подозрительная активность (§10.5).** `SuspiciousActivityService` + `GET /api/operator/analytics/suspicious`
|
||||||
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту; пороги — константы).
|
(всплеск неудачных входов по IP/логину, входы актора с множества IP, серии по тенанту, перебор разных
|
||||||
|
логинов с одного IP `distinct_logins_per_ip`; пороги — константы). Плюс real-time учёт
|
||||||
|
`SuspiciousActivityReporter`: метрика `deal.security.suspicious{kind}` и предупреждающий лог на 429
|
||||||
|
rate limiter (`rate_limit`) и блокировке входа (`login_blocked`).
|
||||||
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
|
- **«Открыть исходник» на карточке (§6.6)** и **темы оформления (§8.12, §15)** — во фронтенде.
|
||||||
|
|
||||||
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
|
Итог: core-тесты **1275/1275**; фронт `npm run build` + `lint:i18n` зелёные. Контракт API —
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Заменить дублирующий <summary> реализации на <inheritdoc/> (код-стайл Дейла).
|
||||||
|
|
||||||
|
Консервативно:
|
||||||
|
* собираются тексты <summary> членов интерфейсов (блоки, состоящие только из <summary>);
|
||||||
|
* в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// <inheritdoc />`;
|
||||||
|
* блоки с <param>/<returns>/<remarks>/... и summary самих типов не трогаются.
|
||||||
|
|
||||||
|
Запуск: python scripts/dedup_summary_inheritdoc.py [--apply]
|
||||||
|
Без --apply — dry-run со счётчиками.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
ROOTS = [
|
||||||
|
REPO / "src" / "core",
|
||||||
|
REPO / "src" / "telegram-service",
|
||||||
|
REPO / "src" / "ai-service",
|
||||||
|
REPO / "src" / "ml-service",
|
||||||
|
REPO / "src" / "storage-service",
|
||||||
|
REPO / "src" / "grpc-hosting",
|
||||||
|
]
|
||||||
|
|
||||||
|
INTERFACE_RE = re.compile(
|
||||||
|
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||||
|
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+"
|
||||||
|
)
|
||||||
|
TYPE_BASE_RE = re.compile(
|
||||||
|
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||||
|
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*"
|
||||||
|
r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)"
|
||||||
|
)
|
||||||
|
IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*")
|
||||||
|
EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE)
|
||||||
|
SUMMARY_OPEN_RE = re.compile(r"<summary>", re.IGNORECASE)
|
||||||
|
SUMMARY_CLOSE_RE = re.compile(r"</summary>", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_files() -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
for root in ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for path in root.rglob("*.cs"):
|
||||||
|
parts = set(path.parts)
|
||||||
|
if "obj" in parts or "bin" in parts:
|
||||||
|
continue
|
||||||
|
if "tests" in path.as_posix().lower():
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
return sorted(files)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(block: list[str]) -> str:
|
||||||
|
text = " ".join(line.strip().lstrip("/").strip() for line in block)
|
||||||
|
text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text))
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_summary_only(block: list[str]) -> bool:
|
||||||
|
joined = "\n".join(block)
|
||||||
|
return (
|
||||||
|
bool(SUMMARY_OPEN_RE.search(joined))
|
||||||
|
and bool(SUMMARY_CLOSE_RE.search(joined))
|
||||||
|
and not EXTRA_TAG_RE.search(joined)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def depth_snapshot(lines: list[str]) -> list[int]:
|
||||||
|
"""Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев)."""
|
||||||
|
depths: list[int] = []
|
||||||
|
depth = 0
|
||||||
|
for line in lines:
|
||||||
|
depths.append(depth)
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
return depths
|
||||||
|
|
||||||
|
|
||||||
|
def type_scopes(lines: list[str]):
|
||||||
|
"""Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой.
|
||||||
|
|
||||||
|
kind: "interface" | "type". Границы — по фигурным скобкам объявления.
|
||||||
|
"""
|
||||||
|
depths = depth_snapshot(lines)
|
||||||
|
scopes: list[tuple[int, int, str]] = []
|
||||||
|
stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line)
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
depth_before = depths[idx]
|
||||||
|
if INTERFACE_RE.match(line):
|
||||||
|
stack.append((depth_before, "interface", idx))
|
||||||
|
else:
|
||||||
|
m = TYPE_BASE_RE.match(line)
|
||||||
|
if m and IFACE_NAME_RE.search(m.group(1)):
|
||||||
|
stack.append((depth_before, "type", idx))
|
||||||
|
# закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления)
|
||||||
|
while (
|
||||||
|
stack
|
||||||
|
and depths[idx] <= stack[-1][0]
|
||||||
|
and lines[idx].strip().startswith("}")
|
||||||
|
and idx > stack[-1][2]
|
||||||
|
):
|
||||||
|
depth, kind, start = stack.pop()
|
||||||
|
scopes.append((start, idx, kind))
|
||||||
|
for depth, kind, start in stack:
|
||||||
|
scopes.append((start, len(lines) - 1, kind))
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
|
||||||
|
def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]:
|
||||||
|
kinds: set[str] = set()
|
||||||
|
for start, end, kind in scope_kinds:
|
||||||
|
if start <= line_idx <= end and start != line_idx:
|
||||||
|
kinds.add(kind)
|
||||||
|
return kinds
|
||||||
|
|
||||||
|
|
||||||
|
def collect_interface_summaries(files: list[Path]) -> set[str]:
|
||||||
|
summaries: set[str] = set()
|
||||||
|
for path in files:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
depths = depth_snapshot(lines)
|
||||||
|
scopes = type_scopes(lines)
|
||||||
|
idx = 0
|
||||||
|
while idx < len(lines):
|
||||||
|
if not lines[idx].lstrip().startswith("///"):
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
start = idx
|
||||||
|
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||||
|
idx += 1
|
||||||
|
block = lines[start:idx]
|
||||||
|
kinds = containing_kinds(scopes, start, depths[start])
|
||||||
|
if "interface" in kinds and is_summary_only(block):
|
||||||
|
summaries.add(normalize(block))
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]:
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
depths = depth_snapshot(lines)
|
||||||
|
scopes = type_scopes(lines)
|
||||||
|
result: list[str] = []
|
||||||
|
replaced = 0
|
||||||
|
idx = 0
|
||||||
|
while idx < len(lines):
|
||||||
|
if not lines[idx].lstrip().startswith("///"):
|
||||||
|
result.append(lines[idx])
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
start = idx
|
||||||
|
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||||
|
idx += 1
|
||||||
|
block = lines[start:idx]
|
||||||
|
kinds = containing_kinds(scopes, start, depths[start])
|
||||||
|
if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries:
|
||||||
|
indent = block[0][: len(block[0]) - len(block[0].lstrip())]
|
||||||
|
result.append(f"{indent}/// <inheritdoc />")
|
||||||
|
replaced += 1
|
||||||
|
else:
|
||||||
|
result.extend(block)
|
||||||
|
return replaced, result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
apply = "--apply" in sys.argv
|
||||||
|
files = iter_files()
|
||||||
|
iface_summaries = collect_interface_summaries(files)
|
||||||
|
total = 0
|
||||||
|
changed = 0
|
||||||
|
for path in files:
|
||||||
|
replaced, new_lines = process_file(path, iface_summaries)
|
||||||
|
if not replaced:
|
||||||
|
continue
|
||||||
|
total += replaced
|
||||||
|
changed += 1
|
||||||
|
if apply:
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "")
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
mode = "применено" if apply else "dry-run"
|
||||||
|
print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Codemod: неявные реализации интерфейсов -> явные (§11, вариант A — прод).
|
||||||
|
|
||||||
|
Только production-код (/tests/ не трогает). Card (DTO) пропускается.
|
||||||
|
Логика: таблицы членов интерфейсов -> маппинг класс -> интерфейсы (включая partial) ->
|
||||||
|
конвертация public-члена с совпавшим именем и сигнатурой в `Тип IFoo.Член`.
|
||||||
|
Ошибочный конверсионный файл откатывается компиляторным циклом (git checkout).
|
||||||
|
|
||||||
|
Запуск: python scripts/make_explicit.py [--apply] [--file <путь>]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
ROOTS = [REPO / "src" / "core", REPO / "src" / "telegram-service", REPO / "src" / "ai-service",
|
||||||
|
REPO / "src" / "ml-service", REPO / "src" / "storage-service", REPO / "src" / "grpc-hosting"]
|
||||||
|
# файлы-исключения: DTO/модели, члены которых — собственный публичный API
|
||||||
|
EXCLUDE_FILES = {"src/core/Deal.Modules.Cards/Application/Models/Card.cs"}
|
||||||
|
|
||||||
|
IFACE_DECL = re.compile(
|
||||||
|
r"^\s*(?:public\s+|internal\s+)?(?:partial\s+|sealed\s+|static\s+|unsafe\s+)*interface\s+(\w+)", re.M)
|
||||||
|
CLASS_DECL = re.compile(
|
||||||
|
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
|
||||||
|
r"class\s+(\w+)(?:<[^>]*>)?[^{;]*:\s*([^{;]+)", re.M)
|
||||||
|
PARTIAL_CLASS = re.compile(
|
||||||
|
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
|
||||||
|
r"class\s+(\w+)(?:<[^>]*>)?\s*(?::[^{;]+)?\{", re.M)
|
||||||
|
IFNAME = re.compile(r"\bI[A-Z]\w*")
|
||||||
|
METHOD_DECL = re.compile(
|
||||||
|
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
|
||||||
|
r"virtual\s+|override\s+|sealed\s+|async\s+|new\s+)*)"
|
||||||
|
r"(?P<ret>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*(?:<[^>]*>)?\s*\(")
|
||||||
|
PROP_DECL = re.compile(
|
||||||
|
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
|
||||||
|
r"virtual\s+|override\s+|sealed\s+|new\s+)*)"
|
||||||
|
r"(?P<type>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*\{\s*(?P<accessors>(?:public\s+)?(?:get|set|init)[^}]*)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def src_files() -> list[Path]:
|
||||||
|
out = []
|
||||||
|
for root in ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for p in root.rglob("*.cs"):
|
||||||
|
s = p.as_posix().lower()
|
||||||
|
if "/bin/" in s or "/obj/" in s or "/tests/" in s or ".tests/" in s:
|
||||||
|
continue
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def interface_body(text: str, start: int) -> str:
|
||||||
|
b = text.find("{", start)
|
||||||
|
if b == -1:
|
||||||
|
return ""
|
||||||
|
depth, end = 0, b
|
||||||
|
for i in range(b, len(text)):
|
||||||
|
if text[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
return text[b:end]
|
||||||
|
|
||||||
|
|
||||||
|
def norm_types(params: str) -> tuple[str, ...]:
|
||||||
|
if not params.strip():
|
||||||
|
return ()
|
||||||
|
out, depth, cur = [], 0, ""
|
||||||
|
for ch in params:
|
||||||
|
if ch in "<([":
|
||||||
|
depth += 1
|
||||||
|
elif ch in ">)]":
|
||||||
|
depth -= 1
|
||||||
|
if ch == "," and depth == 0:
|
||||||
|
out.append(cur)
|
||||||
|
cur = ""
|
||||||
|
else:
|
||||||
|
cur += ch
|
||||||
|
out.append(cur)
|
||||||
|
res = []
|
||||||
|
for raw in out:
|
||||||
|
t = re.sub(r"\s+", " ", raw.strip())
|
||||||
|
parts = t.rsplit(" ", 1)
|
||||||
|
if len(parts) == 2 and not re.search(r"[<>\[\](),]", parts[1]) and parts[1] not in ("*", "&"):
|
||||||
|
t = parts[0]
|
||||||
|
res.append(t.replace("?", "").replace(" ", ""))
|
||||||
|
return tuple(res)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_interfaces(files: list[Path]) -> dict[str, dict[str, dict]]:
|
||||||
|
tables: dict[str, dict[str, dict]] = {}
|
||||||
|
for p in files:
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
for m in IFACE_DECL.finditer(text):
|
||||||
|
name = m.group(1)
|
||||||
|
body = interface_body(text, m.start())
|
||||||
|
lines = body.splitlines()
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
mm = METHOD_DECL.match(line)
|
||||||
|
if mm and "(" in line:
|
||||||
|
# сигнатура может быть многострочной (§7: параметры на отдельных строках)
|
||||||
|
sig_text = line
|
||||||
|
while sig_text.count("(") > sig_text.count(")") and i + 1 < len(lines):
|
||||||
|
i += 1
|
||||||
|
sig_text += " " + lines[i].strip()
|
||||||
|
if sig_text.count("(") > sig_text.count(")"):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
params = sig_text.split("(", 1)[1].rsplit(")", 1)[0]
|
||||||
|
tables.setdefault(name, {})[mm.group("name")] = {"kind": "method", "sig": norm_types(params)}
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
pm = PROP_DECL.match(line)
|
||||||
|
if pm:
|
||||||
|
tables.setdefault(name, {})[pm.group("name")] = {
|
||||||
|
"kind": "prop", "sig": pm.group("type").replace("?", "").replace(" ", "")}
|
||||||
|
i += 1
|
||||||
|
return tables
|
||||||
|
|
||||||
|
|
||||||
|
def collect_class_ifaces(files: list[Path]) -> dict[str, set[str]]:
|
||||||
|
result: dict[str, set[str]] = {}
|
||||||
|
for p in files:
|
||||||
|
rel = p.relative_to(REPO).as_posix()
|
||||||
|
if rel in EXCLUDE_FILES:
|
||||||
|
continue
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
for m in CLASS_DECL.finditer(text):
|
||||||
|
ifaces = set(IFNAME.findall(m.group(2)))
|
||||||
|
if ifaces:
|
||||||
|
result.setdefault(m.group(1), set()).update(ifaces)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def convert_file(path: Path, cls_ifaces: dict[str, set[str]], itables: dict[str, dict[str, dict]]):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
local: set[str] = set()
|
||||||
|
for m in PARTIAL_CLASS.finditer(text):
|
||||||
|
if m.group(1) in cls_ifaces:
|
||||||
|
local.add(m.group(1))
|
||||||
|
if not local:
|
||||||
|
return 0, None, []
|
||||||
|
allowed = {i for cls in local for i in cls_ifaces[cls] if i in itables}
|
||||||
|
|
||||||
|
changed, notes = 0, []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
stripped = line.lstrip()
|
||||||
|
if stripped.startswith("//") or stripped.startswith("#"):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
mm = METHOD_DECL.match(line.rstrip("\r\n"))
|
||||||
|
pm = None if mm else PROP_DECL.match(line.rstrip("\r\n"))
|
||||||
|
if not mm and not pm:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
kind = "method" if mm else "prop"
|
||||||
|
decl = mm or pm
|
||||||
|
mods = decl.group("mods")
|
||||||
|
if "static" in mods or "public" not in mods:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
name = decl.group("name")
|
||||||
|
if kind == "method":
|
||||||
|
sig_text, j = line, i
|
||||||
|
while sig_text.count("(") > sig_text.count(")") and j + 1 < len(lines):
|
||||||
|
j += 1
|
||||||
|
sig_text += lines[j]
|
||||||
|
if "(" not in sig_text:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
sig = norm_types(sig_text.split("(", 1)[1].rsplit(")", 1)[0])
|
||||||
|
else:
|
||||||
|
sig = decl.group("type").replace("?", "").replace(" ", "")
|
||||||
|
j = i
|
||||||
|
cands = [ifc for ifc in allowed
|
||||||
|
if itables[ifc].get(name, {}).get("kind") == kind and itables[ifc][name]["sig"] == sig]
|
||||||
|
if not cands:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
ifc = cands[0]
|
||||||
|
head = line.rstrip("\r\n")
|
||||||
|
eol = line[len(head):]
|
||||||
|
# убрать модификаторы (оставить отступ), затем квалифицировать имя
|
||||||
|
new_head = re.sub(r"^(\s*)(?:public\s+|internal\s+|protected\s+|private\s+|virtual\s+|"
|
||||||
|
r"override\s+|sealed\s+|new\s+)+", r"\1", head)
|
||||||
|
m2 = re.search(r"\s" + re.escape(name) + r"(?![\w])", new_head)
|
||||||
|
if not m2:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
new_head = new_head[:m2.start()] + " " + ifc + "." + name + new_head[m2.end():]
|
||||||
|
lines[i] = new_head + eol
|
||||||
|
changed += 1
|
||||||
|
notes.append(f"{path.relative_to(REPO).as_posix()}:{i + 1} -> {ifc}.{name}")
|
||||||
|
i = j + 1
|
||||||
|
return changed, ("".join(lines) if changed else None), notes
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
apply = "--apply" in sys.argv
|
||||||
|
only = sys.argv[sys.argv.index("--file") + 1] if "--file" in sys.argv else None
|
||||||
|
files = src_files()
|
||||||
|
itables = collect_interfaces(files)
|
||||||
|
cls_ifaces = collect_class_ifaces(files)
|
||||||
|
total, per_file = 0, 0
|
||||||
|
for p in files:
|
||||||
|
if only and p.as_posix() != only:
|
||||||
|
continue
|
||||||
|
changed, new_text, notes = convert_file(p, cls_ifaces, itables)
|
||||||
|
if not changed:
|
||||||
|
continue
|
||||||
|
total += changed
|
||||||
|
per_file += 1
|
||||||
|
if apply and new_text is not None:
|
||||||
|
p.write_bytes(new_text.replace("\r\n", "\n").encode("utf-8"))
|
||||||
|
if not apply:
|
||||||
|
for n in notes[:3]:
|
||||||
|
print(" ", n)
|
||||||
|
print(f"{'apply' if apply else 'dry-run'}: конверсий {total} в {per_file} файлах")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="6.1.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public sealed class LlmHttpClientTests
|
|||||||
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
|
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
|
||||||
{
|
{
|
||||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
||||||
LlmHttpClient client = CreateClient(handler);
|
IProviderClient client = CreateClient(handler);
|
||||||
|
|
||||||
ProviderChatResult result = await client.ChatAsync(
|
ProviderChatResult result = await client.ChatAsync(
|
||||||
OpenAiConfig(apiKey: "secret-key"),
|
OpenAiConfig(apiKey: "secret-key"),
|
||||||
@@ -87,7 +87,7 @@ public sealed class LlmHttpClientTests
|
|||||||
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
|
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
|
||||||
{
|
{
|
||||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
|
||||||
LlmHttpClient client = CreateClient(handler);
|
IProviderClient client = CreateClient(handler);
|
||||||
|
|
||||||
await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None);
|
await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None);
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ public sealed class LlmHttpClientTests
|
|||||||
const string reasoningOnlyReply =
|
const string reasoningOnlyReply =
|
||||||
"""{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }""";
|
"""{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }""";
|
||||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply));
|
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply));
|
||||||
LlmHttpClient client = CreateClient(handler);
|
IProviderClient client = CreateClient(handler);
|
||||||
|
|
||||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||||
@@ -118,7 +118,7 @@ public sealed class LlmHttpClientTests
|
|||||||
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
|
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
|
||||||
{
|
{
|
||||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply));
|
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply));
|
||||||
LlmHttpClient client = CreateClient(handler);
|
IProviderClient client = CreateClient(handler);
|
||||||
|
|
||||||
ProviderChatResult result = await client.ChatAsync(
|
ProviderChatResult result = await client.ChatAsync(
|
||||||
AnthropicConfig(),
|
AnthropicConfig(),
|
||||||
@@ -153,7 +153,7 @@ public sealed class LlmHttpClientTests
|
|||||||
public async Task ChatAsync_HttpError_Throws()
|
public async Task ChatAsync_HttpError_Throws()
|
||||||
{
|
{
|
||||||
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError));
|
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError));
|
||||||
LlmHttpClient client = CreateClient(handler);
|
IProviderClient client = CreateClient(handler);
|
||||||
|
|
||||||
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||||
@@ -171,7 +171,7 @@ public sealed class LlmHttpClientTests
|
|||||||
{
|
{
|
||||||
Content = new StringContent("<html>upstream error</html>", System.Text.Encoding.UTF8, "text/html"),
|
Content = new StringContent("<html>upstream error</html>", System.Text.Encoding.UTF8, "text/html"),
|
||||||
});
|
});
|
||||||
LlmHttpClient client = CreateClient(handler);
|
IProviderClient client = CreateClient(handler);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<LlmHttpException>(() =>
|
await Assert.ThrowsAsync<LlmHttpException>(() =>
|
||||||
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
|
||||||
@@ -186,7 +186,7 @@ public sealed class LlmHttpClientTests
|
|||||||
var handler = new StubHttpMessageHandler(
|
var handler = new StubHttpMessageHandler(
|
||||||
StubHttpMessageHandler.JsonOk(OpenAiJsonReply),
|
StubHttpMessageHandler.JsonOk(OpenAiJsonReply),
|
||||||
delay: TestHandlerDelay);
|
delay: TestHandlerDelay);
|
||||||
LlmHttpClient client = new(
|
IProviderClient client = new LlmHttpClient(
|
||||||
TestHttpClient(handler),
|
TestHttpClient(handler),
|
||||||
TestCallTimeout,
|
TestCallTimeout,
|
||||||
TestCallTimeout);
|
TestCallTimeout);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public sealed class LlmHttpClient : IProviderClient
|
|||||||
/// <param name="systemPrompt">Системный промпт.</param>
|
/// <param name="systemPrompt">Системный промпт.</param>
|
||||||
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
/// <param name="userText">Пользовательское сообщение/контекст.</param>
|
||||||
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
|
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
|
||||||
public async Task<ProviderChatResult> ChatAsync(
|
async Task<ProviderChatResult> IProviderClient.ChatAsync(
|
||||||
LlmConfig config,
|
LlmConfig config,
|
||||||
string systemPrompt,
|
string systemPrompt,
|
||||||
string userText,
|
string userText,
|
||||||
|
|||||||
@@ -46,12 +46,15 @@ public static class AuthEndpoints
|
|||||||
IOptions<CookieOptions> cookieOptions,
|
IOptions<CookieOptions> cookieOptions,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
LoginAttemptGuard loginAttemptGuard)
|
LoginAttemptGuard loginAttemptGuard,
|
||||||
|
SuspiciousActivityReporter suspicious)
|
||||||
{
|
{
|
||||||
string? attemptedLogin = NormalizeLogin(body.Login);
|
string? attemptedLogin = NormalizeLogin(body.Login);
|
||||||
|
|
||||||
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
|
||||||
{
|
{
|
||||||
|
// Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
|
||||||
|
suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
|
||||||
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,8 +111,8 @@ public static class AuthEndpoints
|
|||||||
HttpContext context,
|
HttpContext context,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var cookieName = cookieOptions.Value.Name;
|
string cookieName = cookieOptions.Value.Name;
|
||||||
var rawToken = context.Request.Cookies[cookieName];
|
string? rawToken = context.Request.Cookies[cookieName];
|
||||||
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
|
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
|
||||||
CurrentUser? user = context.GetCurrentUser();
|
CurrentUser? user = context.GetCurrentUser();
|
||||||
var logout = await authService.LogoutAsync(rawToken, ct);
|
var logout = await authService.LogoutAsync(rawToken, ct);
|
||||||
@@ -163,7 +166,7 @@ public static class AuthEndpoints
|
|||||||
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
|
var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct);
|
||||||
if (!result.Ok || result.NewToken is null)
|
if (!result.Ok || result.NewToken is null)
|
||||||
{
|
{
|
||||||
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
|
string detail = result.Error == ChangePasswordResultDto.ErrorTooShort
|
||||||
? PasswordTooShortDetail
|
? PasswordTooShortDetail
|
||||||
: WrongOldPasswordDetail;
|
: WrongOldPasswordDetail;
|
||||||
return EndpointResults.BadRequest(detail);
|
return EndpointResults.BadRequest(detail);
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ public static class OperatorAuthEndpoints
|
|||||||
HttpContext context,
|
HttpContext context,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var cookieName = cookieOptions.Value.Name;
|
string cookieName = cookieOptions.Value.Name;
|
||||||
var rawToken = context.Request.Cookies[cookieName];
|
string? rawToken = context.Request.Cookies[cookieName];
|
||||||
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
|
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
|
||||||
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
|
||||||
await operatorAuthService.LogoutAsync(rawToken, ct);
|
await operatorAuthService.LogoutAsync(rawToken, ct);
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ public sealed class OperatorBootstrapHostedService(
|
|||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
|
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
|
||||||
|
|
||||||
var login = configuration[OperatorBootstrapService.LoginEnvKey];
|
string? login = configuration[OperatorBootstrapService.LoginEnvKey];
|
||||||
var password = configuration[OperatorBootstrapService.PasswordEnvKey];
|
string? password = configuration[OperatorBootstrapService.PasswordEnvKey];
|
||||||
bool hasLogin = !string.IsNullOrWhiteSpace(login);
|
bool hasLogin = !string.IsNullOrWhiteSpace(login);
|
||||||
bool hasPassword = !string.IsNullOrWhiteSpace(password);
|
bool hasPassword = !string.IsNullOrWhiteSpace(password);
|
||||||
bool allowDevelopmentDefaults = environment.IsDevelopment();
|
bool allowDevelopmentDefaults = environment.IsDevelopment();
|
||||||
@@ -47,7 +47,7 @@ public sealed class OperatorBootstrapHostedService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
|
string? ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
|
||||||
login, password, allowDevelopmentDefaults, ct);
|
login, password, allowDevelopmentDefaults, ct);
|
||||||
if (ensuredLogin is not null)
|
if (ensuredLogin is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) :
|
|||||||
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
|
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
|
||||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
|
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
|
||||||
|
|
||||||
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
|
string login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
|
||||||
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
|
string password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
|
||||||
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
|
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
|
||||||
|
|
||||||
var seedDefaultTenant = environment.IsDevelopment()
|
bool seedDefaultTenant = environment.IsDevelopment()
|
||||||
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
|
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
|
||||||
if (seedDefaultTenant)
|
if (seedDefaultTenant)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ public sealed class OperatorSessionMiddleware
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
string cookieName = _cookieOptions.CurrentValue.Name;
|
||||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
|
||||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||||
{
|
{
|
||||||
// OperatorAuthService scoped: создаём scope на запрос через RequestServices.
|
// OperatorAuthService scoped: создаём scope на запрос через RequestServices.
|
||||||
|
|||||||
@@ -126,7 +126,13 @@ public static class RateLimitPolicies
|
|||||||
|
|
||||||
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
HttpContext http = context.HttpContext;
|
||||||
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
// Событие подозрительной активности: сработал rate limiter (актор — тенант либо IP анонима).
|
||||||
|
SuspiciousActivityReporter? reporter = http.RequestServices.GetService<SuspiciousActivityReporter>();
|
||||||
|
string actor = http.GetCurrentUser() is { } user ? user.TenantId.ToString("N") : ClientKey(http);
|
||||||
|
reporter?.Report(SuspiciousActivityReporter.RateLimitKind, actor);
|
||||||
|
|
||||||
|
http.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
await http.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ public sealed class SessionMiddleware
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cookieName = _cookieOptions.CurrentValue.Name;
|
string cookieName = _cookieOptions.CurrentValue.Name;
|
||||||
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
|
if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
|
||||||
&& !string.IsNullOrWhiteSpace(rawToken))
|
&& !string.IsNullOrWhiteSpace(rawToken))
|
||||||
{
|
{
|
||||||
// AuthService scoped: создаём scope на запрос через RequestServices.
|
// AuthService scoped: создаём scope на запрос через RequestServices.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
|||||||
|
|
||||||
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
|
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
|
||||||
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
|
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
|
string connectionString = builder.Configuration.GetConnectionString("DealPostgres")
|
||||||
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
|
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
|
||||||
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
|
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
|
||||||
builder.Services.AddSingleton<ITenantContext, TenantContext>();
|
builder.Services.AddSingleton<ITenantContext, TenantContext>();
|
||||||
@@ -177,6 +177,7 @@ builder.Services.AddSingleton(rateLimitOptions);
|
|||||||
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
|
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
|
||||||
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
|
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
|
||||||
builder.Services.AddScoped<LoginAttemptGuard>();
|
builder.Services.AddScoped<LoginAttemptGuard>();
|
||||||
|
builder.Services.AddScoped<SuspiciousActivityReporter>();
|
||||||
if (rateLimitOptions.Enabled)
|
if (rateLimitOptions.Enabled)
|
||||||
{
|
{
|
||||||
builder.Services.AddDealRateLimiter(rateLimitOptions);
|
builder.Services.AddDealRateLimiter(rateLimitOptions);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using Deal.SharedKernel.Observability;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признаки подозрительной активности и их учёт (метрика + предупреждающий лог).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Логгер событий.</param>
|
||||||
|
public sealed class SuspiciousActivityReporter(ILogger<SuspiciousActivityReporter> logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Вид: сработал rate limiter (слишком много запросов).
|
||||||
|
/// </summary>
|
||||||
|
public const string RateLimitKind = "rate_limit";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Вид: вход заблокирован после серии неудачных попыток.
|
||||||
|
/// </summary>
|
||||||
|
public const string LoginBlockedKind = "login_blocked";
|
||||||
|
|
||||||
|
// Актор неизвестен (нет ни тенанта, ни IP).
|
||||||
|
private const string UnknownActor = "-";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Учитывает событие подозрительной активности.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="kind">Вид события (константы класса).</param>
|
||||||
|
/// <param name="actor">Актор: id тенанта либо IP; null/пусто — «-».</param>
|
||||||
|
public void Report(string kind, string? actor)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||||
|
string resolvedActor = string.IsNullOrWhiteSpace(actor) ? UnknownActor : actor;
|
||||||
|
DealMetrics.SecurityEvents.Add(1, new TagList { { DealMetrics.SecurityKindTagName, kind } });
|
||||||
|
logger.LogWarning("Подозрительная активность: {Kind}, actor={Actor}", kind, resolvedActor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@ namespace Deal.Contracts;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер слоя Contracts
|
/// Маркер слоя Contracts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ContractsMarker
|
public interface IContracts
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,8 @@ public sealed class TenantContext : ITenantContext
|
|||||||
public string? SchemaName => Current.Value?.SchemaName;
|
public string? SchemaName => Current.Value?.SchemaName;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void SetTenant(TenantId tenantId) => Current.Value = tenantId;
|
void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Reset() => Current.Value = null;
|
void ITenantContext.Reset() => Current.Value = null;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Infrastructure;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер слоя Infrastructure
|
/// Маркер слоя Infrastructure
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InfrastructureMarker
|
public interface IInfrastructure
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,7 @@ using Grpc.Core;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Extensions;
|
namespace Deal.Infrastructure.Integrations.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения классификации gRPC-исключений клиентов автономных сервисов.
|
||||||
/// Расширения классификации gRPC-исключений клиентов автономных сервисов.
|
|
||||||
/// </summary>
|
|
||||||
internal static class RpcExceptionExtensions
|
internal static class RpcExceptionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ using System.Net.Sockets;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Extensions;
|
namespace Deal.Infrastructure.Integrations.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения Uri для SSRF-гейта интеграций
|
||||||
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций
|
|
||||||
/// </summary>
|
|
||||||
internal static class UriExtensions
|
internal static class UriExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiCheckResultDto> CheckAsync(AiCheckRequest request, CancellationToken ct)
|
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await IsPaidAllowedAsync(ct))
|
if (await IsPaidAllowedAsync(ct))
|
||||||
{
|
{
|
||||||
@@ -68,7 +68,7 @@ public sealed class BudgetedAiClassifier : IAiClassifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await IsPaidAllowedAsync(ct))
|
if (await IsPaidAllowedAsync(ct))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public sealed class BudgetedAiTools : IAiTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
|
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||||
{
|
{
|
||||||
BudgetStateDto state = await GateStateAsync(ct);
|
BudgetStateDto state = await GateStateAsync(ct);
|
||||||
if (state.Allowed)
|
if (state.Allowed)
|
||||||
@@ -70,7 +70,7 @@ public sealed class BudgetedAiTools : IAiTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
|
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
||||||
string text,
|
string text,
|
||||||
string description,
|
string description,
|
||||||
IReadOnlyCollection<string> keywords,
|
IReadOnlyCollection<string> keywords,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public sealed class CbrRateSource : IRatesSource
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct)
|
async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
|
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -107,7 +107,7 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
|
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct);
|
string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public sealed class GrpcAiTools : IAiTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
|
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -105,7 +105,7 @@ public sealed class GrpcAiTools : IAiTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
|
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
||||||
string text,
|
string text,
|
||||||
string description,
|
string description,
|
||||||
IReadOnlyCollection<string> keywords,
|
IReadOnlyCollection<string> keywords,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
|
async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct);
|
MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct);
|
||||||
@@ -123,7 +123,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
|
async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -144,7 +144,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
|
async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
ResetReply reply;
|
ResetReply reply;
|
||||||
@@ -172,7 +172,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task PushAsync(
|
async Task IMlClient.PushAsync(
|
||||||
string text,
|
string text,
|
||||||
string label,
|
string label,
|
||||||
double delta,
|
double delta,
|
||||||
@@ -182,7 +182,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
|
async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
var request = new TrainBatchRequest();
|
var request = new TrainBatchRequest();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
|
async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -82,7 +82,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<TelegramAuthResultDto> StartPhoneAsync(
|
async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
|
||||||
string phone,
|
string phone,
|
||||||
int apiId,
|
int apiId,
|
||||||
string apiHash,
|
string apiHash,
|
||||||
@@ -105,7 +105,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<TelegramAuthResultDto> StartQrAsync(
|
async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
|
||||||
int apiId,
|
int apiId,
|
||||||
string apiHash,
|
string apiHash,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -129,7 +129,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<string> SendCodeAsync(string code, CancellationToken ct)
|
async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -147,7 +147,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<string> SendPasswordAsync(string password, CancellationToken ct)
|
async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -165,7 +165,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task LogoutAsync(CancellationToken ct)
|
async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -181,7 +181,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
|
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -198,7 +198,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task SetMonitorAsync(
|
async Task ITelegramGateway.SetMonitorAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool enabled,
|
bool enabled,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -218,7 +218,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct)
|
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -235,7 +235,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> BackfillAsync(
|
async Task<int> ITelegramGateway.BackfillAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool force,
|
bool force,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -256,7 +256,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
|
async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -279,7 +279,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<TelegramSourceContentDto> ReadSourceAsync(
|
async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
long msgId,
|
long msgId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -303,7 +303,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
|
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
|
||||||
string query,
|
string query,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -324,7 +324,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
|
async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -350,7 +350,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<TelegramEvalReadDto> ReadForEvalAsync(
|
async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -381,7 +381,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task JoinAsync(string username, CancellationToken ct)
|
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
@@ -398,7 +398,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task LeaveAsync(string dialogId, CancellationToken ct)
|
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantId tenantId = RequireTenant();
|
TenantId tenantId = RequireTenant();
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ public sealed class LocalAiTools : IAiTools
|
|||||||
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
|
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
|
Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
|
||||||
=> throw new NotSupportedException(NotSupportedMessage);
|
=> throw new NotSupportedException(NotSupportedMessage);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
|
Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
|
||||||
string text,
|
string text,
|
||||||
string description,
|
string description,
|
||||||
IReadOnlyCollection<string> keywords,
|
IReadOnlyCollection<string> keywords,
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
|||||||
private const string IdlePhase = "idle";
|
private const string IdlePhase = "idle";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
|
Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
|
return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TelegramAuthResultDto> StartPhoneAsync(
|
Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
|
||||||
string phone,
|
string phone,
|
||||||
int apiId,
|
int apiId,
|
||||||
string apiHash,
|
string apiHash,
|
||||||
@@ -26,7 +26,7 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
|||||||
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TelegramAuthResultDto> StartQrAsync(
|
Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
|
||||||
int apiId,
|
int apiId,
|
||||||
string apiHash,
|
string apiHash,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -39,20 +39,20 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
|||||||
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
|
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
|
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
|
||||||
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task SetMonitorAsync(
|
Task ITelegramGateway.SetMonitorAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool enabled,
|
bool enabled,
|
||||||
CancellationToken ct) => Task.CompletedTask;
|
CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<int> BackfillAsync(
|
public Task<int> BackfillAsync(
|
||||||
@@ -61,40 +61,40 @@ public sealed class LocalTelegramGateway : ITelegramGateway
|
|||||||
CancellationToken ct) => Task.FromResult(0);
|
CancellationToken ct) => Task.FromResult(0);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
|
Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
|
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TelegramSourceContentDto> ReadSourceAsync(
|
Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
long msgId,
|
long msgId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
|
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
|
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
|
||||||
string query,
|
string query,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
|
Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
|
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, SourceDefaults.DefaultHue, null, false));
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TelegramEvalReadDto> ReadForEvalAsync(
|
Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
|
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
|
Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -2,9 +2,7 @@ using Deal.Infrastructure.Integrations.Storage.Options;
|
|||||||
|
|
||||||
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения MinioStorageOptions
|
||||||
/// Расширения <see cref="MinioStorageOptions"/>
|
|
||||||
/// </summary>
|
|
||||||
internal static class MinioStorageOptionsExtensions
|
internal static class MinioStorageOptionsExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения string для разбора конфигурационных значений.
|
||||||
/// Расширения <see cref="string"/> для разбора конфигурационных значений.
|
|
||||||
/// </summary>
|
|
||||||
internal static class StringExtensions
|
internal static class StringExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
|
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<string> PutAsync(
|
async Task<string> IFileStorage.PutAsync(
|
||||||
string objectKey,
|
string objectKey,
|
||||||
Stream content,
|
Stream content,
|
||||||
string contentType,
|
string contentType,
|
||||||
@@ -56,7 +56,7 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
|
Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string path = ResolvePath(objectKey);
|
string path = ResolvePath(objectKey);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
@@ -69,7 +69,7 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
|
Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string path = ResolvePath(objectKey);
|
string path = ResolvePath(objectKey);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
@@ -82,7 +82,7 @@ public sealed class LocalFileStorage : IFileStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task DeleteAsync(string objectKey, CancellationToken ct)
|
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string path = ResolvePath(objectKey);
|
string path = ResolvePath(objectKey);
|
||||||
if (File.Exists(path))
|
if (File.Exists(path))
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
|
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<string> PutAsync(
|
async Task<string> IFileStorage.PutAsync(
|
||||||
string objectKey,
|
string objectKey,
|
||||||
Stream content,
|
Stream content,
|
||||||
string contentType,
|
string contentType,
|
||||||
@@ -99,7 +99,7 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
|
async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
MemoryStream buffer = new();
|
MemoryStream buffer = new();
|
||||||
try
|
try
|
||||||
@@ -129,7 +129,7 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
|
async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -145,7 +145,7 @@ public sealed class MinioFileStorage : IFileStorage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task DeleteAsync(string objectKey, CancellationToken ct)
|
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public static class TenantSchemaMigrator
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static string CreateSchemaSql(string schemaName)
|
public static string CreateSchemaSql(string schemaName)
|
||||||
{
|
{
|
||||||
var escaped = schemaName.Replace("\"", "\"\"");
|
string escaped = schemaName.Replace("\"", "\"\"");
|
||||||
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
|
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDb
|
|||||||
{
|
{
|
||||||
public DealDbContext CreateDbContext(string[] args)
|
public DealDbContext CreateDbContext(string[] args)
|
||||||
{
|
{
|
||||||
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||||
var options = new DbContextOptionsBuilder<DealDbContext>()
|
var options = new DbContextOptionsBuilder<DealDbContext>()
|
||||||
.UseNpgsql(connectionString)
|
.UseNpgsql(connectionString)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Deal.Infrastructure.Persistence.Entities;
|
using Deal.Infrastructure.Persistence.Entities;
|
||||||
using Deal.Modules.Discovery.Application.Models;
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Discovery.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class DiscoveryStore
|
public sealed partial class DiscoveryStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task UpsertBlacklistAsync(
|
async Task IDiscoveryStore.UpsertBlacklistAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
string name,
|
string name,
|
||||||
string reason,
|
string reason,
|
||||||
@@ -38,13 +39,13 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
|
async Task IDiscoveryStore.RemoveBlacklistAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
|
async Task<DiscoveryBlacklistDto?> IDiscoveryStore.GetBlacklistAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -53,7 +54,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
|
async Task<IReadOnlyList<DiscoveryBlacklistDto>> IDiscoveryStore.ListBlacklistAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
|
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
+12
-11
@@ -1,6 +1,7 @@
|
|||||||
using Deal.Infrastructure.Persistence.Entities;
|
using Deal.Infrastructure.Persistence.Entities;
|
||||||
using Deal.Modules.Discovery.Application.Models;
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Discovery.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class DiscoveryStore
|
public sealed partial class DiscoveryStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(
|
async Task<IReadOnlyList<DiscoveryCandidateDto>> IDiscoveryStore.ListCandidatesAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
string? status,
|
string? status,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -26,7 +27,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
|
async Task<DiscoveryCandidateDto?> IDiscoveryStore.GetCandidateAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -35,19 +36,19 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
|
async Task<bool> IDiscoveryStore.IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
|
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
|
Task<bool> IDiscoveryStore.IsBlacklistedAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
|
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
|
async Task IDiscoveryStore.CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
|
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
|
||||||
@@ -66,13 +67,13 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
|
async Task IDiscoveryStore.DeleteCandidateAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> PatchCandidateAsync(
|
async Task<bool> IDiscoveryStore.PatchCandidateAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
DiscoveryCandidatePatch patch,
|
DiscoveryCandidatePatch patch,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -91,7 +92,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> SetCandidateStatusAsync(
|
async Task<bool> IDiscoveryStore.SetCandidateStatusAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
string status,
|
string status,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -110,7 +111,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> SetCandidateJoinedAsync(
|
async Task<bool> IDiscoveryStore.SetCandidateJoinedAsync(
|
||||||
string dialogId,
|
string dialogId,
|
||||||
bool autoJoined,
|
bool autoJoined,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -130,7 +131,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
|
async Task<int?> IDiscoveryStore.IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||||
@@ -146,7 +147,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
|
async Task<bool> IDiscoveryStore.SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Deal.Infrastructure.Persistence.Entities;
|
using Deal.Infrastructure.Persistence.Entities;
|
||||||
using Deal.Modules.Discovery.Application.Models;
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Discovery.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class DiscoveryStore
|
public sealed partial class DiscoveryStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task AddLogAsync(
|
async Task IDiscoveryStore.AddLogAsync(
|
||||||
string logId,
|
string logId,
|
||||||
string taskId,
|
string taskId,
|
||||||
string logEvent,
|
string logEvent,
|
||||||
@@ -29,7 +30,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> CountLogEventAsync(
|
async Task<int> IDiscoveryStore.CountLogEventAsync(
|
||||||
string logEvent,
|
string logEvent,
|
||||||
DateTimeOffset sinceUtc,
|
DateTimeOffset sinceUtc,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -38,7 +39,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(
|
async Task<IReadOnlyList<DiscoveryLogDto>> IDiscoveryStore.ListTaskLogAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Deal.Infrastructure.Persistence.Entities;
|
using Deal.Infrastructure.Persistence.Entities;
|
||||||
using Deal.Modules.Discovery.Application.Models;
|
using Deal.Modules.Discovery.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Discovery.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class DiscoveryStore
|
public sealed partial class DiscoveryStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
|
async Task<IReadOnlyList<DiscoveryTaskDto>> IDiscoveryStore.ListTasksAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
|
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -20,7 +21,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
|
async Task<DiscoveryTaskDto?> IDiscoveryStore.GetTaskAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -29,7 +30,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
|
async Task IDiscoveryStore.CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
_dbContext.DiscTasks.Add(new DiscTaskEntity
|
_dbContext.DiscTasks.Add(new DiscTaskEntity
|
||||||
@@ -52,7 +53,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> PatchTaskAsync(
|
async Task<bool> IDiscoveryStore.PatchTaskAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
DiscoveryTaskPatch patch,
|
DiscoveryTaskPatch patch,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -71,7 +72,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
|
async Task<bool> IDiscoveryStore.DeleteTaskAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||||
@@ -88,7 +89,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> SetTaskRunningAsync(
|
async Task<bool> IDiscoveryStore.SetTaskRunningAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
bool resetProgress,
|
bool resetProgress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -117,7 +118,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
|
async Task<bool> IDiscoveryStore.SetTaskPausedAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||||
@@ -133,7 +134,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
|
async Task<bool> IDiscoveryStore.SetTaskDoneAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||||
@@ -149,7 +150,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> BumpTaskCounterAsync(
|
async Task<bool> IDiscoveryStore.BumpTaskCounterAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
DiscoveryCounterField field,
|
DiscoveryCounterField field,
|
||||||
int n,
|
int n,
|
||||||
@@ -186,7 +187,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> AdvanceSearchAsync(
|
async Task<bool> IDiscoveryStore.AdvanceSearchAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
int nextIndex,
|
int nextIndex,
|
||||||
bool searchDone,
|
bool searchDone,
|
||||||
@@ -207,7 +208,7 @@ public sealed partial class DiscoveryStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
|
async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
|
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
|
||||||
.Where(task => task.Status != "done" && task.Status != "failed");
|
.Where(task => task.Status != "done" && task.Status != "failed");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
|
|||||||
using Deal.Modules.Cards.Application.Sources;
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class KanbanStore
|
public sealed partial class KanbanStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct)
|
async Task<IReadOnlyList<CardDto>> ICardStore.ListCardsAsync(CardsQuery query, CancellationToken ct)
|
||||||
{
|
{
|
||||||
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
||||||
if (query.Col is null)
|
if (query.Col is null)
|
||||||
@@ -30,7 +31,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(
|
async Task<IReadOnlyList<CardDto>> ICardStore.SearchCardsAsync(
|
||||||
string q,
|
string q,
|
||||||
int limit,
|
int limit,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -61,7 +62,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
|
async Task<CardDto?> ICardStore.GetCardAsync(string cardId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardEntity? entity = await _dbContext.Cards
|
CardEntity? entity = await _dbContext.Cards
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -76,7 +77,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<CardDto?> GetCardBySourceAsync(
|
async Task<CardDto?> ICardStore.GetCardBySourceAsync(
|
||||||
SourceRef source,
|
SourceRef source,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -104,7 +105,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
|
async Task ICardStore.AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot).
|
// CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot).
|
||||||
_dbContext.Cards.Add(ToCardEntity(snapshot));
|
_dbContext.Cards.Add(ToCardEntity(snapshot));
|
||||||
@@ -131,7 +132,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
|
async Task ICardStore.UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
@@ -152,7 +153,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
|
async Task<bool> ICardStore.ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
|
||||||
{
|
{
|
||||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
@@ -182,7 +183,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task UpdateSeenAsync(
|
async Task ICardStore.UpdateSeenAsync(
|
||||||
string? cardId,
|
string? cardId,
|
||||||
string? col,
|
string? col,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -201,7 +202,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task DeleteForeverAsync(string cardId, CancellationToken ct)
|
async Task ICardStore.DeleteForeverAsync(string cardId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||||
await _dbContext.DedupEntries
|
await _dbContext.DedupEntries
|
||||||
@@ -214,7 +215,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> ClearColAsync(string col, CancellationToken ct)
|
async Task<int> ICardStore.ClearColAsync(string col, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||||
await _dbContext.DedupEntries
|
await _dbContext.DedupEntries
|
||||||
@@ -228,7 +229,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
|
async Task<IReadOnlyDictionary<string, CardColumnCountDto>> ICardStore.CountCardsByColAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var rows = await _dbContext.Cards
|
var rows = await _dbContext.Cards
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
|
|||||||
using Deal.Modules.Cards.Application.Models;
|
using Deal.Modules.Cards.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class KanbanStore
|
public sealed partial class KanbanStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
|
async Task<IReadOnlyList<CardCommentDto>> ICardStore.ListCommentsAsync(string cardId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
List<LeadCommentEntity> entities = await _dbContext.LeadComments
|
List<LeadCommentEntity> entities = await _dbContext.LeadComments
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -23,7 +24,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task AddCommentAsync(
|
async Task ICardStore.AddCommentAsync(
|
||||||
string commentId,
|
string commentId,
|
||||||
string cardId,
|
string cardId,
|
||||||
string by,
|
string by,
|
||||||
@@ -42,7 +43,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
|
async Task ICardStore.AddMoveAsync(CardMoveDto move, CancellationToken ct)
|
||||||
{
|
{
|
||||||
_dbContext.CardMoves.Add(new CardMoveEntity
|
_dbContext.CardMoves.Add(new CardMoveEntity
|
||||||
{
|
{
|
||||||
@@ -60,7 +61,7 @@ public sealed partial class KanbanStore
|
|||||||
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
|
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
|
async Task<IReadOnlyList<AiMarkupExampleDto>> ICardStore.GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _dbContext.CardMoves
|
return await _dbContext.CardMoves
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
|
|||||||
using Deal.Modules.Cards.Application.Models;
|
using Deal.Modules.Cards.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class KanbanStore
|
public sealed partial class KanbanStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
|
async Task<IReadOnlyList<ContainerDto>> ICardStore.ListContainersAsync(string? space, CancellationToken ct)
|
||||||
{
|
{
|
||||||
IQueryable<ContainerEntity> queryable = _dbContext.Containers.AsNoTracking();
|
IQueryable<ContainerEntity> queryable = _dbContext.Containers.AsNoTracking();
|
||||||
if (space is not null)
|
if (space is not null)
|
||||||
@@ -28,7 +29,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct)
|
async Task<ContainerDto?> ICardStore.GetContainerAsync(string containerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ContainerEntity? entity = await _dbContext.Containers
|
ContainerEntity? entity = await _dbContext.Containers
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -37,14 +38,14 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task CreateContainerAsync(ContainerDto container, CancellationToken ct)
|
async Task ICardStore.CreateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||||
{
|
{
|
||||||
_dbContext.Containers.Add(ToContainerEntity(container));
|
_dbContext.Containers.Add(ToContainerEntity(container));
|
||||||
await _dbContext.SaveChangesAsync(ct);
|
await _dbContext.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task UpdateContainerAsync(ContainerDto container, CancellationToken ct)
|
async Task ICardStore.UpdateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся
|
// Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся
|
||||||
// целиком, CreatedAt не трогаем — одним UPDATE.
|
// целиком, CreatedAt не трогаем — одним UPDATE.
|
||||||
@@ -64,7 +65,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
|
async Task<int> ICardStore.DeleteContainerAsync(string containerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер
|
// Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер
|
||||||
// удалён, либо ничего не изменилось.
|
// удалён, либо ничего не изменилось.
|
||||||
@@ -84,7 +85,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ReorderContainersAsync(
|
async Task ICardStore.ReorderContainersAsync(
|
||||||
string space,
|
string space,
|
||||||
IReadOnlyList<string> containerIds,
|
IReadOnlyList<string> containerIds,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Deal.Infrastructure.Persistence.Entities;
|
using Deal.Infrastructure.Persistence.Entities;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class KanbanStore
|
public sealed partial class KanbanStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
async Task<IReadOnlyList<CardDto>> ICardStore.ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
||||||
if (containerId is not null)
|
if (containerId is not null)
|
||||||
@@ -29,7 +30,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> PatchCardAsync(
|
async Task<bool> ICardStore.PatchCardAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
CardPatch patch,
|
CardPatch patch,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -68,7 +69,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> AddLinkAsync(
|
async Task<bool> ICardStore.AddLinkAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
CardLinkDto link,
|
CardLinkDto link,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -85,7 +86,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> RemoveLinkAsync(
|
async Task<bool> ICardStore.RemoveLinkAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
string linkId,
|
string linkId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -106,7 +107,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> AddFileAsync(
|
async Task<bool> ICardStore.AddFileAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
CardFileDto file,
|
CardFileDto file,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -123,7 +124,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> RemoveFileAsync(
|
async Task<bool> ICardStore.RemoveFileAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
string fileId,
|
string fileId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -144,7 +145,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> MoveCardStageAsync(
|
async Task<bool> ICardStore.MoveCardStageAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
string containerId,
|
string containerId,
|
||||||
CardHistoryDto historyEntry,
|
CardHistoryDto historyEntry,
|
||||||
@@ -176,7 +177,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task SetReminderAsync(
|
async Task ICardStore.SetReminderAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
long atMs,
|
long atMs,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -191,7 +192,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ClearReminderAsync(string cardId, CancellationToken ct)
|
async Task ICardStore.ClearReminderAsync(string cardId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _dbContext.Cards
|
await _dbContext.Cards
|
||||||
.Where(card => card.Id == cardId)
|
.Where(card => card.Id == cardId)
|
||||||
@@ -202,7 +203,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> ClearStageAsync(string containerId, CancellationToken ct)
|
async Task<int> ICardStore.ClearStageAsync(string containerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _dbContext.Cards
|
return await _dbContext.Cards
|
||||||
.Where(card => card.Col == containerId)
|
.Where(card => card.Col == containerId)
|
||||||
@@ -210,7 +211,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
async Task<IReadOnlyList<CardReminderDueDto>> ICardStore.ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _dbContext.Cards
|
return await _dbContext.Cards
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -224,7 +225,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
async Task ICardStore.MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (cardIds.Count == 0)
|
if (cardIds.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -237,7 +238,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
async Task<int> ICardStore.ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _dbContext.Cards
|
return await _dbContext.Cards
|
||||||
.Where(card => card.ReminderAt != null && card.ReminderAt <= now)
|
.Where(card => card.ReminderAt != null && card.ReminderAt <= now)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
|
|||||||
using Deal.Modules.Cards.Application.Models;
|
using Deal.Modules.Cards.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
|
|||||||
public sealed partial class KanbanStore
|
public sealed partial class KanbanStore
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
async Task<IReadOnlyList<string>> ICardStore.ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||||
{
|
{
|
||||||
List<string> boardIds = await _dbContext.Containers
|
List<string> boardIds = await _dbContext.Containers
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -28,7 +29,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> ArchiveAsync(
|
async Task<int> ICardStore.ArchiveAsync(
|
||||||
IReadOnlyList<string> cardIds,
|
IReadOnlyList<string> cardIds,
|
||||||
DateTimeOffset archivedAt,
|
DateTimeOffset archivedAt,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -49,7 +50,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
|
async Task<IReadOnlyList<string>> ICardStore.ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _dbContext.Cards
|
return await _dbContext.Cards
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -61,7 +62,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
async Task<IReadOnlyList<string>> ICardStore.ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||||
{
|
{
|
||||||
return await _dbContext.Cards
|
return await _dbContext.Cards
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -71,7 +72,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
async Task<int> ICardStore.PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (cardIds.Count == 0)
|
if (cardIds.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -90,7 +91,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
|
async Task<IReadOnlyList<CardDto>> ICardStore.ListCardsForConversionAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
List<CardEntity> entities = await _dbContext.Cards
|
List<CardEntity> entities = await _dbContext.Cards
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -102,7 +103,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task UpdateConversionAsync(
|
async Task ICardStore.UpdateConversionAsync(
|
||||||
string cardId,
|
string cardId,
|
||||||
double? convFrom,
|
double? convFrom,
|
||||||
double? convTo,
|
double? convTo,
|
||||||
@@ -119,7 +120,7 @@ public sealed partial class KanbanStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
|
async Task<IReadOnlyList<CardDto>> ICardStore.ListInboxWithSourceAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
List<CardEntity> entities = await _dbContext.Cards
|
List<CardEntity> entities = await _dbContext.Cards
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|||||||
@@ -59,17 +59,17 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<TenantLimitDto> GetOrCreateAsync(
|
async Task<TenantLimitDto> ITenantLimitStore.GetOrCreateAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
TokenLimitDefaults? defaults = null)
|
TokenLimitDefaults? defaults)
|
||||||
{
|
{
|
||||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct);
|
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct);
|
||||||
return ToLimitDto(entity);
|
return ToLimitDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
async Task<BudgetStateDto> ITenantLimitStore.GetStateAsync(Guid tenantId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||||
@@ -77,7 +77,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<BudgetStateDto> AddUsageAsync(
|
async Task<BudgetStateDto> ITenantLimitStore.AddUsageAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
long tokens,
|
long tokens,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -110,7 +110,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<BudgetStateDto> UpdateBudgetAsync(
|
async Task<BudgetStateDto> ITenantLimitStore.UpdateBudgetAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
long budgetTokens,
|
long budgetTokens,
|
||||||
string period,
|
string period,
|
||||||
@@ -133,7 +133,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
async Task<bool> ITenantLimitStore.TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||||
@@ -149,7 +149,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
async Task<bool> ITenantLimitStore.TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||||
@@ -220,7 +220,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
async Task<int> ITenantLimitStore.ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
|
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
|
||||||
List<TenantLimitEntity> candidates = await _dbContext.TenantLimits
|
List<TenantLimitEntity> candidates = await _dbContext.TenantLimits
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<Tena
|
|||||||
{
|
{
|
||||||
public TenantDbContext CreateDbContext(string[] args)
|
public TenantDbContext CreateDbContext(string[] args)
|
||||||
{
|
{
|
||||||
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||||
var options = new DbContextOptionsBuilder<TenantDbContext>()
|
var options = new DbContextOptionsBuilder<TenantDbContext>()
|
||||||
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
|
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public sealed class AesGcmSecretCipher : ISecretCipher
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string Encrypt(string plainText)
|
string ISecretCipher.Encrypt(string plainText)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(plainText))
|
if (string.IsNullOrEmpty(plainText))
|
||||||
{
|
{
|
||||||
@@ -66,7 +66,7 @@ public sealed class AesGcmSecretCipher : ISecretCipher
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string Decrypt(string cipherText)
|
string ISecretCipher.Decrypt(string cipherText)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
|
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class TenantProvisioningService(ConnectionStringProvider connectio
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
|
public async Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var schemaName = tenantId.SchemaName;
|
string schemaName = tenantId.SchemaName;
|
||||||
var provisionLock = SchemaProvisionLocks.GetOrAdd(schemaName, static _ => new SemaphoreSlim(1, 1));
|
var provisionLock = SchemaProvisionLocks.GetOrAdd(schemaName, static _ => new SemaphoreSlim(1, 1));
|
||||||
await provisionLock.WaitAsync(ct);
|
await provisionLock.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
@@ -63,7 +63,7 @@ public sealed class TenantProvisioningService(ConnectionStringProvider connectio
|
|||||||
{
|
{
|
||||||
// Строка уже с Search Path=tenant_<id> (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
|
// Строка уже с Search Path=tenant_<id> (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
|
||||||
// лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
|
// лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
|
||||||
var connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
|
string connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
|
||||||
var options = new DbContextOptionsBuilder<TenantDbContext>()
|
var options = new DbContextOptionsBuilder<TenantDbContext>()
|
||||||
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(TenantMigrationsHistoryTable, schemaName))
|
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(TenantMigrationsHistoryTable, schemaName))
|
||||||
.Options;
|
.Options;
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Deal.Infrastructure.Tenancy;
|
namespace Deal.Infrastructure.Tenancy;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра: шардированный обход
|
/// Пакетная миграция схем всех существующих тенантов реестра для провижининга на сотни/тысячи схем.
|
||||||
/// страницами с ограниченным параллелизмом и логированием прогресса — для провижининга на сотни/тысячи схем.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TenantSchemaMigrationService(
|
public sealed class TenantSchemaMigrationService(
|
||||||
ITenantRepository tenantRepository,
|
ITenantRepository tenantRepository,
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ public interface IContainerRules
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Mode { get; }
|
public string Mode { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ключевые слова
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<string> Keywords { get; }
|
public IReadOnlyList<string> Keywords { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Стек технологий
|
||||||
|
/// </summary>
|
||||||
public IReadOnlyList<string> Stack { get; }
|
public IReadOnlyList<string> Stack { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ public interface ISourceContentProvider
|
|||||||
/// Загружает содержимое записи источника.
|
/// Загружает содержимое записи источника.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="source">Ссылка на источник.</param>
|
/// <param name="source">Ссылка на источник.</param>
|
||||||
/// <param name="ct">Токен отмены.</param>
|
|
||||||
/// <returns>Содержимое записи либо null, если источник не отдал данные.</returns>
|
/// <returns>Содержимое записи либо null, если источник не отдал данные.</returns>
|
||||||
public Task<SourceContent?> LoadAsync(SourceRef source, CancellationToken ct);
|
public Task<SourceContent?> LoadAsync(SourceRef source, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ public interface ISourceIngestObserver
|
|||||||
/// Обрабатывает принятую запись источника.
|
/// Обрабатывает принятую запись источника.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="item">Принятая запись.</param>
|
/// <param name="item">Принятая запись.</param>
|
||||||
/// <param name="ct">Токен отмены.</param>
|
|
||||||
/// <returns>Завершается после обработки; сбой наблюдателя не влияет на приём.</returns>
|
/// <returns>Завершается после обработки; сбой наблюдателя не влияет на приём.</returns>
|
||||||
public Task OnIngestedAsync(SourceItem item, CancellationToken ct);
|
public Task OnIngestedAsync(SourceItem item, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ public sealed class SourceContentResolver
|
|||||||
/// Загружает содержимое записи источника.
|
/// Загружает содержимое записи источника.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="source">Ссылка на источник.</param>
|
/// <param name="source">Ссылка на источник.</param>
|
||||||
/// <param name="ct">Токен отмены.</param>
|
|
||||||
/// <returns>Содержимое записи либо null — провайдера нет/источник не отдал данные.</returns>
|
/// <returns>Содержимое записи либо null — провайдера нет/источник не отдал данные.</returns>
|
||||||
public Task<SourceContent?> ResolveAsync(SourceRef source, CancellationToken ct)
|
public Task<SourceContent?> ResolveAsync(SourceRef source, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Cards;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер модуля Cards
|
/// Маркер модуля Cards
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CardsModuleMarker
|
public interface ICardsModule
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int Next(string taskId)
|
int IDiscoverySearchErrorCounter.Next(string taskId)
|
||||||
{
|
{
|
||||||
EvictExpired();
|
EvictExpired();
|
||||||
Entry fresh = _failures.AddOrUpdate(
|
Entry fresh = _failures.AddOrUpdate(
|
||||||
@@ -56,7 +56,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Reset(string taskId)
|
void IDiscoverySearchErrorCounter.Reset(string taskId)
|
||||||
{
|
{
|
||||||
EvictExpired();
|
EvictExpired();
|
||||||
_failures.TryRemove(taskId, out _);
|
_failures.TryRemove(taskId, out _);
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Discovery;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер модуля Discovery
|
/// Маркер модуля Discovery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DiscoveryModuleMarker
|
public interface IDiscoveryModule
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,7 @@ using Deal.Modules.Kanban.Application.Models;
|
|||||||
|
|
||||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения бюджетной группы правил колонки.
|
||||||
/// Расширения бюджетной группы правил колонки.
|
|
||||||
/// </summary>
|
|
||||||
internal static class BudgetRangeDtoExtensions
|
internal static class BudgetRangeDtoExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения списков термов правил колонки.
|
||||||
/// Расширения списков термов правил колонки.
|
|
||||||
/// </summary>
|
|
||||||
internal static class TermListExtensions
|
internal static class TermListExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
namespace Deal.Modules.Kanban.Application.Extensions;
|
namespace Deal.Modules.Kanban.Application.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения символов для нормализации бюджетной валюты.
|
||||||
/// Расширения символов для нормализации бюджетной валюты.
|
|
||||||
/// </summary>
|
|
||||||
internal static class CharExtensions
|
internal static class CharExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ public sealed partial class CardsService
|
|||||||
/// Загружает содержимое источника карточки.
|
/// Загружает содержимое источника карточки.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="card">Карточка, для которой нужно содержимое.</param>
|
/// <param name="card">Карточка, для которой нужно содержимое.</param>
|
||||||
/// <param name="ct">Токен отмены.</param>
|
|
||||||
/// <returns>Содержимое от провайдера источника либо сохранённое в карточке.</returns>
|
/// <returns>Содержимое от провайдера источника либо сохранённое в карточке.</returns>
|
||||||
public async Task<SourceContent> ResolveSourceAsync(CardDto card, CancellationToken ct)
|
public async Task<SourceContent> ResolveSourceAsync(CardDto card, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Kanban;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер модуля Kanban
|
/// Маркер модуля Kanban
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class KanbanModuleMarker
|
public interface IKanbanModule
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения кодовых точек для чистки текста сообщений.
|
||||||
/// Расширения кодовых точек для чистки текста сообщений.
|
|
||||||
/// </summary>
|
|
||||||
internal static class CodePointExtensions
|
internal static class CodePointExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения строк для разбора текста сообщений.
|
||||||
/// Расширения строк для разбора текста сообщений.
|
|
||||||
/// </summary>
|
|
||||||
internal static class StringExtensions
|
internal static class StringExtensions
|
||||||
{
|
{
|
||||||
private static readonly string[] FooterHintsArray =
|
private static readonly string[] FooterHintsArray =
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ public sealed class CardReclassifier(
|
|||||||
/// Пакетная переклассификация «Неразобранного»
|
/// Пакетная переклассификация «Неразобранного»
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
|
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
|
||||||
/// <param name="ct">Токен отмены.</param>
|
|
||||||
/// <param name="progress">Наблюдатель прогресса (SSE); null — без отчётов.</param>
|
/// <param name="progress">Наблюдатель прогресса (SSE); null — без отчётов.</param>
|
||||||
/// <returns>Итог прохода (счётчики исхода) либо <c>busy</c>, если проход уже идёт.</returns>
|
/// <returns>Итог прохода (счётчики исхода) либо <c>busy</c>, если проход уже идёт.</returns>
|
||||||
public async Task<ReclassifyResultDto> ReclassifyInboxAsync(
|
public async Task<ReclassifyResultDto> ReclassifyInboxAsync(
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ public sealed partial class PipelineWorkerService
|
|||||||
/// Прогоняет текст по этапам конвейера без записи в систему.
|
/// Прогоняет текст по этапам конвейера без записи в систему.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="text">Текст для проверки.</param>
|
/// <param name="text">Текст для проверки.</param>
|
||||||
/// <param name="ct">Токен отмены.</param>
|
|
||||||
/// <returns>Результаты этапов, разбор и целевой контейнер (если карточка была бы создана).</returns>
|
/// <returns>Результаты этапов, разбор и целевой контейнер (если карточка была бы создана).</returns>
|
||||||
public async Task<PipelineDryRunDto> DryRunAsync(string text, CancellationToken ct)
|
public async Task<PipelineDryRunDto> DryRunAsync(string text, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Pipeline;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер модуля Pipeline
|
/// Маркер модуля Pipeline
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PipelineModuleMarker
|
public interface IPipelineModule
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ public static class SettingsKeys
|
|||||||
|
|
||||||
public const string ExcludeTypes = "excludeTypes";
|
public const string ExcludeTypes = "excludeTypes";
|
||||||
|
|
||||||
// ── Dict / special ──
|
// ── Словари / особые ──
|
||||||
|
|
||||||
public const string ColState = "colState";
|
public const string ColState = "colState";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Settings;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер модуля Settings
|
/// Маркер модуля Settings
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SettingsModuleMarker
|
public interface ISettingsModule
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Telegram;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер модуля Telegram
|
/// Маркер модуля Telegram
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TelegramModuleMarker
|
public interface ITelegramModule
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
namespace Deal.Modules.Tenants.Application.Models;
|
namespace Deal.Modules.Tenants.Application.Models;
|
||||||
|
|
||||||
/// <summary>
|
// Расширения записей аудита
|
||||||
/// Расширения записей аудита
|
|
||||||
/// </summary>
|
|
||||||
internal static class AuditRecordDtoExtensions
|
internal static class AuditRecordDtoExtensions
|
||||||
{
|
{
|
||||||
// События аудита «неудачный вход» (тенант/оператор).
|
// События аудита «неудачный вход» (тенант/оператор).
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed class AuthService(
|
|||||||
string password,
|
string password,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var normalizedLogin = NormalizeLogin(login);
|
string normalizedLogin = NormalizeLogin(login);
|
||||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
||||||
{
|
{
|
||||||
return new LoginResultDto(null, null);
|
return new LoginResultDto(null, null);
|
||||||
@@ -61,7 +61,7 @@ public sealed class AuthService(
|
|||||||
Error: LoginResultDto.ErrorTenantSuspended);
|
Error: LoginResultDto.ErrorTenantSuspended);
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||||
return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
|
return new LoginResultDto(user.Login, token, user.Id, user.TenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ public sealed class AuthService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenHash = SessionTokens.HashToken(rawToken);
|
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||||
await authStore.DeleteSessionAsync(tokenHash, ct);
|
await authStore.DeleteSessionAsync(tokenHash, ct);
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ public sealed class AuthService(
|
|||||||
string newPassword,
|
string newPassword,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var normalizedLogin = NormalizeLogin(login);
|
string normalizedLogin = NormalizeLogin(login);
|
||||||
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
|
||||||
if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
|
if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash))
|
||||||
{
|
{
|
||||||
@@ -125,7 +125,7 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
|
await authStore.DeleteSessionsByUserIdAsync(user.Id, ct);
|
||||||
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
|
await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct);
|
||||||
var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
string token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct);
|
||||||
return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
|
return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ public sealed class AuthService(
|
|||||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
|
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalizedLogin = NormalizeLogin(targetLogin);
|
string normalizedLogin = NormalizeLogin(targetLogin);
|
||||||
if (string.IsNullOrEmpty(normalizedLogin))
|
if (string.IsNullOrEmpty(normalizedLogin))
|
||||||
{
|
{
|
||||||
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
|
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
|
||||||
@@ -159,7 +159,7 @@ public sealed class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
UserIdentityDto firstUser = tenantUsers[0];
|
UserIdentityDto firstUser = tenantUsers[0];
|
||||||
var rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
|
string rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct);
|
||||||
return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
|
return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ public sealed class AuthService(
|
|||||||
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
|
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
|
string rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct);
|
||||||
return Success(rawToken, user.Id, user.Login, user.TenantId);
|
return Success(rawToken, user.Id, user.Login, user.TenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ public sealed class AuthService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenHash = SessionTokens.HashToken(rawToken);
|
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||||
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||||
UserIdentityDto? user = null;
|
UserIdentityDto? user = null;
|
||||||
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
@@ -230,7 +230,7 @@ public sealed class AuthService(
|
|||||||
Guid? impersonatedByOperatorId,
|
Guid? impersonatedByOperatorId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var rawToken = SessionTokens.NewToken();
|
string rawToken = SessionTokens.NewToken();
|
||||||
var session = new SessionDto(
|
var session = new SessionDto(
|
||||||
TokenHash: SessionTokens.HashToken(rawToken),
|
TokenHash: SessionTokens.HashToken(rawToken),
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
|
|||||||
string password,
|
string password,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var normalizedLogin = NormalizeLogin(login);
|
string normalizedLogin = NormalizeLogin(login);
|
||||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
|
||||||
{
|
{
|
||||||
return new OperatorLoginResultDto(null, null);
|
return new OperatorLoginResultDto(null, null);
|
||||||
@@ -39,7 +39,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
|
|||||||
return new OperatorLoginResultDto(null, null);
|
return new OperatorLoginResultDto(null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = await CreateSessionForOperatorAsync(operatorRecord, ct);
|
string token = await CreateSessionForOperatorAsync(operatorRecord, ct);
|
||||||
return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
|
return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenHash = SessionTokens.HashToken(rawToken);
|
string tokenHash = SessionTokens.HashToken(rawToken);
|
||||||
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
|
||||||
OperatorIdentityDto? operatorIdentity = null;
|
OperatorIdentityDto? operatorIdentity = null;
|
||||||
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
@@ -83,7 +83,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
|
|||||||
else if (session is not null)
|
else if (session is not null)
|
||||||
{
|
{
|
||||||
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
|
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
|
||||||
// (hot-path, Security review).
|
// (горячий путь запросов).
|
||||||
await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
|
await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
|
|||||||
// Возвращает: Raw-токен для выдачи клиенту.
|
// Возвращает: Raw-токен для выдачи клиенту.
|
||||||
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
|
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var rawToken = SessionTokens.NewToken();
|
string rawToken = SessionTokens.NewToken();
|
||||||
var session = new OperatorSessionDto(
|
var session = new OperatorSessionDto(
|
||||||
TokenHash: SessionTokens.HashToken(rawToken),
|
TokenHash: SessionTokens.HashToken(rawToken),
|
||||||
OperatorId: operatorRecord.Id,
|
OperatorId: operatorRecord.Id,
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStor
|
|||||||
bool allowDevelopmentDefaults,
|
bool allowDevelopmentDefaults,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var normalizedLogin = NormalizeLogin(login);
|
string normalizedLogin = NormalizeLogin(login);
|
||||||
var resolvedPassword = password;
|
string? resolvedPassword = password;
|
||||||
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
|
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
|
||||||
{
|
{
|
||||||
if (!allowDevelopmentDefaults)
|
if (!allowDevelopmentDefaults)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public static class SessionTokens
|
|||||||
/// <returns>64 hex-символа.</returns>
|
/// <returns>64 hex-символа.</returns>
|
||||||
public static string HashToken(string rawToken)
|
public static string HashToken(string rawToken)
|
||||||
{
|
{
|
||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
|
||||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ public sealed class SuspiciousActivityService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int AuthFailuresPerTenantThreshold = 20;
|
public const int AuthFailuresPerTenantThreshold = 20;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
|
||||||
|
/// </summary>
|
||||||
|
public const int DistinctLoginsPerIpThreshold = 5;
|
||||||
|
|
||||||
// Кратность порога, с которой уровень поднимается до high (2× порог).
|
// Кратность порога, с которой уровень поднимается до high (2× порог).
|
||||||
private const int HighSeverityMultiplier = 2;
|
private const int HighSeverityMultiplier = 2;
|
||||||
|
|
||||||
@@ -62,6 +67,11 @@ public sealed class SuspiciousActivityService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
|
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Правило: перебор разных логинов с одного IP.
|
||||||
|
/// </summary>
|
||||||
|
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Уровень находки
|
/// Уровень находки
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -126,6 +136,7 @@ public sealed class SuspiciousActivityService
|
|||||||
AddFailedLoginsPerLogin(records, findings);
|
AddFailedLoginsPerLogin(records, findings);
|
||||||
AddManyIpsPerActor(records, findings);
|
AddManyIpsPerActor(records, findings);
|
||||||
AddAuthFailuresPerTenant(records, findings);
|
AddAuthFailuresPerTenant(records, findings);
|
||||||
|
AddDistinctLoginsPerIp(records, findings);
|
||||||
|
|
||||||
findings.Sort(static (left, right) =>
|
findings.Sort(static (left, right) =>
|
||||||
{
|
{
|
||||||
@@ -255,6 +266,48 @@ public sealed class SuspiciousActivityService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Правило «перебор разных логинов с одного IP» (credential stuffing).
|
||||||
|
// records: Записи окна.
|
||||||
|
// findings: Накопитель находок.
|
||||||
|
private static void AddDistinctLoginsPerIp(IReadOnlyList<AuditRecordDto> records, List<SuspiciousFindingDto> findings)
|
||||||
|
{
|
||||||
|
var loginsByIp = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||||
|
foreach (AuditRecordDto record in records)
|
||||||
|
{
|
||||||
|
if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? login = ExtractLogin(record);
|
||||||
|
if (string.IsNullOrWhiteSpace(login))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginsByIp.TryGetValue(record.Ip, out HashSet<string>? logins))
|
||||||
|
{
|
||||||
|
logins = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
loginsByIp[record.Ip] = logins;
|
||||||
|
}
|
||||||
|
|
||||||
|
logins.Add(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((string ip, HashSet<string> logins) in loginsByIp)
|
||||||
|
{
|
||||||
|
if (logins.Count >= DistinctLoginsPerIpThreshold)
|
||||||
|
{
|
||||||
|
findings.Add(new SuspiciousFindingDto(
|
||||||
|
KindDistinctLoginsPerIp,
|
||||||
|
SeverityFor(logins.Count, DistinctLoginsPerIpThreshold),
|
||||||
|
ip,
|
||||||
|
logins.Count,
|
||||||
|
$"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются).
|
// Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются).
|
||||||
// records: Записи окна.
|
// records: Записи окна.
|
||||||
// predicate: Отбор записей правила.
|
// predicate: Отбор записей правила.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed class TenantService(ITenantRepository tenantRepository, ITenantPro
|
|||||||
CreatedAt: DateTimeOffset.UtcNow),
|
CreatedAt: DateTimeOffset.UtcNow),
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
var tenantIdValue = id.ToString("N");
|
string tenantIdValue = id.ToString("N");
|
||||||
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
|
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
|
||||||
return new TenantId(tenantIdValue);
|
return new TenantId(tenantIdValue);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Tenants;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер модуля Tenants
|
/// Маркер модуля Tenants
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TenantsModuleMarker
|
public interface ITenantsModule
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
+1
-1
@@ -5,6 +5,6 @@ namespace Deal.SharedKernel;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Маркер слоя SharedKernel
|
/// Маркер слоя SharedKernel
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SharedKernelMarker
|
public interface ISharedKernel
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,11 @@ public static class DealMetrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string TenantTagName = "tenant";
|
public const string TenantTagName = "tenant";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Имя метки вида события безопасности
|
||||||
|
/// </summary>
|
||||||
|
public const string SecurityKindTagName = "kind";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Имя метрики доли израсходованного ИИ-бюджета
|
/// Имя метрики доли израсходованного ИИ-бюджета
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -82,6 +87,12 @@ public static class DealMetrics
|
|||||||
public static readonly Counter<long> AuditEvents =
|
public static readonly Counter<long> AuditEvents =
|
||||||
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
|
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// События подозрительной активности по видам
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Counter<long> SecurityEvents =
|
||||||
|
Meter.CreateCounter<long>("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked).");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Суммарная глубина очереди пайплайна
|
/// Суммарная глубина очереди пайплайна
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ namespace Deal.SharedKernel.Tenants.Abstractions;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITenantContext
|
public interface ITenantContext
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Идентификатор текущего тенанта или null для системного контекста
|
||||||
|
/// </summary>
|
||||||
public TenantId? TenantId { get; }
|
public TenantId? TenantId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Признак наличия текущего тенанта
|
||||||
|
/// </summary>
|
||||||
public bool HasTenant { get; }
|
public bool HasTenant { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public static class UrlSafeToken
|
|||||||
{
|
{
|
||||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
|
||||||
|
|
||||||
var bytes = new byte[byteCount];
|
byte[] bytes = new byte[byteCount];
|
||||||
RandomNumberGenerator.Fill(bytes);
|
RandomNumberGenerator.Fill(bytes);
|
||||||
return Convert.ToBase64String(bytes)
|
return Convert.ToBase64String(bytes)
|
||||||
.TrimEnd('=')
|
.TrimEnd('=')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Api;
|
namespace Deal.Tests.Unit.Api;
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ public sealed class OperatorBootstrapHostedServiceTests
|
|||||||
.AddInMemoryCollection(values)
|
.AddInMemoryCollection(values)
|
||||||
.Build();
|
.Build();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new FakeOperatorAuthStore();
|
||||||
var passwordHasher = new FakePasswordHasher();
|
var passwordHasher = TestHashers.New();
|
||||||
var logger = new ListLogger();
|
var logger = new ListLogger();
|
||||||
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
|
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
|
||||||
// провайдер собирается с реальной регистрацией модуля поверх фейков.
|
// провайдер собирается с реальной регистрацией модуля поверх фейков.
|
||||||
@@ -150,7 +151,7 @@ public sealed class OperatorBootstrapHostedServiceTests
|
|||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
OperatorBootstrapHostedService Hosted,
|
OperatorBootstrapHostedService Hosted,
|
||||||
FakeOperatorAuthStore Store,
|
FakeOperatorAuthStore Store,
|
||||||
FakePasswordHasher PasswordHasher,
|
IPasswordHasher PasswordHasher,
|
||||||
ListLogger Logs);
|
ListLogger Logs);
|
||||||
|
|
||||||
// Окружение хоста с фиксированным именем (тестовый IHostEnvironment).
|
// Окружение хоста с фиксированным именем (тестовый IHostEnvironment).
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Deal.Api.Services;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit-тесты учёта подозрительной активности
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SuspiciousActivityReporterTests
|
||||||
|
{
|
||||||
|
// Актор сценариев.
|
||||||
|
private const string Actor = "tenant-1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Report инкрементит deal.security.suspicious с меткой kind
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Report_IncrementsSecurityCounter_WithKindTag()
|
||||||
|
{
|
||||||
|
using var capture = new SecurityEventCapture();
|
||||||
|
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
|
||||||
|
|
||||||
|
reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor);
|
||||||
|
|
||||||
|
Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind));
|
||||||
|
(string? Kind, long Value) measurement = Assert.Single(
|
||||||
|
capture.Snapshot(),
|
||||||
|
item => item.Kind == SuspiciousActivityReporter.RateLimitKind);
|
||||||
|
Assert.Equal(1, measurement.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Report пишет предупреждение с видом и актором
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Report_WritesWarningWithKindAndActor()
|
||||||
|
{
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
var reporter = new SuspiciousActivityReporter(logger);
|
||||||
|
|
||||||
|
reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||||
|
Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message);
|
||||||
|
Assert.Contains($"actor={Actor}", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// null/пусто/пробелы actor → в логе «-»
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actor">Пустой актор сценария.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Report_UnknownActor_WritesDash(string? actor)
|
||||||
|
{
|
||||||
|
var logger = new RecordingLogger();
|
||||||
|
var reporter = new SuspiciousActivityReporter(logger);
|
||||||
|
|
||||||
|
reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Contains("actor=-", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пустой/пробельный kind → ArgumentException
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="kind">Пустой вид события сценария.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Report_EmptyOrWhitespaceKind_Throws(string? kind)
|
||||||
|
{
|
||||||
|
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
|
||||||
|
|
||||||
|
Assert.ThrowsAny<ArgumentException>(() => reporter.Report(kind!, Actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory логгер: копит записи с уровнем, сообщением и исключением.
|
||||||
|
private sealed class RecordingLogger : ILogger<SuspiciousActivityReporter>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Записи лога в порядке поступления.
|
||||||
|
/// </summary>
|
||||||
|
public List<LogEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
|
where TState : notnull => null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись лога, снятая in-memory логгером.
|
||||||
|
private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ using Deal.SharedKernel.Tenants.Models;
|
|||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Contracts;
|
namespace Deal.Tests.Unit.Contracts;
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ public sealed class BudgetedAiClassifierTests
|
|||||||
// Paid: Платный фейк-исполнитель (счётчики/ответы сценария).
|
// Paid: Платный фейк-исполнитель (счётчики/ответы сценария).
|
||||||
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
|
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
BudgetedAiClassifier Decorator,
|
IAiClassifier Decorator,
|
||||||
FakeAiClassifier Paid,
|
FakeAiClassifier Paid,
|
||||||
FakeSettingsStore Settings);
|
FakeSettingsStore Settings);
|
||||||
|
|
||||||
@@ -160,10 +162,10 @@ public sealed class BudgetedAiClassifierTests
|
|||||||
var settings = new FakeSettingsStore();
|
var settings = new FakeSettingsStore();
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new FakeTenantLimitStore();
|
||||||
configure?.Invoke(limits);
|
configure?.Invoke(limits);
|
||||||
var tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var paid = new FakeAiClassifier();
|
var paid = new FakeAiClassifier();
|
||||||
var decorator = new BudgetedAiClassifier(
|
IAiClassifier decorator = new BudgetedAiClassifier(
|
||||||
paid,
|
paid,
|
||||||
new LocalAiClassifier(new LocalFieldsParser(settings)),
|
new LocalAiClassifier(new LocalFieldsParser(settings)),
|
||||||
limits,
|
limits,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ using Deal.Modules.Tenants.Application.Models;
|
|||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Contracts;
|
namespace Deal.Tests.Unit.Contracts;
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ public sealed class BudgetedAiToolsTests
|
|||||||
// Decorator: Декоратор бюджетного гейта.
|
// Decorator: Декоратор бюджетного гейта.
|
||||||
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
|
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
BudgetedAiTools Decorator,
|
IAiTools Decorator,
|
||||||
FakeAiTools Paid);
|
FakeAiTools Paid);
|
||||||
|
|
||||||
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
|
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
|
||||||
@@ -139,10 +141,10 @@ public sealed class BudgetedAiToolsTests
|
|||||||
{
|
{
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new FakeTenantLimitStore();
|
||||||
configure?.Invoke(limits);
|
configure?.Invoke(limits);
|
||||||
var tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var paid = new FakeAiTools();
|
var paid = new FakeAiTools();
|
||||||
var decorator = new BudgetedAiTools(
|
IAiTools decorator = new BudgetedAiTools(
|
||||||
paid,
|
paid,
|
||||||
limits,
|
limits,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user