Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b745c324e3 | ||
|
|
93e9100ae4 | ||
|
|
206d61068a | ||
|
|
355723f4ad | ||
|
|
fd64c11441 | ||
|
|
a7e38846d7 | ||
|
|
713d554dc2 | ||
|
|
39c9bdc1b7 | ||
|
|
dde86fd63c | ||
|
|
f4737b9797 |
+4
-2
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Нормализация концов строк: в репозитории и рабочей копии — LF.
|
||||
# Решение 2026-09-11 (backlog TD-STYLE-ANALYZERS): Python/Node-инструменты проекта пишут LF,
|
||||
# shell-скрипты на Linux CI не работают с CRLF, фактическое большинство файлов — LF.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Явно бинарные (на всякий случай, auto-детект и так их не трогает)
|
||||
*.docx binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.eot binary
|
||||
*.pyc binary
|
||||
@@ -30,3 +30,7 @@ deploy/certs/
|
||||
# === Рантайм-данные (БД, объектное хранилище, ключи шифрования) ===
|
||||
archive/leadradar-legacy/data/
|
||||
src/core/Deal.Api/data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Ledger: codestyle-residue (2026-09-11, вечер)
|
||||
|
||||
План: `docs/superpowers/plans/2026-09-11-codestyle-остатки.md`
|
||||
|
||||
## Итог
|
||||
|
||||
- Замер: дубли `<summary>` (текст и имя члена) — 0; var-литералы/касты в src — 0; латинских комментариев — 18
|
||||
(из них англоязычных 3); членов интерфейсов без дока — 4; TODO — 0; CRLF-файлов — 1029 из 1445
|
||||
(все 42 .sh — CRLF).
|
||||
- `.editorconfig`: `csharp_style_var_for_built_in_types = false:warning`; `end_of_line = lf`.
|
||||
- `dotnet format style --diagnostics IDE0008 --severity warn` по 5 sln — 51 файл (только встроенные типы).
|
||||
- `.gitattributes` добавлен (`* text=auto eol=lf` + бинарные исключения); 1029 файлов конвертированы в LF;
|
||||
`git add --renormalize .`.
|
||||
- `fix_private_docs.py --apply` — 12 блоков; `<summary>` добавлены: `IContainerRules.Keywords/Stack`,
|
||||
`ITenantContext.TenantId/HasTenant`; переведены 3 комментария (SettingsKeys, OperatorAuthService,
|
||||
ConversionRecomputerTests); `.pyc` из индекса убраны; STATUS.md — устаревший блок удалён.
|
||||
- Явные реализации интерфейсов — владельцу на точечное ревью (не автоматизировано сознательно).
|
||||
|
||||
## Проверка
|
||||
|
||||
- `dotnet build` 5 sln (core, telegram, ai, ml, storage): 0 warnings / 0 errors.
|
||||
- `sh scripts/test.sh`: все 5 тест-проектов зелёные (счётчики — STATUS.md), `lint:i18n` — зелёный.
|
||||
- Фронт содержательно не менялся.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Ledger: explicit-interfaces (2026-09-11, поздний вечер)
|
||||
|
||||
Решение владельца: вариант A — явные реализации по умолчанию, классы напрямую не вызываем
|
||||
(исключения: DTO, хелперы, экстеншены), тесты через интерфейсы, моки — NSubstitute,
|
||||
маркерные классы не используем (маркерные интерфейсы).
|
||||
|
||||
## Итог
|
||||
|
||||
- Codemod `scripts/make_explicit.py` (идемпотентный): таблицы членов интерфейсов (многострочные
|
||||
сигнатуры), маппинг класс→интерфейсы включая partial-файлы, конвертация `public M(` → `IFoo.M(`.
|
||||
Применено: 161 член в 30 прод-файлах.
|
||||
- Исправления компиляторного цикла: недостающие using'и в partial-файлах (скрипт-фиксер), снят
|
||||
дефолт параметра в явной реализации `ITenantLimitStore.GetOrCreateAsync`, самовызовы
|
||||
`WTelegramSessionClient` квалифицированы `((ISessionClient)this)`, мусорный using в `LlmHttpClient`.
|
||||
- Тесты: 17 файлов перетипизированы с конкретных классов на интерфейсы (поля, tuple-деконструкции,
|
||||
var/target-typed new); DI-регистрации фейков → регистрация интерфейсных инстансов.
|
||||
- Маркеры: 10 классов → интерфейсы `IKanbanModule`, `ICardsModule`, `IPipelineModule`,
|
||||
`IDiscoveryModule`, `ISettingsModule`, `ITelegramModule`, `ITenantsModule`, `IContracts`,
|
||||
`IInfrastructure`, `ISharedKernel`; тесты на `IsInterface`.
|
||||
- NSubstitute 6.1.0 добавлен в 5 тест-проектов; `FakePasswordHasher` удалён, вместо него
|
||||
`Support/TestHashers.New()` (Substitute.For + детерминированная семантика «fake-hash:»).
|
||||
- Правила владельца зафиксированы в §11 код-стайла; план миграции оставшихся ~30 фейков —
|
||||
`backlog.md` (TD-TESTS-NSUBSTITUTE).
|
||||
|
||||
## Проверка
|
||||
|
||||
- `dotnet build` 5 sln: 0 warnings / 0 errors.
|
||||
- Тесты: core 1340/1340, telegram 130/130, ai 52/52, ml 38/38, storage 9/9.
|
||||
- Коммиты: ed25c71 (явные реализации), 93e9100 (маркеры), далее — NSubstitute/доки.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9
-8
@@ -14,10 +14,11 @@
|
||||
| BL-TG-MULTI | Мультиаккаунтность Telegram (сейчас 1 аккаунт на тенант) | ТЗ §12 | P2 | DEFERRED |
|
||||
| BL-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-проверки (нужны внешние условия)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Заменить дублирующий <summary> реализации на <inheritdoc/> (код-стайл Дейла).
|
||||
|
||||
Консервативно:
|
||||
* собираются тексты <summary> членов интерфейсов (блоки, состоящие только из <summary>);
|
||||
* в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// <inheritdoc />`;
|
||||
* блоки с <param>/<returns>/<remarks>/... и summary самих типов не трогаются.
|
||||
|
||||
Запуск: python scripts/dedup_summary_inheritdoc.py [--apply]
|
||||
Без --apply — dry-run со счётчиками.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
ROOTS = [
|
||||
REPO / "src" / "core",
|
||||
REPO / "src" / "telegram-service",
|
||||
REPO / "src" / "ai-service",
|
||||
REPO / "src" / "ml-service",
|
||||
REPO / "src" / "storage-service",
|
||||
REPO / "src" / "grpc-hosting",
|
||||
]
|
||||
|
||||
INTERFACE_RE = re.compile(
|
||||
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+"
|
||||
)
|
||||
TYPE_BASE_RE = re.compile(
|
||||
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*"
|
||||
r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)"
|
||||
)
|
||||
IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*")
|
||||
EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE)
|
||||
SUMMARY_OPEN_RE = re.compile(r"<summary>", re.IGNORECASE)
|
||||
SUMMARY_CLOSE_RE = re.compile(r"</summary>", re.IGNORECASE)
|
||||
|
||||
|
||||
def iter_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for root in ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*.cs"):
|
||||
parts = set(path.parts)
|
||||
if "obj" in parts or "bin" in parts:
|
||||
continue
|
||||
if "tests" in path.as_posix().lower():
|
||||
continue
|
||||
files.append(path)
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def normalize(block: list[str]) -> str:
|
||||
text = " ".join(line.strip().lstrip("/").strip() for line in block)
|
||||
text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text))
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def is_summary_only(block: list[str]) -> bool:
|
||||
joined = "\n".join(block)
|
||||
return (
|
||||
bool(SUMMARY_OPEN_RE.search(joined))
|
||||
and bool(SUMMARY_CLOSE_RE.search(joined))
|
||||
and not EXTRA_TAG_RE.search(joined)
|
||||
)
|
||||
|
||||
|
||||
def depth_snapshot(lines: list[str]) -> list[int]:
|
||||
"""Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев)."""
|
||||
depths: list[int] = []
|
||||
depth = 0
|
||||
for line in lines:
|
||||
depths.append(depth)
|
||||
depth += line.count("{") - line.count("}")
|
||||
return depths
|
||||
|
||||
|
||||
def type_scopes(lines: list[str]):
|
||||
"""Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой.
|
||||
|
||||
kind: "interface" | "type". Границы — по фигурным скобкам объявления.
|
||||
"""
|
||||
depths = depth_snapshot(lines)
|
||||
scopes: list[tuple[int, int, str]] = []
|
||||
stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line)
|
||||
for idx, line in enumerate(lines):
|
||||
depth_before = depths[idx]
|
||||
if INTERFACE_RE.match(line):
|
||||
stack.append((depth_before, "interface", idx))
|
||||
else:
|
||||
m = TYPE_BASE_RE.match(line)
|
||||
if m and IFACE_NAME_RE.search(m.group(1)):
|
||||
stack.append((depth_before, "type", idx))
|
||||
# закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления)
|
||||
while (
|
||||
stack
|
||||
and depths[idx] <= stack[-1][0]
|
||||
and lines[idx].strip().startswith("}")
|
||||
and idx > stack[-1][2]
|
||||
):
|
||||
depth, kind, start = stack.pop()
|
||||
scopes.append((start, idx, kind))
|
||||
for depth, kind, start in stack:
|
||||
scopes.append((start, len(lines) - 1, kind))
|
||||
return scopes
|
||||
|
||||
|
||||
def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]:
|
||||
kinds: set[str] = set()
|
||||
for start, end, kind in scope_kinds:
|
||||
if start <= line_idx <= end and start != line_idx:
|
||||
kinds.add(kind)
|
||||
return kinds
|
||||
|
||||
|
||||
def collect_interface_summaries(files: list[Path]) -> set[str]:
|
||||
summaries: set[str] = set()
|
||||
for path in files:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
depths = depth_snapshot(lines)
|
||||
scopes = type_scopes(lines)
|
||||
idx = 0
|
||||
while idx < len(lines):
|
||||
if not lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
continue
|
||||
start = idx
|
||||
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
block = lines[start:idx]
|
||||
kinds = containing_kinds(scopes, start, depths[start])
|
||||
if "interface" in kinds and is_summary_only(block):
|
||||
summaries.add(normalize(block))
|
||||
return summaries
|
||||
|
||||
|
||||
def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
depths = depth_snapshot(lines)
|
||||
scopes = type_scopes(lines)
|
||||
result: list[str] = []
|
||||
replaced = 0
|
||||
idx = 0
|
||||
while idx < len(lines):
|
||||
if not lines[idx].lstrip().startswith("///"):
|
||||
result.append(lines[idx])
|
||||
idx += 1
|
||||
continue
|
||||
start = idx
|
||||
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
block = lines[start:idx]
|
||||
kinds = containing_kinds(scopes, start, depths[start])
|
||||
if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries:
|
||||
indent = block[0][: len(block[0]) - len(block[0].lstrip())]
|
||||
result.append(f"{indent}/// <inheritdoc />")
|
||||
replaced += 1
|
||||
else:
|
||||
result.extend(block)
|
||||
return replaced, result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
apply = "--apply" in sys.argv
|
||||
files = iter_files()
|
||||
iface_summaries = collect_interface_summaries(files)
|
||||
total = 0
|
||||
changed = 0
|
||||
for path in files:
|
||||
replaced, new_lines = process_file(path, iface_summaries)
|
||||
if not replaced:
|
||||
continue
|
||||
total += replaced
|
||||
changed += 1
|
||||
if apply:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "")
|
||||
path.write_text(text, encoding="utf-8")
|
||||
mode = "применено" if apply else "dry-run"
|
||||
print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Codemod: неявные реализации интерфейсов -> явные (§11, вариант A — прод).
|
||||
|
||||
Только production-код (/tests/ не трогает). Card (DTO) пропускается.
|
||||
Логика: таблицы членов интерфейсов -> маппинг класс -> интерфейсы (включая partial) ->
|
||||
конвертация public-члена с совпавшим именем и сигнатурой в `Тип IFoo.Член`.
|
||||
Ошибочный конверсионный файл откатывается компиляторным циклом (git checkout).
|
||||
|
||||
Запуск: python scripts/make_explicit.py [--apply] [--file <путь>]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
ROOTS = [REPO / "src" / "core", REPO / "src" / "telegram-service", REPO / "src" / "ai-service",
|
||||
REPO / "src" / "ml-service", REPO / "src" / "storage-service", REPO / "src" / "grpc-hosting"]
|
||||
# файлы-исключения: DTO/модели, члены которых — собственный публичный API
|
||||
EXCLUDE_FILES = {"src/core/Deal.Modules.Cards/Application/Models/Card.cs"}
|
||||
|
||||
IFACE_DECL = re.compile(
|
||||
r"^\s*(?:public\s+|internal\s+)?(?:partial\s+|sealed\s+|static\s+|unsafe\s+)*interface\s+(\w+)", re.M)
|
||||
CLASS_DECL = re.compile(
|
||||
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
|
||||
r"class\s+(\w+)(?:<[^>]*>)?[^{;]*:\s*([^{;]+)", re.M)
|
||||
PARTIAL_CLASS = re.compile(
|
||||
r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*"
|
||||
r"class\s+(\w+)(?:<[^>]*>)?\s*(?::[^{;]+)?\{", re.M)
|
||||
IFNAME = re.compile(r"\bI[A-Z]\w*")
|
||||
METHOD_DECL = re.compile(
|
||||
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
|
||||
r"virtual\s+|override\s+|sealed\s+|async\s+|new\s+)*)"
|
||||
r"(?P<ret>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*(?:<[^>]*>)?\s*\(")
|
||||
PROP_DECL = re.compile(
|
||||
r"^(?P<indent>\s*)(?P<mods>(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|"
|
||||
r"virtual\s+|override\s+|sealed\s+|new\s+)*)"
|
||||
r"(?P<type>[\w<>\[\],\s.?]+?)\s(?P<name>\w+)\s*\{\s*(?P<accessors>(?:public\s+)?(?:get|set|init)[^}]*)\}")
|
||||
|
||||
|
||||
def src_files() -> list[Path]:
|
||||
out = []
|
||||
for root in ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for p in root.rglob("*.cs"):
|
||||
s = p.as_posix().lower()
|
||||
if "/bin/" in s or "/obj/" in s or "/tests/" in s or ".tests/" in s:
|
||||
continue
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def interface_body(text: str, start: int) -> str:
|
||||
b = text.find("{", start)
|
||||
if b == -1:
|
||||
return ""
|
||||
depth, end = 0, b
|
||||
for i in range(b, len(text)):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i
|
||||
break
|
||||
return text[b:end]
|
||||
|
||||
|
||||
def norm_types(params: str) -> tuple[str, ...]:
|
||||
if not params.strip():
|
||||
return ()
|
||||
out, depth, cur = [], 0, ""
|
||||
for ch in params:
|
||||
if ch in "<([":
|
||||
depth += 1
|
||||
elif ch in ">)]":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
out.append(cur)
|
||||
cur = ""
|
||||
else:
|
||||
cur += ch
|
||||
out.append(cur)
|
||||
res = []
|
||||
for raw in out:
|
||||
t = re.sub(r"\s+", " ", raw.strip())
|
||||
parts = t.rsplit(" ", 1)
|
||||
if len(parts) == 2 and not re.search(r"[<>\[\](),]", parts[1]) and parts[1] not in ("*", "&"):
|
||||
t = parts[0]
|
||||
res.append(t.replace("?", "").replace(" ", ""))
|
||||
return tuple(res)
|
||||
|
||||
|
||||
def collect_interfaces(files: list[Path]) -> dict[str, dict[str, dict]]:
|
||||
tables: dict[str, dict[str, dict]] = {}
|
||||
for p in files:
|
||||
text = p.read_text(encoding="utf-8")
|
||||
for m in IFACE_DECL.finditer(text):
|
||||
name = m.group(1)
|
||||
body = interface_body(text, m.start())
|
||||
lines = body.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
mm = METHOD_DECL.match(line)
|
||||
if mm and "(" in line:
|
||||
# сигнатура может быть многострочной (§7: параметры на отдельных строках)
|
||||
sig_text = line
|
||||
while sig_text.count("(") > sig_text.count(")") and i + 1 < len(lines):
|
||||
i += 1
|
||||
sig_text += " " + lines[i].strip()
|
||||
if sig_text.count("(") > sig_text.count(")"):
|
||||
i += 1
|
||||
continue
|
||||
params = sig_text.split("(", 1)[1].rsplit(")", 1)[0]
|
||||
tables.setdefault(name, {})[mm.group("name")] = {"kind": "method", "sig": norm_types(params)}
|
||||
i += 1
|
||||
continue
|
||||
pm = PROP_DECL.match(line)
|
||||
if pm:
|
||||
tables.setdefault(name, {})[pm.group("name")] = {
|
||||
"kind": "prop", "sig": pm.group("type").replace("?", "").replace(" ", "")}
|
||||
i += 1
|
||||
return tables
|
||||
|
||||
|
||||
def collect_class_ifaces(files: list[Path]) -> dict[str, set[str]]:
|
||||
result: dict[str, set[str]] = {}
|
||||
for p in files:
|
||||
rel = p.relative_to(REPO).as_posix()
|
||||
if rel in EXCLUDE_FILES:
|
||||
continue
|
||||
text = p.read_text(encoding="utf-8")
|
||||
for m in CLASS_DECL.finditer(text):
|
||||
ifaces = set(IFNAME.findall(m.group(2)))
|
||||
if ifaces:
|
||||
result.setdefault(m.group(1), set()).update(ifaces)
|
||||
return result
|
||||
|
||||
|
||||
def convert_file(path: Path, cls_ifaces: dict[str, set[str]], itables: dict[str, dict[str, dict]]):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
local: set[str] = set()
|
||||
for m in PARTIAL_CLASS.finditer(text):
|
||||
if m.group(1) in cls_ifaces:
|
||||
local.add(m.group(1))
|
||||
if not local:
|
||||
return 0, None, []
|
||||
allowed = {i for cls in local for i in cls_ifaces[cls] if i in itables}
|
||||
|
||||
changed, notes = 0, []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("//") or stripped.startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
mm = METHOD_DECL.match(line.rstrip("\r\n"))
|
||||
pm = None if mm else PROP_DECL.match(line.rstrip("\r\n"))
|
||||
if not mm and not pm:
|
||||
i += 1
|
||||
continue
|
||||
kind = "method" if mm else "prop"
|
||||
decl = mm or pm
|
||||
mods = decl.group("mods")
|
||||
if "static" in mods or "public" not in mods:
|
||||
i += 1
|
||||
continue
|
||||
name = decl.group("name")
|
||||
if kind == "method":
|
||||
sig_text, j = line, i
|
||||
while sig_text.count("(") > sig_text.count(")") and j + 1 < len(lines):
|
||||
j += 1
|
||||
sig_text += lines[j]
|
||||
if "(" not in sig_text:
|
||||
i += 1
|
||||
continue
|
||||
sig = norm_types(sig_text.split("(", 1)[1].rsplit(")", 1)[0])
|
||||
else:
|
||||
sig = decl.group("type").replace("?", "").replace(" ", "")
|
||||
j = i
|
||||
cands = [ifc for ifc in allowed
|
||||
if itables[ifc].get(name, {}).get("kind") == kind and itables[ifc][name]["sig"] == sig]
|
||||
if not cands:
|
||||
i += 1
|
||||
continue
|
||||
ifc = cands[0]
|
||||
head = line.rstrip("\r\n")
|
||||
eol = line[len(head):]
|
||||
# убрать модификаторы (оставить отступ), затем квалифицировать имя
|
||||
new_head = re.sub(r"^(\s*)(?:public\s+|internal\s+|protected\s+|private\s+|virtual\s+|"
|
||||
r"override\s+|sealed\s+|new\s+)+", r"\1", head)
|
||||
m2 = re.search(r"\s" + re.escape(name) + r"(?![\w])", new_head)
|
||||
if not m2:
|
||||
i += 1
|
||||
continue
|
||||
new_head = new_head[:m2.start()] + " " + ifc + "." + name + new_head[m2.end():]
|
||||
lines[i] = new_head + eol
|
||||
changed += 1
|
||||
notes.append(f"{path.relative_to(REPO).as_posix()}:{i + 1} -> {ifc}.{name}")
|
||||
i = j + 1
|
||||
return changed, ("".join(lines) if changed else None), notes
|
||||
|
||||
|
||||
def main() -> int:
|
||||
apply = "--apply" in sys.argv
|
||||
only = sys.argv[sys.argv.index("--file") + 1] if "--file" in sys.argv else None
|
||||
files = src_files()
|
||||
itables = collect_interfaces(files)
|
||||
cls_ifaces = collect_class_ifaces(files)
|
||||
total, per_file = 0, 0
|
||||
for p in files:
|
||||
if only and p.as_posix() != only:
|
||||
continue
|
||||
changed, new_text, notes = convert_file(p, cls_ifaces, itables)
|
||||
if not changed:
|
||||
continue
|
||||
total += changed
|
||||
per_file += 1
|
||||
if apply and new_text is not None:
|
||||
p.write_bytes(new_text.replace("\r\n", "\n").encode("utf-8"))
|
||||
if not apply:
|
||||
for n in notes[:3]:
|
||||
print(" ", n)
|
||||
print(f"{'apply' if apply else 'dry-run'}: конверсий {total} в {per_file} файлах")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -14,6 +14,7 @@
|
||||
<ItemGroup>
|
||||
<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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+1
-3
@@ -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()
|
||||
|
||||
+12
-11
@@ -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_<id> (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)
|
||||
{
|
||||
|
||||
+1
-1
@@ -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 _);
|
||||
|
||||
+1
-1
@@ -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)
|
||||
{
|
||||
|
||||
+1
-1
@@ -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)
|
||||
{
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Settings;
|
||||
/// <summary>
|
||||
/// Маркер модуля Settings
|
||||
/// </summary>
|
||||
public sealed class SettingsModuleMarker
|
||||
public interface ISettingsModule
|
||||
{
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@ namespace Deal.Modules.Tenants;
|
||||
/// <summary>
|
||||
/// Маркер модуля Tenants
|
||||
/// </summary>
|
||||
public sealed class TenantsModuleMarker
|
||||
public interface ITenantsModule
|
||||
{
|
||||
}
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user