Compare commits

...
10 Commits
Author SHA1 Message Date
Rustam Khalimov b745c324e3 Подключить NSubstitute и мигрировать FakePasswordHasher, зафиксировать правила тестов
ci / build-test (push) Has been cancelled
NSubstitute 6.1.0 в 5 тест-проектов; эталон миграции: FakePasswordHasher удалён,
вместо него Support/TestHashers.New() (Substitute.For с детерминированной
семантикой fake-hash:, DI-регистрации переведены на инстанс хелпера).
Правила владельца в §11 код-стайла: классы напрямую не вызываем (DTO/хелперы/
экстеншены — исключения), моки через NSubstitute, маркерные классы не используем.
План миграции оставшихся фейков — backlog TD-TESTS-NSUBSTITUTE. Build 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 20:25:39 +03:00
Rustam Khalimov 93e9100ae4 Перевести маркерные классы на маркерные интерфейсы
10 пустых классов-маркеров (модули, Contracts/Infrastructure/SharedKernel)
заменены интерфейсами IKanbanModule/ICardsModule/.../ISharedKernel по правилу
владельца: маркерные классы не используем. Файлы переименованы под имя типа,
тесты проверяют IsInterface + IsPublic.
2026-09-11 20:18:17 +03:00
Rustam Khalimov 206d61068a Перевести реализации интерфейсов на явные (§11, вариант A)
Codemod scripts/make_explicit.py: 161 член в 30 прод-файлах конвертирован в
вид "Тип IFoo.Член" (частичные классы и многострочные сигнатуры учтены; Card и
ICard-семейство — DTO, оставлены implicit). Потребители, дёргавшие классы
напрямую, перетипизированы на интерфейсы: 8 мест в проде (самовызовы через
((ISessionClient)this), снят дефолт параметра в явной реализации) и 17 тестовых
файлов (поля, tuple-деконструкции, var/target-typed new). Build 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 20:16:06 +03:00
Rustam Khalimov 355723f4ad Убрать __pycache__ из индекса
Файлы регенерируемые и уже в .gitignore; в коммит нормализации попали из-за
отмены индексации при git reset. Рабочие копии остаются на месте.
2026-09-11 19:19:58 +03:00
Rustam Khalimov fd64c11441 Убрать очевидные <param> токенов отмены и ужать 3 summary
73 <param name="ct|cancellationToken"> («Токен отмены.») — пересказ сигнатуры,
запрещённый §5, удалены в 17 файлах; ужаты 2 многосентенционных и 1 длинное
summary. Контроль краткости: <remarks> 0, inline-<summary> 0, многосентенционных
0, TODO 0. Build 5 sln 0/0, тесты зелёные (1340/130/52/38/9).
2026-09-11 19:17:00 +03:00
Rustam Khalimov a7e38846d7 Обновить доки по закрытию остатков код-стайла
Аудит §2 переписан под решения (var-гейт, LF, дедуп закрыт — дублей нет);
backlog: TD-COMMENTS-IFACE п.1–3 закрыты, TD-STYLE-ANALYZERS закрыт; STATUS.md —
новый блок захода, устаревший блок «Осталось (в backlog)» в шапке удалён; план и
ledger захода.
2026-09-11 19:01:42 +03:00
Rustam Khalimov 713d554dc2 Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
2026-09-11 19:01:42 +03:00
Rustam Khalimov 39c9bdc1b7 Дочистить код-стайл: var встроенных типов, приватные доки, доки интерфейсам
Гейт csharp_style_var_for_built_in_types=false:warning (ломает сборку), остаток
выправлен dotnet format IDE0008 по 5 sln (только встроенные типы). Понижено 12
новых XML-доков private/internal, добавлены <summary> членам IContainerRules и
ITenantContext, 3 англоязычных комментария переведены на русский.
2026-09-11 19:00:35 +03:00
Rustam Khalimov dde86fd63c Добавить codemod дедупликации summary и уточнить бэклог
scripts/dedup_summary_inheritdoc.py (dry-run не нашёл простых дублей summary в продакшне — реализации уже на <inheritdoc/>); закрыт TD-SETTINGS-UI, DEFERRED с обоснованием TD-CARD-MERGE/TD-VIRT/TD-STORE-ATTACH/TD-SOURCE-CONTACTS; игнор __pycache__.
2026-09-11 18:19:46 +03:00
Rustam Khalimov f4737b9797 Расширить детектор подозрительной активности
Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
2026-09-11 18:15:20 +03:00
1089 changed files with 130453 additions and 129454 deletions
+4 -2
View File
@@ -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
+20
View File
@@ -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
+4
View File
@@ -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/доки.
+9 -8
View File
@@ -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-проверки (нужны внешние условия)
+9 -4
View File
@@ -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 — разовое решение по политике переводов строк.
+24 -3
View File
@@ -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.
+190
View File
@@ -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())
+234
View File
@@ -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);
+1 -1
View File
@@ -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,
+7 -4
View File
@@ -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.
+2 -1
View File
@@ -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;
} }
@@ -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;
} }
@@ -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()
@@ -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_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext // Строка уже с Search Path=tenant_&lt;id&gt; (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)
{ {
@@ -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 _);
@@ -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)
{ {
@@ -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)
{ {
@@ -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";
@@ -3,6 +3,6 @@ namespace Deal.Modules.Settings;
/// <summary> /// <summary>
/// Маркер модуля Settings /// Маркер модуля Settings
/// </summary> /// </summary>
public sealed class SettingsModuleMarker public interface ISettingsModule
{ {
} }
@@ -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);
} }
@@ -3,6 +3,6 @@ namespace Deal.Modules.Tenants;
/// <summary> /// <summary>
/// Маркер модуля Tenants /// Маркер модуля Tenants
/// </summary> /// </summary>
public sealed class TenantsModuleMarker public interface ITenantsModule
{ {
} }
@@ -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