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
end_of_line = crlf
# LF: пишут инструменты проекта (Python/Node), требуется shell-скриптам на Linux CI
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
@@ -30,7 +31,8 @@ dotnet_style_qualification_for_method = 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_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/
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-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 |
| 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-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-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-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-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. Инфраструктура и эксплуатация
@@ -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-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-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. Технический долг (качество/архитектура)
| ID | Пункт | Источник | Приоритет | Статус |
|---|---|---|---|---|
| TD-SETTINGS-UI | Вынос оставшихся вкладок `SettingsView` в отдельные компоненты (частично сделано) | ревью 2026-09-08 | P3 | TECHDEBT |
| TD-VIRT | Полная виртуализация длинных колонок (сейчас — прогрессивный рендер «Показать ещё») | ревью, этап 12 | P3 | TECHDEBT |
| TD-SETTINGS-UI | Вынос вкладок `SettingsView` в компоненты. **Сделано (2026-09-11):** `SettingsView.vue` — только набор вкладок/QR-опрос, все 10 вкладок — отдельные компоненты (`components/settings/*`) | ревью 2026-09-08 | P3 | DONE |
| 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-DBL-CLICK | **Сделано (2026-09-11):** перезагрузка доски при batch-переклассификации коалесцируется `scheduleBoardReload()` (ответ + SSE → один запрос) | этап 12, E | P3 | DONE |
| 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-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-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 |
## 6. Manual-проверки (нужны внешние условия)
+9 -4
View File
@@ -232,11 +232,16 @@
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
Текст описания пишется **один раз** — у интерфейса.
- **Явная реализация интерфейсов — где возможно.** Предпочитать явную реализацию
(`Task ICardStore.GetAsync(...)`), если член не является публичным API класса сам по себе. Если тип
реализует член как собственный публичный сервис (нужен в DI/прямых вызовах) — допустима implicit,
но решение осознанное.
- **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
(напр. `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. Приложение: сводная таблица правил именования
@@ -28,23 +28,35 @@
- Тесты: core **1275/1275**, telegram **125/125**, ai **52/52**, ml **38/38** — все пройдены.
- Повторный прогон renamer: `this.` — 0, полей к переименованию — 0 (идемпотентно).
## 2. Осталось — требует решения владельца
## 2. Остатки — решения (закрыто 2026-09-11, вечер)
1. **`var`1529 употреблений.** Правило §4: не использовать для встроенных типов и при неочевидном типе.
В `.editorconfig` `csharp_style_var_* = false:silent`. Замена требует семантики (вывод типа).
**Рекомендация:** включить анализатор (`:warning`) + `dotnet format` с проверкой.
1. **`var`закрыто.** В `.editorconfig` включён ломающий сборку гейт `csharp_style_var_for_built_in_types = false:warning`
(запрет только для встроенных типов — как в §4); режимы «очевидный тип» и «прочие» оставлены `silent`
осознанно: правка субъективна и потребовала бы ~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).** Субъективное «где возможно» — массовая правка может сломать
DI/прямые вызовы и тесты. **Рекомендация:** точечный ревью по 61 интерфейсу, без автоматизации.
### Попутно исправлено (2026-09-11, вечер)
3. **Дедупликация `<summary>` в реализациях через `/// <inheritdoc/>` (§11).** Надёжно детектируется только
по семантической модели (сопоставление интерфейс↔класс). В коде уже 674 `<inheritdoc/>`.
**Рекомендация:** Roslyn-анализатор, если нужно добить остаток.
4. **Переводы строк.** `.editorconfig` требует `end_of_line = crlf`, фактически: **231 файл CRLF / 697 LF**
(смешанно). Правка объёмная. **Рекомендация:** решить — нормализовать под CRLF или зафиксировать LF.
## 3. Примечание
Пункты 2.1, 2.3 можно закрыть анализаторами Roslyn в `Directory.Build.props` — это даст автоматическую
проверку на новом коде. Пункт 2.4 — разовое решение по политике переводов строк.
- Повторный прогон `scripts/fix_private_docs.py --apply`: понижено 12 XML-доков на private/internal (extension-файлы).
- Добавлены недостающие `<summary>`: `IContainerRules.Keywords`/`Stack`, `ITenantContext.TenantId`/`HasTenant`.
- Переведены на русский англоязычные `//`-комментарии (3 шт. из 18 найденных; остальные — имена
сущностей/заголовки секций тестов, не англоязычный текст).
- Из индекса убраны случайно закоммиченные `archive/**/__pycache__/*.pyc` (2 шт., уже в `.gitignore`).
- STATUS.md: удалён устаревший блок «Осталось (в backlog)» в шапке (пункты закрыты generic-контрактом источника).
- Дочистка по контрольному скану краткости: удалены 73 очевидных `<param name="ct|cancellationToken">`
(«Токен отмены.» — пересказ сигнатуры, §5) в 17 файлах; ужаты 3 summary (2 многосентенционных, 1 длинное).
Контроль: `<remarks>` — 0, inline-`<summary>` — 0, многосентенционных summary — 0, TODO — 0.
+24 -3
View File
@@ -17,9 +17,30 @@
> единый 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` зелёные.
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
> (`TelegramStore`, `Dialogs`/`TgMessages`, Discovery) в telegram-сервис.
>
> **2026-09-11 (вечер) — закрыты остатки код-стайла (TD-COMMENTS-IFACE, TD-STYLE-ANALYZERS).** Дедупликация
> `<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
> `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}`
(общий `RuntimeDepthsCollector`, без дублей SQL).
- **Подозрительная активность (§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)** — во фронтенде.
Итог: 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>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<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.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -53,7 +53,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_OpenAiStyle_BuildsWireRequest()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
LlmHttpClient client = CreateClient(handler);
IProviderClient client = CreateClient(handler);
ProviderChatResult result = await client.ChatAsync(
OpenAiConfig(apiKey: "secret-key"),
@@ -87,7 +87,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply));
LlmHttpClient client = CreateClient(handler);
IProviderClient client = CreateClient(handler);
await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None);
@@ -103,7 +103,7 @@ public sealed class LlmHttpClientTests
const string reasoningOnlyReply =
"""{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }""";
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply));
LlmHttpClient client = CreateClient(handler);
IProviderClient client = CreateClient(handler);
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
@@ -118,7 +118,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_AnthropicStyle_BuildsWireRequest()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply));
LlmHttpClient client = CreateClient(handler);
IProviderClient client = CreateClient(handler);
ProviderChatResult result = await client.ChatAsync(
AnthropicConfig(),
@@ -153,7 +153,7 @@ public sealed class LlmHttpClientTests
public async Task ChatAsync_HttpError_Throws()
{
var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError));
LlmHttpClient client = CreateClient(handler);
IProviderClient client = CreateClient(handler);
LlmHttpException exception = await Assert.ThrowsAsync<LlmHttpException>(() =>
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"),
});
LlmHttpClient client = CreateClient(handler);
IProviderClient client = CreateClient(handler);
await Assert.ThrowsAsync<LlmHttpException>(() =>
client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None));
@@ -186,7 +186,7 @@ public sealed class LlmHttpClientTests
var handler = new StubHttpMessageHandler(
StubHttpMessageHandler.JsonOk(OpenAiJsonReply),
delay: TestHandlerDelay);
LlmHttpClient client = new(
IProviderClient client = new LlmHttpClient(
TestHttpClient(handler),
TestCallTimeout,
TestCallTimeout);
+1 -1
View File
@@ -65,7 +65,7 @@ public sealed class LlmHttpClient : IProviderClient
/// <param name="systemPrompt">Системный промпт.</param>
/// <param name="userText">Пользовательское сообщение/контекст.</param>
/// <returns>Текст ответа и usage API-ответа (null при его отсутствии).</returns>
public async Task<ProviderChatResult> ChatAsync(
async Task<ProviderChatResult> IProviderClient.ChatAsync(
LlmConfig config,
string systemPrompt,
string userText,
+7 -4
View File
@@ -46,12 +46,15 @@ public static class AuthEndpoints
IOptions<CookieOptions> cookieOptions,
HttpContext context,
CancellationToken ct,
LoginAttemptGuard loginAttemptGuard)
LoginAttemptGuard loginAttemptGuard,
SuspiciousActivityReporter suspicious)
{
string? attemptedLogin = NormalizeLogin(body.Login);
if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct))
{
// Событие подозрительной активности: серия неудачных попыток входа → блокировка ключа.
suspicious.Report(SuspiciousActivityReporter.LoginBlockedKind, ClientIp(context));
return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail);
}
@@ -108,8 +111,8 @@ public static class AuthEndpoints
HttpContext context,
CancellationToken ct)
{
var cookieName = cookieOptions.Value.Name;
var rawToken = context.Request.Cookies[cookieName];
string cookieName = cookieOptions.Value.Name;
string? rawToken = context.Request.Cookies[cookieName];
// Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса).
CurrentUser? user = context.GetCurrentUser();
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);
if (!result.Ok || result.NewToken is null)
{
var detail = result.Error == ChangePasswordResultDto.ErrorTooShort
string detail = result.Error == ChangePasswordResultDto.ErrorTooShort
? PasswordTooShortDetail
: WrongOldPasswordDetail;
return EndpointResults.BadRequest(detail);
@@ -91,8 +91,8 @@ public static class OperatorAuthEndpoints
HttpContext context,
CancellationToken ct)
{
var cookieName = cookieOptions.Value.Name;
var rawToken = context.Request.Cookies[cookieName];
string cookieName = cookieOptions.Value.Name;
string? rawToken = context.Request.Cookies[cookieName];
// Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items).
CurrentOperator? operatorIdentity = context.GetCurrentOperator();
await operatorAuthService.LogoutAsync(rawToken, ct);
@@ -17,8 +17,8 @@ public sealed class OperatorBootstrapHostedService(
await using var scope = scopeFactory.CreateAsyncScope();
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
var login = configuration[OperatorBootstrapService.LoginEnvKey];
var password = configuration[OperatorBootstrapService.PasswordEnvKey];
string? login = configuration[OperatorBootstrapService.LoginEnvKey];
string? password = configuration[OperatorBootstrapService.PasswordEnvKey];
bool hasLogin = !string.IsNullOrWhiteSpace(login);
bool hasPassword = !string.IsNullOrWhiteSpace(password);
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);
if (ensuredLogin is not null)
{
@@ -34,11 +34,11 @@ public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) :
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
string login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
string password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
var seedDefaultTenant = environment.IsDevelopment()
bool seedDefaultTenant = environment.IsDevelopment()
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
if (seedDefaultTenant)
{
@@ -26,8 +26,8 @@ public sealed class OperatorSessionMiddleware
/// </summary>
public async Task InvokeAsync(HttpContext context)
{
var cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
string cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
&& !string.IsNullOrWhiteSpace(rawToken))
{
// OperatorAuthService scoped: создаём scope на запрос через RequestServices.
@@ -126,7 +126,13 @@ public static class RateLimitPolicies
private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken)
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken);
HttpContext http = context.HttpContext;
// Событие подозрительной активности: сработал 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
{
var cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken)
string cookieName = _cookieOptions.CurrentValue.Name;
if (context.Request.Cookies.TryGetValue(cookieName, out string? rawToken)
&& !string.IsNullOrWhiteSpace(rawToken))
{
// AuthService scoped: создаём scope на запрос через RequestServices.
+2 -1
View File
@@ -69,7 +69,7 @@ DealMetricsHosting.AddDealMetrics(builder, metricsPort);
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
string connectionString = builder.Configuration.GetConnectionString("DealPostgres")
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
builder.Services.AddSingleton<ITenantContext, TenantContext>();
@@ -177,6 +177,7 @@ builder.Services.AddSingleton(rateLimitOptions);
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
if (rateLimitOptions.Enabled)
{
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>
/// Маркер слоя Contracts
/// </summary>
public sealed class ContractsMarker
public interface IContracts
{
}
@@ -17,8 +17,8 @@ public sealed class TenantContext : ITenantContext
public string? SchemaName => Current.Value?.SchemaName;
/// <inheritdoc />
public void SetTenant(TenantId tenantId) => Current.Value = tenantId;
void ITenantContext.SetTenant(TenantId tenantId) => Current.Value = tenantId;
/// <inheritdoc />
public void Reset() => Current.Value = null;
void ITenantContext.Reset() => Current.Value = null;
}
@@ -3,6 +3,6 @@ namespace Deal.Infrastructure;
/// <summary>
/// Маркер слоя Infrastructure
/// </summary>
public sealed class InfrastructureMarker
public interface IInfrastructure
{
}
@@ -2,9 +2,7 @@ using Grpc.Core;
namespace Deal.Infrastructure.Integrations.Extensions;
/// <summary>
/// Расширения классификации gRPC-исключений клиентов автономных сервисов.
/// </summary>
// Расширения классификации gRPC-исключений клиентов автономных сервисов.
internal static class RpcExceptionExtensions
{
/// <summary>
@@ -3,9 +3,7 @@ using System.Net.Sockets;
namespace Deal.Infrastructure.Integrations.Extensions;
/// <summary>
/// Расширения <see cref="Uri"/> для SSRF-гейта интеграций
/// </summary>
// Расширения Uri для SSRF-гейта интеграций
internal static class UriExtensions
{
/// <summary>
@@ -79,7 +79,7 @@ public sealed class AiConnectionChecker : IAiConnectionChecker
}
/// <inheritdoc />
public async Task<AiCheckResultDto> CheckAsync(AiCheckRequest request, CancellationToken ct)
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
@@ -55,7 +55,7 @@ public sealed class BudgetedAiClassifier : IAiClassifier
}
/// <inheritdoc />
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
{
@@ -68,7 +68,7 @@ public sealed class BudgetedAiClassifier : IAiClassifier
}
/// <inheritdoc />
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{
if (await IsPaidAllowedAsync(ct))
{
@@ -51,7 +51,7 @@ public sealed class BudgetedAiTools : IAiTools
}
/// <inheritdoc />
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{
BudgetStateDto state = await GateStateAsync(ct);
if (state.Allowed)
@@ -70,7 +70,7 @@ public sealed class BudgetedAiTools : IAiTools
}
/// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text,
string description,
IReadOnlyCollection<string> keywords,
@@ -48,7 +48,7 @@ public sealed class CbrRateSource : IRatesSource
}
/// <inheritdoc />
public async Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct)
async Task<Dictionary<string, double>?> IRatesSource.FetchAsync(CancellationToken ct)
{
try
{
@@ -71,7 +71,7 @@ public sealed class GrpcAiClassifier : IAiClassifier
}
/// <inheritdoc />
public async Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
async Task<AiFilterResultDto> IAiClassifier.FilterAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -107,7 +107,7 @@ public sealed class GrpcAiClassifier : IAiClassifier
}
/// <inheritdoc />
public async Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
async Task<AiParsedCardDto> IAiClassifier.ClassifyAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct);
@@ -71,7 +71,7 @@ public sealed class GrpcAiTools : IAiTools
}
/// <inheritdoc />
public async Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
async Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -105,7 +105,7 @@ public sealed class GrpcAiTools : IAiTools
}
/// <inheritdoc />
public async Task<AiEvaluateFitResultDto> EvaluateFitAsync(
async Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text,
string description,
IReadOnlyCollection<string> keywords,
@@ -98,7 +98,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
/// <inheritdoc />
public async Task<MlStatusResponseDto> StatusAsync(CancellationToken ct)
async Task<MlStatusResponseDto> IMlClient.StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct);
@@ -123,7 +123,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
/// <inheritdoc />
public async Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
async Task<MlPredictResultDto> IMlClient.PredictAsync(string text, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -144,7 +144,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
/// <inheritdoc />
public async Task<MlResetResultDto> ResetAsync(CancellationToken ct)
async Task<MlResetResultDto> IMlClient.ResetAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
ResetReply reply;
@@ -172,7 +172,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
/// <inheritdoc />
public async Task PushAsync(
async Task IMlClient.PushAsync(
string text,
string label,
double delta,
@@ -182,7 +182,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
}
/// <inheritdoc />
public async Task<int> TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
async Task<int> IMlTrainClient.TrainBatchAsync(IReadOnlyList<MlOutboxEntryDto> items, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
var request = new TrainBatchRequest();
@@ -59,7 +59,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
async Task<TelegramAccountStatusDto> ITelegramGateway.StatusAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -82,7 +82,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<TelegramAuthResultDto> StartPhoneAsync(
async Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone,
int apiId,
string apiHash,
@@ -105,7 +105,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<TelegramAuthResultDto> StartQrAsync(
async Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
@@ -129,7 +129,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<string> SendCodeAsync(string code, CancellationToken ct)
async Task<string> ITelegramGateway.SendCodeAsync(string code, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -147,7 +147,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<string> SendPasswordAsync(string password, CancellationToken ct)
async Task<string> ITelegramGateway.SendPasswordAsync(string password, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -165,7 +165,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task LogoutAsync(CancellationToken ct)
async Task ITelegramGateway.LogoutAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -181,7 +181,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -198,7 +198,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task SetMonitorAsync(
async Task ITelegramGateway.SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
@@ -218,7 +218,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct)
async Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -235,7 +235,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<int> BackfillAsync(
async Task<int> ITelegramGateway.BackfillAsync(
string dialogId,
bool force,
CancellationToken ct)
@@ -256,7 +256,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
async Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
@@ -279,7 +279,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<TelegramSourceContentDto> ReadSourceAsync(
async Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
@@ -303,7 +303,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
async Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query,
int limit,
CancellationToken ct)
@@ -324,7 +324,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
async Task<TelegramChannelInfoDto> ITelegramGateway.InfoAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -350,7 +350,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task<TelegramEvalReadDto> ReadForEvalAsync(
async Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
@@ -381,7 +381,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task JoinAsync(string username, CancellationToken ct)
async Task ITelegramGateway.JoinAsync(string username, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -398,7 +398,7 @@ public sealed class GrpcTelegramClient : ITelegramGateway
}
/// <inheritdoc />
public async Task LeaveAsync(string dialogId, CancellationToken ct)
async Task ITelegramGateway.LeaveAsync(string dialogId, CancellationToken ct)
{
TenantId tenantId = RequireTenant();
try
@@ -13,11 +13,11 @@ public sealed class LocalAiTools : IAiTools
"ИИ-инструменты доступны только при подключённом ai-service (Services:Ai:UseLocal=false).";
/// <inheritdoc />
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
Task<AiGenerateKeywordsResultDto> IAiTools.GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException(NotSupportedMessage);
/// <inheritdoc />
public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
Task<AiEvaluateFitResultDto> IAiTools.EvaluateFitAsync(
string text,
string description,
IReadOnlyCollection<string> keywords,
@@ -12,13 +12,13 @@ public sealed class LocalTelegramGateway : ITelegramGateway
private const string IdlePhase = "idle";
/// <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));
}
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync(
Task<TelegramAuthResultDto> ITelegramGateway.StartPhoneAsync(
string phone,
int apiId,
string apiHash,
@@ -26,7 +26,7 @@ public sealed class LocalTelegramGateway : ITelegramGateway
=> Task.FromResult(new TelegramAuthResultDto(IdlePhase, null));
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync(
Task<TelegramAuthResultDto> ITelegramGateway.StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
@@ -39,20 +39,20 @@ public sealed class LocalTelegramGateway : ITelegramGateway
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult(IdlePhase);
/// <inheritdoc />
public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask;
Task ITelegramGateway.LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <inheritdoc />
public Task SetMonitorAsync(
Task ITelegramGateway.SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
Task ITelegramGateway.SetMonitorAllAsync(bool enabled, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<int> BackfillAsync(
@@ -61,40 +61,40 @@ public sealed class LocalTelegramGateway : ITelegramGateway
CancellationToken ct) => Task.FromResult(0);
/// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
Task<IReadOnlyList<TelegramRecentMessageDto>> ITelegramGateway.ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
Task<TelegramSourceContentDto> ITelegramGateway.ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
=> Task.FromResult(new TelegramSourceContentDto(false, null, null));
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
Task<IReadOnlyList<TelegramDialogEntryDto>> ITelegramGateway.SearchAsync(
string query,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramDialogEntryDto>>([]);
/// <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));
/// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync(
Task<TelegramEvalReadDto> ITelegramGateway.ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
Task ITelegramGateway.JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <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;
/// <summary>
/// Расширения <see cref="MinioStorageOptions"/>
/// </summary>
// Расширения MinioStorageOptions
internal static class MinioStorageOptionsExtensions
{
/// <summary>
@@ -1,8 +1,6 @@
namespace Deal.Infrastructure.Integrations.Storage.Extensions;
/// <summary>
/// Расширения <see cref="string"/> для разбора конфигурационных значений.
/// </summary>
// Расширения string для разбора конфигурационных значений.
internal static class StringExtensions
{
/// <summary>
@@ -32,7 +32,7 @@ public sealed class LocalFileStorage : IFileStorage
public override string ToString() => $"LocalFileStorage (root: {_rootPath})";
/// <inheritdoc />
public async Task<string> PutAsync(
async Task<string> IFileStorage.PutAsync(
string objectKey,
Stream content,
string contentType,
@@ -56,7 +56,7 @@ public sealed class LocalFileStorage : IFileStorage
}
/// <inheritdoc />
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
if (!File.Exists(path))
@@ -69,7 +69,7 @@ public sealed class LocalFileStorage : IFileStorage
}
/// <inheritdoc />
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
if (!File.Exists(path))
@@ -82,7 +82,7 @@ public sealed class LocalFileStorage : IFileStorage
}
/// <inheritdoc />
public Task DeleteAsync(string objectKey, CancellationToken ct)
Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{
string path = ResolvePath(objectKey);
if (File.Exists(path))
@@ -67,7 +67,7 @@ public sealed class MinioFileStorage : IFileStorage
public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})";
/// <inheritdoc />
public async Task<string> PutAsync(
async Task<string> IFileStorage.PutAsync(
string objectKey,
Stream content,
string contentType,
@@ -99,7 +99,7 @@ public sealed class MinioFileStorage : IFileStorage
}
/// <inheritdoc />
public async Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
async Task<Stream?> IFileStorage.GetAsync(string objectKey, CancellationToken ct)
{
MemoryStream buffer = new();
try
@@ -129,7 +129,7 @@ public sealed class MinioFileStorage : IFileStorage
}
/// <inheritdoc />
public async Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
async Task<FileMeta?> IFileStorage.StatAsync(string objectKey, CancellationToken ct)
{
try
{
@@ -145,7 +145,7 @@ public sealed class MinioFileStorage : IFileStorage
}
/// <inheritdoc />
public async Task DeleteAsync(string objectKey, CancellationToken ct)
async Task IFileStorage.DeleteAsync(string objectKey, CancellationToken ct)
{
try
{
@@ -10,7 +10,7 @@ public static class TenantSchemaMigrator
/// </summary>
public static string CreateSchemaSql(string schemaName)
{
var escaped = schemaName.Replace("\"", "\"\"");
string escaped = schemaName.Replace("\"", "\"\"");
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
}
@@ -10,7 +10,7 @@ public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDb
{
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";
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseNpgsql(connectionString)
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task UpsertBlacklistAsync(
async Task IDiscoveryStore.UpsertBlacklistAsync(
string dialogId,
string name,
string reason,
@@ -38,13 +39,13 @@ public sealed partial class DiscoveryStore
}
/// <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);
}
/// <inheritdoc />
public async Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
async Task<DiscoveryBlacklistDto?> IDiscoveryStore.GetBlacklistAsync(string dialogId, CancellationToken ct)
{
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
.AsNoTracking()
@@ -53,7 +54,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
async Task<IReadOnlyList<DiscoveryBlacklistDto>> IDiscoveryStore.ListBlacklistAsync(CancellationToken ct)
{
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
.AsNoTracking()
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(
async Task<IReadOnlyList<DiscoveryCandidateDto>> IDiscoveryStore.ListCandidatesAsync(
string taskId,
string? status,
CancellationToken ct)
@@ -26,7 +27,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
async Task<DiscoveryCandidateDto?> IDiscoveryStore.GetCandidateAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.AsNoTracking()
@@ -35,19 +36,19 @@ public sealed partial class DiscoveryStore
}
/// <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);
}
/// <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);
}
/// <inheritdoc />
public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
async Task IDiscoveryStore.CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
@@ -66,13 +67,13 @@ public sealed partial class DiscoveryStore
}
/// <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);
}
/// <inheritdoc />
public async Task<bool> PatchCandidateAsync(
async Task<bool> IDiscoveryStore.PatchCandidateAsync(
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct)
@@ -91,7 +92,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> SetCandidateStatusAsync(
async Task<bool> IDiscoveryStore.SetCandidateStatusAsync(
string dialogId,
string status,
CancellationToken ct)
@@ -110,7 +111,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> SetCandidateJoinedAsync(
async Task<bool> IDiscoveryStore.SetCandidateJoinedAsync(
string dialogId,
bool autoJoined,
CancellationToken ct)
@@ -130,7 +131,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
async Task<int?> IDiscoveryStore.IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
@@ -146,7 +147,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
async Task<bool> IDiscoveryStore.SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{
DiscCandidateEntity? row = await _dbContext.DiscCandidates
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task AddLogAsync(
async Task IDiscoveryStore.AddLogAsync(
string logId,
string taskId,
string logEvent,
@@ -29,7 +30,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<int> CountLogEventAsync(
async Task<int> IDiscoveryStore.CountLogEventAsync(
string logEvent,
DateTimeOffset sinceUtc,
CancellationToken ct)
@@ -38,7 +39,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(
async Task<IReadOnlyList<DiscoveryLogDto>> IDiscoveryStore.ListTaskLogAsync(
string taskId,
int limit,
CancellationToken ct)
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Discovery.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class DiscoveryStore
{
/// <inheritdoc />
public async Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
async Task<IReadOnlyList<DiscoveryTaskDto>> IDiscoveryStore.ListTasksAsync(CancellationToken ct)
{
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
.AsNoTracking()
@@ -20,7 +21,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
async Task<DiscoveryTaskDto?> IDiscoveryStore.GetTaskAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.AsNoTracking()
@@ -29,7 +30,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
async Task IDiscoveryStore.CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_dbContext.DiscTasks.Add(new DiscTaskEntity
@@ -52,7 +53,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> PatchTaskAsync(
async Task<bool> IDiscoveryStore.PatchTaskAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
@@ -71,7 +72,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
async Task<bool> IDiscoveryStore.DeleteTaskAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -88,7 +89,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> SetTaskRunningAsync(
async Task<bool> IDiscoveryStore.SetTaskRunningAsync(
string taskId,
bool resetProgress,
CancellationToken ct)
@@ -117,7 +118,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
async Task<bool> IDiscoveryStore.SetTaskPausedAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -133,7 +134,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
async Task<bool> IDiscoveryStore.SetTaskDoneAsync(string taskId, CancellationToken ct)
{
DiscTaskEntity? row = await _dbContext.DiscTasks
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
@@ -149,7 +150,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> BumpTaskCounterAsync(
async Task<bool> IDiscoveryStore.BumpTaskCounterAsync(
string taskId,
DiscoveryCounterField field,
int n,
@@ -186,7 +187,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<bool> AdvanceSearchAsync(
async Task<bool> IDiscoveryStore.AdvanceSearchAsync(
string taskId,
int nextIndex,
bool searchDone,
@@ -207,7 +208,7 @@ public sealed partial class DiscoveryStore
}
/// <inheritdoc />
public async Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
.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.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore
{
/// <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();
if (query.Col is null)
@@ -30,7 +31,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(
async Task<IReadOnlyList<CardDto>> ICardStore.SearchCardsAsync(
string q,
int limit,
CancellationToken ct)
@@ -61,7 +62,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
async Task<CardDto?> ICardStore.GetCardAsync(string cardId, CancellationToken ct)
{
CardEntity? entity = await _dbContext.Cards
.AsNoTracking()
@@ -76,7 +77,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<CardDto?> GetCardBySourceAsync(
async Task<CardDto?> ICardStore.GetCardBySourceAsync(
SourceRef source,
CancellationToken ct)
{
@@ -104,7 +105,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
async Task ICardStore.AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
{
// CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot).
_dbContext.Cards.Add(ToCardEntity(snapshot));
@@ -131,7 +132,7 @@ public sealed partial class KanbanStore
}
/// <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);
if (entity is null)
@@ -152,7 +153,7 @@ public sealed partial class KanbanStore
}
/// <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);
if (entity is null)
@@ -182,7 +183,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task UpdateSeenAsync(
async Task ICardStore.UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
@@ -201,7 +202,7 @@ public sealed partial class KanbanStore
}
/// <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 _dbContext.DedupEntries
@@ -214,7 +215,7 @@ public sealed partial class KanbanStore
}
/// <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 _dbContext.DedupEntries
@@ -228,7 +229,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
async Task<IReadOnlyDictionary<string, CardColumnCountDto>> ICardStore.CountCardsByColAsync(CancellationToken ct)
{
var rows = await _dbContext.Cards
.AsNoTracking()
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore
{
/// <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
.AsNoTracking()
@@ -23,7 +24,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task AddCommentAsync(
async Task ICardStore.AddCommentAsync(
string commentId,
string cardId,
string by,
@@ -42,7 +43,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
async Task ICardStore.AddMoveAsync(CardMoveDto move, CancellationToken ct)
{
_dbContext.CardMoves.Add(new CardMoveEntity
{
@@ -60,7 +61,7 @@ public sealed partial class KanbanStore
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
/// <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
.AsNoTracking()
@@ -2,6 +2,7 @@ using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore
{
/// <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();
if (space is not null)
@@ -28,7 +29,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct)
async Task<ContainerDto?> ICardStore.GetContainerAsync(string containerId, CancellationToken ct)
{
ContainerEntity? entity = await _dbContext.Containers
.AsNoTracking()
@@ -37,14 +38,14 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task CreateContainerAsync(ContainerDto container, CancellationToken ct)
async Task ICardStore.CreateContainerAsync(ContainerDto container, CancellationToken ct)
{
_dbContext.Containers.Add(ToContainerEntity(container));
await _dbContext.SaveChangesAsync(ct);
}
/// <inheritdoc />
public async Task UpdateContainerAsync(ContainerDto container, CancellationToken ct)
async Task ICardStore.UpdateContainerAsync(ContainerDto container, CancellationToken ct)
{
// Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся
// целиком, CreatedAt не трогаем — одним UPDATE.
@@ -64,7 +65,7 @@ public sealed partial class KanbanStore
}
/// <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 />
public async Task ReorderContainersAsync(
async Task ICardStore.ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
@@ -1,6 +1,7 @@
using Deal.Infrastructure.Persistence.Entities;
using Deal.Modules.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -10,7 +11,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore
{
/// <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();
if (containerId is not null)
@@ -29,7 +30,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<bool> PatchCardAsync(
async Task<bool> ICardStore.PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct)
@@ -68,7 +69,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<bool> AddLinkAsync(
async Task<bool> ICardStore.AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct)
@@ -85,7 +86,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<bool> RemoveLinkAsync(
async Task<bool> ICardStore.RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
@@ -106,7 +107,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<bool> AddFileAsync(
async Task<bool> ICardStore.AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct)
@@ -123,7 +124,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<bool> RemoveFileAsync(
async Task<bool> ICardStore.RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
@@ -144,7 +145,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<bool> MoveCardStageAsync(
async Task<bool> ICardStore.MoveCardStageAsync(
string cardId,
string containerId,
CardHistoryDto historyEntry,
@@ -176,7 +177,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task SetReminderAsync(
async Task ICardStore.SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
@@ -191,7 +192,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task ClearReminderAsync(string cardId, CancellationToken ct)
async Task ICardStore.ClearReminderAsync(string cardId, CancellationToken ct)
{
await _dbContext.Cards
.Where(card => card.Id == cardId)
@@ -202,7 +203,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<int> ClearStageAsync(string containerId, CancellationToken ct)
async Task<int> ICardStore.ClearStageAsync(string containerId, CancellationToken ct)
{
return await _dbContext.Cards
.Where(card => card.Col == containerId)
@@ -210,7 +211,7 @@ public sealed partial class KanbanStore
}
/// <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
.AsNoTracking()
@@ -224,7 +225,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
async Task ICardStore.MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{
if (cardIds.Count == 0)
{
@@ -237,7 +238,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
async Task<int> ICardStore.ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
return await _dbContext.Cards
.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.Kanban.Application.Models;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Kanban.Application.Abstractions;
namespace Deal.Infrastructure.Persistence.Repositories;
@@ -11,7 +12,7 @@ namespace Deal.Infrastructure.Persistence.Repositories;
public sealed partial class KanbanStore
{
/// <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
.AsNoTracking()
@@ -28,7 +29,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<int> ArchiveAsync(
async Task<int> ICardStore.ArchiveAsync(
IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt,
CancellationToken ct)
@@ -49,7 +50,7 @@ public sealed partial class KanbanStore
}
/// <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
.AsNoTracking()
@@ -61,7 +62,7 @@ public sealed partial class KanbanStore
}
/// <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
.AsNoTracking()
@@ -71,7 +72,7 @@ public sealed partial class KanbanStore
}
/// <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)
{
@@ -90,7 +91,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
async Task<IReadOnlyList<CardDto>> ICardStore.ListCardsForConversionAsync(CancellationToken ct)
{
List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking()
@@ -102,7 +103,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task UpdateConversionAsync(
async Task ICardStore.UpdateConversionAsync(
string cardId,
double? convFrom,
double? convTo,
@@ -119,7 +120,7 @@ public sealed partial class KanbanStore
}
/// <inheritdoc />
public async Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
async Task<IReadOnlyList<CardDto>> ICardStore.ListInboxWithSourceAsync(CancellationToken ct)
{
List<CardEntity> entities = await _dbContext.Cards
.AsNoTracking()
@@ -59,17 +59,17 @@ public sealed class TenantLimitStore : ITenantLimitStore
}
/// <inheritdoc />
public async Task<TenantLimitDto> GetOrCreateAsync(
async Task<TenantLimitDto> ITenantLimitStore.GetOrCreateAsync(
Guid tenantId,
CancellationToken ct,
TokenLimitDefaults? defaults = null)
TokenLimitDefaults? defaults)
{
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct);
return ToLimitDto(entity);
}
/// <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);
await ResetIfPeriodExpiredAsync(entity, ct);
@@ -77,7 +77,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
}
/// <inheritdoc />
public async Task<BudgetStateDto> AddUsageAsync(
async Task<BudgetStateDto> ITenantLimitStore.AddUsageAsync(
Guid tenantId,
long tokens,
CancellationToken ct)
@@ -110,7 +110,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
}
/// <inheritdoc />
public async Task<BudgetStateDto> UpdateBudgetAsync(
async Task<BudgetStateDto> ITenantLimitStore.UpdateBudgetAsync(
Guid tenantId,
long budgetTokens,
string period,
@@ -133,7 +133,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
}
/// <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);
await ResetIfPeriodExpiredAsync(entity, ct);
@@ -149,7 +149,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
}
/// <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);
await ResetIfPeriodExpiredAsync(entity, ct);
@@ -220,7 +220,7 @@ public sealed class TenantLimitStore : ITenantLimitStore
}
/// <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
@@ -10,7 +10,7 @@ public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<Tena
{
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";
var options = new DbContextOptionsBuilder<TenantDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
@@ -40,7 +40,7 @@ public sealed class AesGcmSecretCipher : ISecretCipher
}
/// <inheritdoc />
public string Encrypt(string plainText)
string ISecretCipher.Encrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText))
{
@@ -66,7 +66,7 @@ public sealed class AesGcmSecretCipher : ISecretCipher
}
/// <inheritdoc />
public string Decrypt(string cipherText)
string ISecretCipher.Decrypt(string cipherText)
{
if (string.IsNullOrEmpty(cipherText) || !cipherText.StartsWith(EncryptedPrefix, StringComparison.Ordinal))
{
@@ -24,7 +24,7 @@ public sealed class TenantProvisioningService(ConnectionStringProvider connectio
/// <inheritdoc />
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));
await provisionLock.WaitAsync(ct);
try
@@ -63,7 +63,7 @@ public sealed class TenantProvisioningService(ConnectionStringProvider connectio
{
// Строка уже с Search Path=tenant_&lt;id&gt; (ForSchemaDdl) — таблицы бессхемной модели TenantDbContext
// лягут в схему тенанта. DDL применяется мигратор-ролью при её наличии (см. ConnectionStringProvider).
var connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
string connectionString = connectionStringProvider.ForSchemaDdl(tenantId);
var options = new DbContextOptionsBuilder<TenantDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable(TenantMigrationsHistoryTable, schemaName))
.Options;
@@ -8,8 +8,7 @@ using Microsoft.Extensions.Logging;
namespace Deal.Infrastructure.Tenancy;
/// <summary>
/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра: шардированный обход
/// страницами с ограниченным параллелизмом и логированием прогресса — для провижининга на сотни/тысячи схем.
/// Пакетная миграция схем всех существующих тенантов реестра для провижининга на сотни/тысячи схем.
/// </summary>
public sealed class TenantSchemaMigrationService(
ITenantRepository tenantRepository,
@@ -12,8 +12,14 @@ public interface IContainerRules
/// </summary>
public string Mode { get; }
/// <summary>
/// Ключевые слова
/// </summary>
public IReadOnlyList<string> Keywords { get; }
/// <summary>
/// Стек технологий
/// </summary>
public IReadOnlyList<string> Stack { get; }
/// <summary>
@@ -14,7 +14,6 @@ public interface ISourceContentProvider
/// Загружает содержимое записи источника.
/// </summary>
/// <param name="source">Ссылка на источник.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Содержимое записи либо null, если источник не отдал данные.</returns>
public Task<SourceContent?> LoadAsync(SourceRef source, CancellationToken ct);
}
@@ -9,7 +9,6 @@ public interface ISourceIngestObserver
/// Обрабатывает принятую запись источника.
/// </summary>
/// <param name="item">Принятая запись.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Завершается после обработки; сбой наблюдателя не влияет на приём.</returns>
public Task OnIngestedAsync(SourceItem item, CancellationToken ct);
}
@@ -31,7 +31,6 @@ public sealed class SourceContentResolver
/// Загружает содержимое записи источника.
/// </summary>
/// <param name="source">Ссылка на источник.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Содержимое записи либо null — провайдера нет/источник не отдал данные.</returns>
public Task<SourceContent?> ResolveAsync(SourceRef source, CancellationToken ct)
{
@@ -3,6 +3,6 @@ namespace Deal.Modules.Cards;
/// <summary>
/// Маркер модуля Cards
/// </summary>
public sealed class CardsModuleMarker
public interface ICardsModule
{
}
@@ -44,7 +44,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
}
/// <inheritdoc />
public int Next(string taskId)
int IDiscoverySearchErrorCounter.Next(string taskId)
{
EvictExpired();
Entry fresh = _failures.AddOrUpdate(
@@ -56,7 +56,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter
}
/// <inheritdoc />
public void Reset(string taskId)
void IDiscoverySearchErrorCounter.Reset(string taskId)
{
EvictExpired();
_failures.TryRemove(taskId, out _);
@@ -3,6 +3,6 @@ namespace Deal.Modules.Discovery;
/// <summary>
/// Маркер модуля Discovery
/// </summary>
public sealed class DiscoveryModuleMarker
public interface IDiscoveryModule
{
}
@@ -2,9 +2,7 @@ using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Расширения бюджетной группы правил колонки.
/// </summary>
// Расширения бюджетной группы правил колонки.
internal static class BudgetRangeDtoExtensions
{
/// <summary>
@@ -1,8 +1,6 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Расширения списков термов правил колонки.
/// </summary>
// Расширения списков термов правил колонки.
internal static class TermListExtensions
{
/// <summary>
@@ -1,8 +1,6 @@
namespace Deal.Modules.Kanban.Application.Extensions;
/// <summary>
/// Расширения символов для нормализации бюджетной валюты.
/// </summary>
// Расширения символов для нормализации бюджетной валюты.
internal static class CharExtensions
{
/// <summary>
@@ -12,7 +12,6 @@ public sealed partial class CardsService
/// Загружает содержимое источника карточки.
/// </summary>
/// <param name="card">Карточка, для которой нужно содержимое.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Содержимое от провайдера источника либо сохранённое в карточке.</returns>
public async Task<SourceContent> ResolveSourceAsync(CardDto card, CancellationToken ct)
{
@@ -3,6 +3,6 @@ namespace Deal.Modules.Kanban;
/// <summary>
/// Маркер модуля Kanban
/// </summary>
public sealed class KanbanModuleMarker
public interface IKanbanModule
{
}
@@ -1,8 +1,6 @@
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Расширения кодовых точек для чистки текста сообщений.
/// </summary>
// Расширения кодовых точек для чистки текста сообщений.
internal static class CodePointExtensions
{
/// <summary>
@@ -1,8 +1,6 @@
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Расширения строк для разбора текста сообщений.
/// </summary>
// Расширения строк для разбора текста сообщений.
internal static class StringExtensions
{
private static readonly string[] FooterHintsArray =
@@ -51,7 +51,6 @@ public sealed class CardReclassifier(
/// Пакетная переклассификация «Неразобранного»
/// </summary>
/// <param name="ids">Опциональный список id (null/пусто — все карточки inbox).</param>
/// <param name="ct">Токен отмены.</param>
/// <param name="progress">Наблюдатель прогресса (SSE); null — без отчётов.</param>
/// <returns>Итог прохода (счётчики исхода) либо <c>busy</c>, если проход уже идёт.</returns>
public async Task<ReclassifyResultDto> ReclassifyInboxAsync(
@@ -31,7 +31,6 @@ public sealed partial class PipelineWorkerService
/// Прогоняет текст по этапам конвейера без записи в систему.
/// </summary>
/// <param name="text">Текст для проверки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Результаты этапов, разбор и целевой контейнер (если карточка была бы создана).</returns>
public async Task<PipelineDryRunDto> DryRunAsync(string text, CancellationToken ct)
{
@@ -3,6 +3,6 @@ namespace Deal.Modules.Pipeline;
/// <summary>
/// Маркер модуля Pipeline
/// </summary>
public sealed class PipelineModuleMarker
public interface IPipelineModule
{
}
@@ -141,7 +141,7 @@ public static class SettingsKeys
public const string ExcludeTypes = "excludeTypes";
// ── Dict / special ──
// ── Словари / особые ──
public const string ColState = "colState";
@@ -3,6 +3,6 @@ namespace Deal.Modules.Settings;
/// <summary>
/// Маркер модуля Settings
/// </summary>
public sealed class SettingsModuleMarker
public interface ISettingsModule
{
}
@@ -3,6 +3,6 @@ namespace Deal.Modules.Telegram;
/// <summary>
/// Маркер модуля Telegram
/// </summary>
public sealed class TelegramModuleMarker
public interface ITelegramModule
{
}
@@ -1,8 +1,6 @@
namespace Deal.Modules.Tenants.Application.Models;
/// <summary>
/// Расширения записей аудита
/// </summary>
// Расширения записей аудита
internal static class AuditRecordDtoExtensions
{
// События аудита «неудачный вход» (тенант/оператор).
@@ -36,7 +36,7 @@ public sealed class AuthService(
string password,
CancellationToken ct)
{
var normalizedLogin = NormalizeLogin(login);
string normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{
return new LoginResultDto(null, null);
@@ -61,7 +61,7 @@ public sealed class AuthService(
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);
}
@@ -77,7 +77,7 @@ public sealed class AuthService(
return null;
}
var tokenHash = SessionTokens.HashToken(rawToken);
string tokenHash = SessionTokens.HashToken(rawToken);
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
await authStore.DeleteSessionAsync(tokenHash, ct);
@@ -111,7 +111,7 @@ public sealed class AuthService(
string newPassword,
CancellationToken ct)
{
var normalizedLogin = NormalizeLogin(login);
string normalizedLogin = NormalizeLogin(login);
var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct);
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.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);
}
@@ -148,7 +148,7 @@ public sealed class AuthService(
return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound);
}
var normalizedLogin = NormalizeLogin(targetLogin);
string normalizedLogin = NormalizeLogin(targetLogin);
if (string.IsNullOrEmpty(normalizedLogin))
{
// login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync).
@@ -159,7 +159,7 @@ public sealed class AuthService(
}
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);
}
@@ -170,7 +170,7 @@ public sealed class AuthService(
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);
}
@@ -186,7 +186,7 @@ public sealed class AuthService(
return null;
}
var tokenHash = SessionTokens.HashToken(rawToken);
string tokenHash = SessionTokens.HashToken(rawToken);
var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct);
UserIdentityDto? user = null;
if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow)
@@ -230,7 +230,7 @@ public sealed class AuthService(
Guid? impersonatedByOperatorId,
CancellationToken ct)
{
var rawToken = SessionTokens.NewToken();
string rawToken = SessionTokens.NewToken();
var session = new SessionDto(
TokenHash: SessionTokens.HashToken(rawToken),
UserId: userId,
@@ -27,7 +27,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
string password,
CancellationToken ct)
{
var normalizedLogin = NormalizeLogin(login);
string normalizedLogin = NormalizeLogin(login);
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password))
{
return new OperatorLoginResultDto(null, null);
@@ -39,7 +39,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
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);
}
@@ -69,7 +69,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
return null;
}
var tokenHash = SessionTokens.HashToken(rawToken);
string tokenHash = SessionTokens.HashToken(rawToken);
var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct);
OperatorIdentityDto? operatorIdentity = null;
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)
{
// Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос
// (hot-path, Security review).
// (горячий путь запросов).
await operatorAuthStore.DeleteExpiredSessionsAsync(ct);
}
@@ -96,7 +96,7 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP
// Возвращает: Raw-токен для выдачи клиенту.
private async Task<string> CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
{
var rawToken = SessionTokens.NewToken();
string rawToken = SessionTokens.NewToken();
var session = new OperatorSessionDto(
TokenHash: SessionTokens.HashToken(rawToken),
OperatorId: operatorRecord.Id,
@@ -43,8 +43,8 @@ public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStor
bool allowDevelopmentDefaults,
CancellationToken ct)
{
var normalizedLogin = NormalizeLogin(login);
var resolvedPassword = password;
string normalizedLogin = NormalizeLogin(login);
string? resolvedPassword = password;
if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword))
{
if (!allowDevelopmentDefaults)
@@ -26,7 +26,7 @@ public static class SessionTokens
/// <returns>64 hex-символа.</returns>
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();
}
}
@@ -39,6 +39,11 @@ public sealed class SuspiciousActivityService
/// </summary>
public const int AuthFailuresPerTenantThreshold = 20;
/// <summary>
/// Порог числа разных логинов в неудачных входах с одного IP за окно.
/// </summary>
public const int DistinctLoginsPerIpThreshold = 5;
// Кратность порога, с которой уровень поднимается до high (2× порог).
private const int HighSeverityMultiplier = 2;
@@ -62,6 +67,11 @@ public sealed class SuspiciousActivityService
/// </summary>
public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant";
/// <summary>
/// Правило: перебор разных логинов с одного IP.
/// </summary>
public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip";
/// <summary>
/// Уровень находки
/// </summary>
@@ -126,6 +136,7 @@ public sealed class SuspiciousActivityService
AddFailedLoginsPerLogin(records, findings);
AddManyIpsPerActor(records, findings);
AddAuthFailuresPerTenant(records, findings);
AddDistinctLoginsPerIp(records, findings);
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: Записи окна.
// predicate: Отбор записей правила.
@@ -36,7 +36,7 @@ public sealed class TenantService(ITenantRepository tenantRepository, ITenantPro
CreatedAt: DateTimeOffset.UtcNow),
ct);
var tenantIdValue = id.ToString("N");
string tenantIdValue = id.ToString("N");
await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct);
return new TenantId(tenantIdValue);
}
@@ -3,6 +3,6 @@ namespace Deal.Modules.Tenants;
/// <summary>
/// Маркер модуля Tenants
/// </summary>
public sealed class TenantsModuleMarker
public interface ITenantsModule
{
}
@@ -5,6 +5,6 @@ namespace Deal.SharedKernel;
/// <summary>
/// Маркер слоя SharedKernel
/// </summary>
public sealed class SharedKernelMarker
public interface ISharedKernel
{
}
@@ -44,6 +44,11 @@ public static class DealMetrics
/// </summary>
public const string TenantTagName = "tenant";
/// <summary>
/// Имя метки вида события безопасности
/// </summary>
public const string SecurityKindTagName = "kind";
/// <summary>
/// Имя метрики доли израсходованного ИИ-бюджета
/// </summary>
@@ -82,6 +87,12 @@ public static class DealMetrics
public static readonly Counter<long> AuditEvents =
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>
@@ -7,8 +7,14 @@ namespace Deal.SharedKernel.Tenants.Abstractions;
/// </summary>
public interface ITenantContext
{
/// <summary>
/// Идентификатор текущего тенанта или null для системного контекста
/// </summary>
public TenantId? TenantId { get; }
/// <summary>
/// Признак наличия текущего тенанта
/// </summary>
public bool HasTenant { get; }
/// <summary>
@@ -18,7 +18,7 @@ public static class UrlSafeToken
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
var bytes = new byte[byteCount];
byte[] bytes = new byte[byteCount];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
@@ -128,7 +129,7 @@ public sealed class OperatorBootstrapHostedServiceTests
.AddInMemoryCollection(values)
.Build();
var store = new FakeOperatorAuthStore();
var passwordHasher = new FakePasswordHasher();
var passwordHasher = TestHashers.New();
var logger = new ListLogger();
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
// провайдер собирается с реальной регистрацией модуля поверх фейков.
@@ -150,7 +151,7 @@ public sealed class OperatorBootstrapHostedServiceTests
private sealed record Context(
OperatorBootstrapHostedService Hosted,
FakeOperatorAuthStore Store,
FakePasswordHasher PasswordHasher,
IPasswordHasher PasswordHasher,
ListLogger Logs);
// Окружение хоста с фиксированным именем (тестовый 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.Tenants;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
@@ -147,7 +149,7 @@ public sealed class BudgetedAiClassifierTests
// Paid: Платный фейк-исполнитель (счётчики/ответы сценария).
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
private sealed record Context(
BudgetedAiClassifier Decorator,
IAiClassifier Decorator,
FakeAiClassifier Paid,
FakeSettingsStore Settings);
@@ -160,10 +162,10 @@ public sealed class BudgetedAiClassifierTests
var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore();
configure?.Invoke(limits);
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiClassifier();
var decorator = new BudgetedAiClassifier(
IAiClassifier decorator = new BudgetedAiClassifier(
paid,
new LocalAiClassifier(new LocalFieldsParser(settings)),
limits,
@@ -6,6 +6,8 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
@@ -128,7 +130,7 @@ public sealed class BudgetedAiToolsTests
// Decorator: Декоратор бюджетного гейта.
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
private sealed record Context(
BudgetedAiTools Decorator,
IAiTools Decorator,
FakeAiTools Paid);
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
@@ -139,10 +141,10 @@ public sealed class BudgetedAiToolsTests
{
var limits = new FakeTenantLimitStore();
configure?.Invoke(limits);
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiTools();
var decorator = new BudgetedAiTools(
IAiTools decorator = new BudgetedAiTools(
paid,
limits,
tenantContext,

Some files were not shown because too many files have changed in this diff Show More